Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5694671227
|
||
|
|
787a227602
|
No files matched your search
@@ -225,3 +225,38 @@ Saved views and legacy stored visibility (including `{}`, meaning all base colum
|
||||
are preserved. New View and Reset All Changes on an unsaved view restore these
|
||||
metadata defaults. Quick search searches only visible string fields. Responsive
|
||||
card/grid behavior is unchanged.
|
||||
## Default sort
|
||||
|
||||
A record list opens sorted by primary key, descending. An application can declare another
|
||||
sort for a table (newest first by a date field, for example) in the same hint object:
|
||||
|
||||
```json
|
||||
{
|
||||
"supplementalMetaData": {
|
||||
"nextUi": {
|
||||
"defaultSort": [
|
||||
{ "fieldName": "receivedAt", "isAscending": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`defaultSort` is an array of order-bys in the shape a query filter uses (`QFilterOrderBy`):
|
||||
`fieldName` is a field of the table, or of an exposed join as `joinTable.fieldName`, and
|
||||
`isAscending` is a boolean (ascending when absent). Several entries sort by the first, then
|
||||
the next. The legacy `supplementalTableMetaData.nextUi.defaultSort` path is also accepted.
|
||||
|
||||
The declared sort is the table's default, not a setting that overrides people: a sort in the
|
||||
address, the sort of a saved view, and the sort the browser remembers for the table from an
|
||||
earlier visit are each used instead. New View and Reset All Changes return to the declared
|
||||
sort, and it is not counted as an unsaved change. An entry that names an unknown field, a
|
||||
field the user may not sort by, or carries a non-boolean `isAscending` is ignored; when no
|
||||
entry is usable, or the hint is malformed, the list sorts by primary key, descending, as
|
||||
before.
|
||||
|
||||
QQQ has no table-level default sort of its own (as of 4.1.0-RC.32), so the application supplies
|
||||
the hint: a `QSupplementalTableMetaData` subclass whose `getType()` is `nextUi`, that returns
|
||||
`true` from `includeInFullFrontendMetaData()` and exposes `getDefaultSort()` as a list of
|
||||
`QFilterOrderBy` (and `getDefaultColumns()` for the hint above), attached to the table with
|
||||
`withSupplementalMetaData(...)`. The table metadata response publishes it as shown.
|
||||
@@ -13,6 +13,7 @@
|
||||
- One `UserAvatar` (photo via one batched `POST /avatars/lookup` and `GET /avatars/{avatarId}`, otherwise initials) shows people in the user menu, presence, comments, mention candidates, the people picker, activity and your own assistant messages. A missing avatar is remembered for the session, so it isn't requested again.
|
||||
- **Privacy:** branding `gravatarDefault` no longer sends your email hash to Gravatar. A Gravatar appears only if you turn it on in your Profile, and the server serves it.
|
||||
- Image fields take a full form row at every width. e2e coverage is `tests/e2e/profile-avatars.spec.ts`, including the 6-width light/dark screenshot matrix.
|
||||
- **A table can declare the sort its record list opens with.** The list's initial sort was fixed at primary key descending and read nothing from the table's metadata, so an application could not make a list open newest first by a date field. A table's Next UI hint now takes `defaultSort` beside `defaultColumns`: `supplementalMetaData.nextUi.defaultSort`, an array of `{ "fieldName", "isAscending" }` in the shape of a query filter's order-bys. The list opens with it when the address carries no sort, no saved view is open and the browser remembers no sort for the table; each of those wins when present. New View and Reset All Changes return to it, and it is not counted as an unsaved change. Unknown or unsortable fields are ignored, and a table without a usable hint sorts by primary key descending as before. QQQ itself has no table-level default sort (as of 4.1.0-RC.32), so the application supplies the hint through a supplemental table metadata object of type `nextUi`; see the README, [Default sort](../../README.md#default-sort).
|
||||
|
||||
## Product fixes
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// The sort a record list opens with: the one its table declares
|
||||
// (`supplementalMetaData.nextUi.defaultSort`), unless the person has a sort of their own (in the
|
||||
// address, in a saved view, or remembered by the browser), and primary key descending otherwise.
|
||||
|
||||
import { act, render, renderHook, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QFilterOrderBy, QTableMetaData } from '@/types'
|
||||
import type { QueryRecordsRequest } from '@/lib/api/tables'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { useRecordQuery } from '@/lib/hooks/use-record-query'
|
||||
import { emptyFilter, serializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { defaultColumnVisibility } from '@/lib/utils/query-columns'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { RecordQuery } from './RecordQuery'
|
||||
|
||||
let search = ''
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
usePathname: () => '/app/person',
|
||||
useSearchParams: () => new URLSearchParams(search),
|
||||
}))
|
||||
|
||||
const NEWEST_FIRST: QFilterOrderBy[] = [{ fieldName: 'modifyDate', isAscending: false }]
|
||||
const PRIMARY_KEY_DESCENDING: QFilterOrderBy[] = [{ fieldName: 'id', isAscending: false }]
|
||||
|
||||
/**
|
||||
* The people table, declaring a default sort unless told otherwise.
|
||||
*
|
||||
* @param defaultSort - The declared sort; `null` for a table that declares none.
|
||||
* @returns Table metadata and the table registry.
|
||||
*/
|
||||
function tables(defaultSort: unknown = NEWEST_FIRST) {
|
||||
const allTables = structuredClone(qInstance.tables)
|
||||
if (defaultSort !== null) allTables.person.supplementalMetaData = { nextUi: { defaultSort } }
|
||||
return { tableName: 'person', tableMetaData: allTables.person as QTableMetaData, allTables }
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return <QueryClientProvider client={client}><QContextProvider>{children}</QContextProvider></QueryClientProvider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what the list asks the server for.
|
||||
*
|
||||
* @returns The sort of the latest query request.
|
||||
*/
|
||||
function captureSort() {
|
||||
const sorts: (QFilterOrderBy[] | undefined)[] = []
|
||||
server.use(http.post('/qqq/v1/table/person/:action', async ({ request, params }) => {
|
||||
const body = await request.json() as QueryRecordsRequest
|
||||
if (params.action === 'query') sorts.push(body.filter.orderBys)
|
||||
return HttpResponse.json(params.action === 'query' ? { records: [{ tableName: 'person', recordLabel: 'Alice', values: { id: 1, firstName: 'Alice' } }] } : { count: 1 })
|
||||
}))
|
||||
return () => sorts.at(-1)
|
||||
}
|
||||
|
||||
/** The sort the address carries, if any. */
|
||||
const addressSort = () => {
|
||||
const encoded = new URLSearchParams(window.location.search).get('filter')
|
||||
return encoded ? (JSON.parse(atob(encoded)) as { orderBys?: QFilterOrderBy[] }).orderBys : undefined
|
||||
}
|
||||
|
||||
describe('record list default sort', () => {
|
||||
beforeEach(() => {
|
||||
search = ''
|
||||
localStorage.clear()
|
||||
window.history.replaceState(null, '', '/app/person')
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('opens with the sort the table declares, without putting it in the address', async () => {
|
||||
const lastSort = captureSort()
|
||||
render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
expect(lastSort()).toEqual(NEWEST_FIRST)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
|
||||
expect(screen.getByRole('combobox', { name: 'Sort field' })).toHaveValue('modifyDate')
|
||||
// a default is not a choice: nothing to share in the address, nothing to save or reset
|
||||
expect(addressSort()).toBeUndefined()
|
||||
expect(screen.queryByRole('button', { name: 'Reset All Changes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens by primary key descending when the table declares nothing, or nothing usable', async () => {
|
||||
for (const declared of [null, [], [{ fieldName: 'noSuchField', isAscending: false }], 'modifyDate']) {
|
||||
const lastSort = captureSort()
|
||||
const view = render(<RecordQuery {...tables(declared)} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
expect(lastSort()).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(addressSort()).toBeUndefined()
|
||||
view.unmount()
|
||||
localStorage.clear()
|
||||
}
|
||||
})
|
||||
|
||||
it('a sort in the address wins over the declared one', async () => {
|
||||
const mine = [{ fieldName: 'lastName', isAscending: true }]
|
||||
search = new URLSearchParams({ filter: serializeFilter({ criteria: [], booleanOperator: 'AND', orderBys: mine }) }).toString()
|
||||
const lastSort = captureSort()
|
||||
render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
expect(lastSort()).toEqual(mine)
|
||||
})
|
||||
|
||||
it("a person's own sort is kept, remembered for the next visit, and Reset returns to the declared one", async () => {
|
||||
const user = userEvent.setup()
|
||||
const lastSort = captureSort()
|
||||
const first = render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
|
||||
const mode = within(screen.getByRole('group', { name: 'Filter mode' }))
|
||||
await user.selectOptions(mode.getByRole('combobox', { name: 'Sort field' }), 'lastName')
|
||||
await waitFor(() => expect(lastSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
|
||||
// now it is a choice: it is in the address, and it can be reset
|
||||
await waitFor(() => expect(addressSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
|
||||
expect(screen.getByRole('button', { name: 'Reset All Changes' })).toBeInTheDocument()
|
||||
|
||||
// the next visit (no sort in the address) opens with the remembered sort, not the declared one
|
||||
first.unmount()
|
||||
window.history.replaceState(null, '', '/app/person')
|
||||
render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
await waitFor(() => expect(lastSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Reset All Changes' }))
|
||||
await waitFor(() => expect(lastSort()).toEqual(NEWEST_FIRST))
|
||||
await waitFor(() => expect(addressSort()).toBeUndefined())
|
||||
expect(screen.queryByRole('button', { name: 'Reset All Changes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("a saved view's sort wins, when the view is opened and when it is applied later", () => {
|
||||
const { tableMetaData, allTables } = tables()
|
||||
const viewSort = [{ fieldName: 'firstName', isAscending: true }]
|
||||
const view = {
|
||||
userFilter: emptyFilter(50), sortOrder: viewSort, columnVisibility: defaultColumnVisibility(tableMetaData),
|
||||
columnOrder: [], columnWidths: {}, pageSize: 50, filterMode: 'basic' as const,
|
||||
}
|
||||
const opened = renderHook(() => useRecordQuery({ tableName: 'person', tableMetaData, allTables, initialView: view, paused: true }), { wrapper })
|
||||
expect(opened.result.current.filter.sortOrder).toEqual(viewSort)
|
||||
expect(opened.result.current.filter.defaultSort).toEqual(NEWEST_FIRST)
|
||||
|
||||
const applied = renderHook(() => useRecordQuery({ tableName: 'person', tableMetaData, allTables, paused: true }), { wrapper })
|
||||
expect(applied.result.current.filter.sortOrder).toEqual(NEWEST_FIRST)
|
||||
act(() => applied.result.current.applyView(view))
|
||||
expect(applied.result.current.filter.sortOrder).toEqual(viewSort)
|
||||
// clearing a sort returns to the declared default, not to the primary key
|
||||
act(() => applied.result.current.filter.setSort([]))
|
||||
expect(applied.result.current.filter.sortOrder).toEqual(NEWEST_FIRST)
|
||||
})
|
||||
|
||||
it('keeps the same default sort when metadata is loaded again with the same declaration', () => {
|
||||
const { tableMetaData, allTables } = tables()
|
||||
const hook = renderHook(({ table }) => useRecordQuery({ tableName: 'person', tableMetaData: table, allTables, paused: true }), { wrapper, initialProps: { table: tableMetaData } })
|
||||
const before = hook.result.current.filter.defaultSort
|
||||
hook.rerender({ table: structuredClone(tableMetaData) })
|
||||
expect(hook.result.current.filter.defaultSort).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ import { isColumnVisible, type ViewState } from '@/lib/utils/saved-view-utils'
|
||||
import { getDefaultQuickFilterFieldNames, reconcileBasicMode } from '@/lib/utils/quick-filter-utils'
|
||||
import { defaultColumnVisibility, hasCapability, withReadableExposedJoins, type ColumnPins } from '@/lib/utils/query-columns'
|
||||
import { DENSITY_STORAGE_KEY, readLegacyColumnState } from '@/lib/utils/query-view-storage'
|
||||
import { tableDefaultSort } from '@/lib/utils/query-default-sort'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { useColumnConfig } from '@/lib/hooks/use-column-config'
|
||||
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/lib/constants'
|
||||
@@ -320,9 +321,13 @@ export function useRecordQuery({
|
||||
const [storedDensity, setDensity] = useLocalStorage<Density>(DENSITY_STORAGE_KEY, 'standard')
|
||||
const density: Density = (['compact', 'standard', 'comfortable'] as const).includes(storedDensity) ? storedDensity : 'standard'
|
||||
|
||||
const primaryKey = tableMetaData?.primaryKeyField
|
||||
/** Material's default sort: primary key, descending. */
|
||||
const defaultSort = useMemo<QFilterOrderBy[]>(() => (primaryKey ? [{ fieldName: primaryKey, isAscending: false }] : []), [primaryKey])
|
||||
/**
|
||||
* The sort the list opens with and resets to: the one the table's metadata declares
|
||||
* (`supplementalMetaData.nextUi.defaultSort`), else Material's default, primary key descending.
|
||||
* Keyed by its content, so reloaded metadata that declares the same sort changes nothing.
|
||||
*/
|
||||
const defaultSortKey = JSON.stringify(tableDefaultSort(tableMetaData))
|
||||
const defaultSort = useMemo<QFilterOrderBy[]>(() => JSON.parse(defaultSortKey) as QFilterOrderBy[], [defaultSortKey])
|
||||
/** Quick filters every user sees in basic mode (Material defaultQuickFilterFieldNames, else T1 fields). */
|
||||
const defaultQuickFilterFieldNames = useMemo(() => (tableMetaData ? getDefaultQuickFilterFieldNames(tableMetaData) : []), [tableMetaData])
|
||||
const filterModeKey = `qqq-${tableName}-filter-mode`
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { declaredDefaultSort, tableDefaultSort } from './query-default-sort'
|
||||
|
||||
const PRIMARY_KEY_DESCENDING = [{ fieldName: 'id', isAscending: false }]
|
||||
|
||||
/**
|
||||
* The people table with a Next UI hint.
|
||||
*
|
||||
* @param nextUi - The hint object.
|
||||
* @param legacy - Put it under the legacy `supplementalTableMetaData` key.
|
||||
* @returns Table metadata.
|
||||
*/
|
||||
function person(nextUi?: unknown, legacy = false): QTableMetaData {
|
||||
const table = structuredClone(qInstance.tables.person)
|
||||
if (nextUi !== undefined) table[legacy ? 'supplementalTableMetaData' : 'supplementalMetaData'] = { nextUi } as Record<string, unknown>
|
||||
return table
|
||||
}
|
||||
|
||||
describe('tableDefaultSort', () => {
|
||||
it('is primary key descending for a table that declares nothing', () => {
|
||||
expect(tableDefaultSort(person())).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(tableDefaultSort(person({ defaultColumns: ['id', 'firstName'] }))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(declaredDefaultSort(person())).toEqual([])
|
||||
})
|
||||
|
||||
it('is empty before metadata loads and for a table without a primary key', () => {
|
||||
expect(tableDefaultSort(undefined)).toEqual([])
|
||||
expect(tableDefaultSort({ ...person(), primaryKeyField: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('honours a declared sort, in order, with ascending as the direction when none is given', () => {
|
||||
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'modifyDate', isAscending: false }] }))).toEqual([{ fieldName: 'modifyDate', isAscending: false }])
|
||||
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'lastName' }, { fieldName: 'firstName', isAscending: true }, { fieldName: 'id', isAscending: false }] }))).toEqual([
|
||||
{ fieldName: 'lastName', isAscending: true },
|
||||
{ fieldName: 'firstName', isAscending: true },
|
||||
{ fieldName: 'id', isAscending: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the legacy supplemental key, and prefers the current one', () => {
|
||||
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'lastName', isAscending: true }] }, true))).toEqual([{ fieldName: 'lastName', isAscending: true }])
|
||||
const both = person({ defaultSort: [{ fieldName: 'firstName', isAscending: true }] })
|
||||
both.supplementalTableMetaData = { nextUi: { defaultSort: [{ fieldName: 'lastName', isAscending: true }] } }
|
||||
expect(tableDefaultSort(both)).toEqual([{ fieldName: 'firstName', isAscending: true }])
|
||||
})
|
||||
|
||||
it('accepts a field of an exposed join', () => {
|
||||
const table = person()
|
||||
const join = table.exposedJoins?.find((candidate) => Object.keys(candidate.joinTable?.fields ?? {}).length > 0)
|
||||
const fieldName = `${join?.joinTable?.name}.${Object.keys(join?.joinTable?.fields ?? {})[0]}`
|
||||
expect(join).toBeTruthy()
|
||||
table.supplementalMetaData = { nextUi: { defaultSort: [{ fieldName, isAscending: false }] } }
|
||||
expect(tableDefaultSort(table)).toEqual([{ fieldName, isAscending: false }])
|
||||
})
|
||||
|
||||
it('drops entries it cannot use and keeps the rest', () => {
|
||||
const table = person({
|
||||
defaultSort: [
|
||||
{ fieldName: 'noSuchField', isAscending: false },
|
||||
{ fieldName: 'lastName', isAscending: 'yes' },
|
||||
{ isAscending: false },
|
||||
'modifyDate',
|
||||
null,
|
||||
['modifyDate', false],
|
||||
{ fieldName: 'modifyDate', isAscending: false },
|
||||
{ fieldName: 'modifyDate', isAscending: true },
|
||||
{ fieldName: 'noSuchJoin.name', isAscending: true },
|
||||
],
|
||||
})
|
||||
expect(tableDefaultSort(table)).toEqual([{ fieldName: 'modifyDate', isAscending: false }])
|
||||
})
|
||||
|
||||
it('does not sort by a field the user may not sort by', () => {
|
||||
const table = person({ defaultSort: [{ fieldName: 'email', isAscending: true }, { fieldName: 'lastName', isAscending: true }] })
|
||||
table.fields.email = { ...table.fields.email, isSortable: false }
|
||||
expect(tableDefaultSort(table)).toEqual([{ fieldName: 'lastName', isAscending: true }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an empty list', []],
|
||||
['a single object', { fieldName: 'modifyDate', isAscending: false }],
|
||||
['a string', 'modifyDate'],
|
||||
['null', null],
|
||||
['only unusable entries', [{ fieldName: 'noSuchField' }, { fieldName: 'lastName', isAscending: 1 }]],
|
||||
])('falls back to primary key descending for %s', (_what, defaultSort) => {
|
||||
expect(tableDefaultSort(person({ defaultSort }))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
})
|
||||
|
||||
it('falls back when the hint itself is not an object', () => {
|
||||
expect(tableDefaultSort(person('newest'))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(tableDefaultSort(person(null))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file query-default-sort — the sort a table's record list opens with.
|
||||
*
|
||||
* A table can declare it, beside its default columns, in the Next UI hint of its supplemental
|
||||
* metadata:
|
||||
*
|
||||
* ```json
|
||||
* "supplementalMetaData": { "nextUi": { "defaultSort": [{ "fieldName": "receivedAt", "isAscending": false }] } }
|
||||
* ```
|
||||
*
|
||||
* Each entry has the shape of an order-by in a query filter (`QFilterOrderBy`). Without a usable
|
||||
* declaration the list sorts by primary key, descending, as it always has.
|
||||
*/
|
||||
|
||||
import type { QFilterOrderBy, QTableMetaData } from '@/types'
|
||||
|
||||
import { resolveField } from './filter-utils'
|
||||
import { canSortField } from './secured-data'
|
||||
|
||||
/**
|
||||
* The sort a table's metadata declares for its record list, validated: an entry is kept when its
|
||||
* `fieldName` names a field of the table (or of an exposed join, as `joinTable.field`) that the
|
||||
* user may sort by, and its `isAscending` is a boolean or absent (ascending, as for an order-by).
|
||||
* A field named twice counts once. Anything else is dropped.
|
||||
*
|
||||
* @param table - Table metadata.
|
||||
* @returns The declared order-bys that can be used; empty when the table declares none.
|
||||
*/
|
||||
export function declaredDefaultSort(table: QTableMetaData): QFilterOrderBy[] {
|
||||
const hint = (table.supplementalMetaData?.nextUi ?? table.supplementalTableMetaData?.nextUi) as { defaultSort?: unknown } | null | undefined
|
||||
const declared = hint && typeof hint === 'object' ? hint.defaultSort : undefined
|
||||
if (!Array.isArray(declared)) return []
|
||||
const orderBys: QFilterOrderBy[] = []
|
||||
for (const entry of declared) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||
const { fieldName, isAscending } = entry as Record<string, unknown>
|
||||
if (typeof fieldName !== 'string' || (isAscending !== undefined && typeof isAscending !== 'boolean')) continue
|
||||
if (orderBys.some((orderBy) => orderBy.fieldName === fieldName)) continue
|
||||
const field = resolveField(table, fieldName)?.field
|
||||
if (!field || !canSortField(field)) continue
|
||||
orderBys.push({ fieldName, isAscending: isAscending ?? true })
|
||||
}
|
||||
return orderBys
|
||||
}
|
||||
|
||||
/**
|
||||
* The sort a table's record list opens with, and returns to on a reset: the one the table
|
||||
* declares, else its primary key, descending (Material's default).
|
||||
*
|
||||
* A person's own choice always comes first: a sort in the address, the sort of a saved view, or
|
||||
* the sort the browser remembers for the table is used instead of this one.
|
||||
*
|
||||
* @param table - Table metadata, when loaded.
|
||||
* @returns The default order-bys; empty only for a table without a primary key.
|
||||
*/
|
||||
export function tableDefaultSort(table: QTableMetaData | undefined): QFilterOrderBy[] {
|
||||
if (!table) return []
|
||||
const declared = declaredDefaultSort(table)
|
||||
if (declared.length > 0) return declared
|
||||
return table.primaryKeyField ? [{ fieldName: table.primaryKeyField, isAscending: false }] : []
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type { QFieldMetaData, QFilterCriteria, QFilterOrderBy, QQueryFilter, QTa
|
||||
import { formatDateTime } from './datetime-utils'
|
||||
import { normalizeFilter, isCriterionComplete, emptyFilter, isFilterExpression, describeExpression, resolveField } from './filter-utils'
|
||||
import { arrangePinnedColumns, effectivePins, getQueryColumns, orderColumns, type ColumnPin, type ColumnPins } from './query-columns'
|
||||
import { tableDefaultSort } from './query-default-sort'
|
||||
|
||||
/** One column entry in a saved view (Material `QQueryColumns.Column`). */
|
||||
export interface SavedViewColumn {
|
||||
@@ -634,9 +635,8 @@ export function reconcileView(table: QTableMetaData, view: RecordQueryView): Rec
|
||||
removedFields.add(orderBy.fieldName)
|
||||
return false
|
||||
})
|
||||
if (savedOrderBys.length > 0 && orderBys.length === 0 && table.primaryKeyField) {
|
||||
orderBys.push({ fieldName: table.primaryKeyField, isAscending: false })
|
||||
}
|
||||
// a view whose every sort field is gone falls back to the table's default sort
|
||||
if (savedOrderBys.length > 0 && orderBys.length === 0) orderBys.push(...tableDefaultSort(table))
|
||||
const quickFilterFieldNames = (view.quickFilterFieldNames ?? []).filter((name) => {
|
||||
if (exists(name)) return true
|
||||
removedFields.add(name)
|
||||
|
||||
Reference in new issue
Block a user