Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.2 5694671227 feat(query): a table can declare the sort its record list opens with
maven / build (push) Successful in 11m7s
build / maven (push) Successful in 11m7s
The record list fixed its initial sort at primary key descending
(use-record-query) and read no default from the table metadata, so an
application could not make a list open newest first by a date field.

QQQ core serves no table-level default sort or default view (checked at
4.1.0-RC.32: the table metadata has built-in views only for record ownership,
and no order-by). It does publish a table's supplemental metadata, where this
frontend already reads a hint object, supplementalMetaData.nextUi, for
defaultColumns. The contract is one more key in that object:

  "supplementalMetaData": { "nextUi": { "defaultSort": [
    { "fieldName": "receivedAt", "isAscending": false } ] } }

Each entry has the shape of a query filter order-by (QFilterOrderBy);
isAscending defaults to ascending. Entries naming an unknown field, a field
the user may not sort by, or with a non-boolean direction are ignored; with no
usable entry the default stays primary key descending.

The declared sort replaces the one default the hook already had, so every use
follows it: the list opens with it, the address omits it, New View and Reset
All Changes return to it, clearing a sort returns to it, and it is not an
unsaved change. A sort in the address, a saved view's sort, and the sort the
browser remembers for the table each win over it.

Documented in the README under "Default sort", including what an application
adds on the backend to fill the hint.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 03:43:12 -06:00
7 changed files with 416 additions and 6 deletions

No files matched your search

+35
View File
@@ -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.
+1
View File
@@ -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)
})
})
+8 -3
View File
@@ -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`
+112
View File
@@ -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)
})
})
+77
View File
@@ -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 }] : []
}
+3 -3
View File
@@ -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)