Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2752ac74a
|
No files matched your search
@@ -225,38 +225,3 @@ 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,7 +13,6 @@
|
||||
- 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
|
||||
|
||||
@@ -21,6 +20,8 @@
|
||||
- **Preferences says how a connection ended.** A flow that left the dashboard returns to `/app/preferences?section=<name>&result=<code>` when it succeeded or `&error=<code>` when it did not. The section now shows the outcome once, above its form, as the section's label and the code as words (`Slack: connected.`, `Slack: authorization denied.`), as a status or an alert, and takes it off the address so a reload does not repeat it. Only a fixed lowercase code is read from the address, never a sentence.
|
||||
- **Links in process HTML look like links.** An `HTML` process component rendered links in the text color with no underline, so a link the backend put on a screen (a bulk load template, a "Continue to Slack" link) could not be told from the words around it. They now use the link color and an underline; a link's own inline style still wins.
|
||||
|
||||
- **Explore views leave a way back.** Pivot, Compare, Correlation, Crosstab, Shift over time and a saved analysis are states of a table's record list, named by the query string (`?view=correlation`), so the breadcrumb, which read only the path, kept showing the table as the current page and offered no link back to the list. The breadcrumb now ends with the view (`… / People / Correlation`) and the table before it links to the list with its filter, sort, page and quick search as they were. "Back to records" is one step back in history when the list it returns to is the one the view was opened from, so the view stays one Forward away and the browser's Back button no longer meets the same list twice; after a drill changed the filter, or on a link straight to a view, it replaces the view with the list in place, as before. A Back or Forward step between the list and a view now takes the page and page size from the address as well as the filter and search, instead of returning the list to page 1. The views are registered in one place (`src/lib/utils/table-sub-views.ts`), which the list, the breadcrumb and the tests read, so a new view cannot ship without a label and a way back.
|
||||
|
||||
- **The Ask button's activity dot sits on its icon.** While a reply was being written, the dot was placed in the bottom-right corner of the button's hit area, which has no visible edge and, from 1440 px, extends past the "Ask" label, so the dot floated under and to the right of the label. The dot and the unread badge are now positioned against the sparkles icon, at every width and with the panel open or closed. The bell and Jobs badges use the same anchor and keep their place.
|
||||
|
||||
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
|
||||
|
||||
@@ -21,13 +21,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
let currentPathname = '/app/person'
|
||||
let currentSearch = ''
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => currentPathname,
|
||||
useSearchParams: () => new URLSearchParams(currentSearch),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
import Breadcrumbs, { buildBreadcrumbs, buildDocumentTitle, humanizeSegment } from './Breadcrumbs'
|
||||
import type { ParentAppInfo } from '@/lib/hooks/use-routes'
|
||||
import { TABLE_SUB_VIEWS } from '@/lib/utils/table-sub-views'
|
||||
|
||||
beforeEach(() => vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
@@ -102,6 +105,30 @@ describe('buildBreadcrumbs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBreadcrumbs with a sub-view in the query string', () => {
|
||||
const list = 'page=3&filter=abc&q=smith'
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('$name is the last crumb and the table links back to the list as it was', (view) => {
|
||||
const search = `${list}&view=${view.name}&${view.params.map((name) => `${name}=layout`).join('&')}`
|
||||
const crumbs = buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap, search)
|
||||
expect(crumbs.map((crumb) => crumb.label)).toEqual(['People App', 'Greetings App', 'Person', view.label])
|
||||
expect(crumbs.at(-2)).toEqual({ path: '/app/person', label: 'Person', href: `/app/person?${list}` })
|
||||
})
|
||||
|
||||
it('links back to a saved view with its own path, and to a bare list without a query string', () => {
|
||||
const saved = buildBreadcrumbs('/app/person/savedView/3', pathToLabelMap, ancestorAppMap, new URLSearchParams('view=pivot&pivot=x'))
|
||||
expect(saved.slice(-2).map((crumb) => crumb.label)).toEqual(['Saved View', 'Pivot'])
|
||||
expect(saved.at(-2)?.href).toBe('/app/person/savedView/3')
|
||||
})
|
||||
|
||||
it('leaves the trail alone for the list, an unknown view, and a record page that has its own view parameter', () => {
|
||||
const plain = buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap)
|
||||
expect(buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap, 'page=3&filter=abc')).toEqual(plain)
|
||||
expect(buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap, 'view=nope')).toEqual(plain)
|
||||
expect(buildBreadcrumbs('/app/person/7', pathToLabelMap, ancestorAppMap, 'view=pivot').map((crumb) => crumb.label)).toEqual(['People App', 'Greetings App', 'Person', '7'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDocumentTitle', () => {
|
||||
const crumbs = buildBreadcrumbs('/app/person/1', pathToLabelMap, ancestorAppMap)
|
||||
|
||||
@@ -121,6 +148,14 @@ describe('buildDocumentTitle', () => {
|
||||
describe('Breadcrumbs', () => {
|
||||
beforeEach(() => {
|
||||
currentPathname = '/app/person'
|
||||
currentSearch = ''
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('inside $name the view is the current page and the table is a link back to the list', (view) => {
|
||||
currentSearch = `page=2&filter=abc&view=${view.name}`
|
||||
render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />)
|
||||
expect(screen.getByText(view.label)).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByRole('link', { name: 'Person' })).toHaveAttribute('href', '/app/person?page=2&filter=abc')
|
||||
})
|
||||
|
||||
it('renders the app hierarchy as links and the current page as text', () => {
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { Home } from 'lucide-react'
|
||||
|
||||
import type { ParentAppInfo } from '@/lib/hooks/use-routes'
|
||||
import { listSearch, tableSubView } from '@/lib/utils/table-sub-views'
|
||||
|
||||
/**
|
||||
* Props for the Breadcrumbs component.
|
||||
@@ -47,6 +48,8 @@ export interface Breadcrumb {
|
||||
path: string
|
||||
/** Human-readable label. */
|
||||
label: string
|
||||
/** Where the crumb links when that is more than its path: the list behind a sub-view keeps its query string. */
|
||||
href?: string
|
||||
}
|
||||
|
||||
/** Labels for the fixed sub-page segments under a table or record. */
|
||||
@@ -102,15 +105,21 @@ export function humanizeSegment(segment: string): string {
|
||||
* and `/app/{table}/{id}/{process}` URLs) takes that object's label; other
|
||||
* segments without a label are humanized, except a record id, which stays as is.
|
||||
*
|
||||
* A sub-view of a record list lives in the query string, not the path (`?view=correlation`, see
|
||||
* `table-sub-views`). With `search`, an open sub-view becomes the last crumb, and the list's own
|
||||
* crumb before it links back to the list with the rest of the query string (filter, sort, page).
|
||||
*
|
||||
* @param pathname - Current browser path.
|
||||
* @param pathToLabelMap - Route path → label.
|
||||
* @param ancestorAppMap - Node path → enclosing apps.
|
||||
* @param search - Current query string; omit to build the trail from the path alone.
|
||||
* @returns Breadcrumbs from the outermost app to the current page.
|
||||
*/
|
||||
export function buildBreadcrumbs(
|
||||
pathname: string,
|
||||
pathToLabelMap: Record<string, string>,
|
||||
ancestorAppMap: Record<string, ParentAppInfo[]> = {}
|
||||
ancestorAppMap: Record<string, ParentAppInfo[]> = {},
|
||||
search?: URLSearchParams | string | null
|
||||
): Breadcrumb[] {
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
if (segments[0] !== 'app' || segments.length < 2) return []
|
||||
@@ -133,6 +142,14 @@ export function buildBreadcrumbs(
|
||||
?? (index === 0 ? decoded : humanizeSegment(decoded))
|
||||
crumbs.push({ path, label })
|
||||
}
|
||||
|
||||
// Only a record list hosts sub-views: the table itself, or one of its saved views.
|
||||
const subView = rest.length === 0 || rest[0] === 'savedView' ? tableSubView(search) : undefined
|
||||
if (subView) {
|
||||
const list = crumbs[crumbs.length - 1]
|
||||
crumbs[crumbs.length - 1] = { ...list, href: `${list.path}${listSearch(search)}` }
|
||||
crumbs.push({ path: `${list.path}?view=${subView.name}`, label: subView.label })
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
@@ -155,14 +172,43 @@ export function buildDocumentTitle(crumbs: Breadcrumb[], pageTitle: string | und
|
||||
/**
|
||||
* Renders a horizontal breadcrumb trail for the current page.
|
||||
*
|
||||
* The trail follows the query string as well as the path, so a sub-view of a record list shows as
|
||||
* the current page with the list as a link back. The query string is read inside a Suspense
|
||||
* boundary (a static export cannot prerender it); until it is known the trail is built from the
|
||||
* path alone.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A `<nav aria-label="Breadcrumb">` element with slash-separated
|
||||
* `<Link>` crumbs (ancestor pages) followed by a `<span aria-current="page">`
|
||||
* for the current page, or `null` on the dashboard root and before labels load.
|
||||
*/
|
||||
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app' }: BreadcrumbsProps) {
|
||||
export default function Breadcrumbs(props: BreadcrumbsProps) {
|
||||
return (
|
||||
<Suspense fallback={<BreadcrumbTrail {...props} search={null} />}>
|
||||
<BreadcrumbsForLocation {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the query string for {@link Breadcrumbs}.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The trail for the current path and query string.
|
||||
*/
|
||||
function BreadcrumbsForLocation(props: BreadcrumbsProps) {
|
||||
return <BreadcrumbTrail {...props} search={useSearchParams()} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail itself.
|
||||
*
|
||||
* @param props - Component properties, and the query string when it is known.
|
||||
* @returns The `<nav>`, or `null`.
|
||||
*/
|
||||
function BreadcrumbTrail({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app', search }: BreadcrumbsProps & { search: URLSearchParams | null }) {
|
||||
const pathname = usePathname()
|
||||
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap)
|
||||
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap, search)
|
||||
const hasLabels = Object.keys(pathToLabelMap).length > 0
|
||||
|
||||
// Until metadata supplies labels, raw URL segments would flash in place of labels
|
||||
@@ -186,7 +232,8 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homeP
|
||||
<Home className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{/* Every crumb stays a link, in order. A long label truncates with an ellipsis (full text in the title) and the
|
||||
trail wraps onto another line instead of scrolling or hiding crumbs. */}
|
||||
trail wraps onto another line instead of scrolling or hiding crumbs. Accessibility: the last crumb is text
|
||||
with aria-current="page"; every crumb before it, including the list behind an open sub-view, is a link. */}
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.path + index}>
|
||||
<span className="shrink-0 text-muted-foreground/60" aria-hidden="true">/</span>
|
||||
@@ -202,7 +249,7 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homeP
|
||||
) : (
|
||||
<Link
|
||||
title={crumb.label}
|
||||
href={crumb.path}
|
||||
href={crumb.href ?? crumb.path}
|
||||
className="min-w-0 max-w-[10rem] truncate text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-w-11 pointer-coarse:py-3 pointer-coarse:text-center"
|
||||
data-qqq-id={`breadcrumb-link-${index}`}
|
||||
>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Every sub-view of the record list (the explore views and the saved analysis viewer) must leave
|
||||
// a way back to the list it was opened from: its own back control, the table's breadcrumb and the
|
||||
// browser's Back button. The tests walk the registry, so a view added to it without a way back,
|
||||
// or one that loses the list's filter or page, fails here.
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { EXPLORE_LABELS, EXPLORE_VIEWS } from '@/lib/utils/data-analysis-explore'
|
||||
import { serializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { listSearch, TABLE_SUB_VIEWS, type TableSubView } from '@/lib/utils/table-sub-views'
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs'
|
||||
import { relationshipCapabilities } from '../../../tests/fixtures/data-analysis-compare'
|
||||
import { RecordQuery } from './RecordQuery'
|
||||
|
||||
vi.mock('next/navigation', async () => (await import('../../../tests/helpers/history-navigation')).historyNavigation)
|
||||
vi.mock('@/lib/api/data-analysis', async (original) => ({
|
||||
...(await original<typeof import('@/lib/api/data-analysis')>()),
|
||||
getCapabilities: vi.fn(async () => relationshipCapabilities),
|
||||
listSavedAnalyses: vi.fn(async () => ({ analyses: [] })),
|
||||
runSavedAnalysis: vi.fn(() => new Promise(() => undefined)),
|
||||
}))
|
||||
|
||||
const LIST = '[data-qqq-id="record-query-person"]'
|
||||
const criteria = [{ fieldName: 'firstName', operator: 'EQUALS' as const, values: ['Alice'] }]
|
||||
const orderBys = [{ fieldName: 'firstName', isAscending: true }]
|
||||
const filter = serializeFilter({ criteria, booleanOperator: 'AND', orderBys })
|
||||
/** The list as the person left it: page 2 of a filtered, sorted, searched query. */
|
||||
const listAddress = `/app/person?${new URLSearchParams({ page: '2', pageSize: '25', filter, q: 'ali' })}`
|
||||
|
||||
/**
|
||||
* The page: the header's breadcrumb and the record list, on one address.
|
||||
*
|
||||
* @returns Both.
|
||||
*/
|
||||
function Page() {
|
||||
const tables = structuredClone(qInstance.tables)
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs pathToLabelMap={{ '/app/person': tables.person.label }} />
|
||||
<RecordQuery tableName="person" tableMetaData={tables.person} allTables={tables} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAt(address: string) {
|
||||
// a new entry, so no test starts with another test's Forward entries
|
||||
window.history.pushState(null, '', address)
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><QContextProvider><Page /></QContextProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the address says about the list.
|
||||
*
|
||||
* @returns Its page, page size, search, and the filter with its sort.
|
||||
*/
|
||||
function listState() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const encoded = params.get('filter')
|
||||
const decoded = encoded ? JSON.parse(atob(encoded)) as { criteria?: unknown; orderBys?: unknown } : {}
|
||||
return { path: window.location.pathname, page: params.get('page'), pageSize: params.get('pageSize'), q: params.get('q'), criteria: decoded.criteria, orderBys: decoded.orderBys, view: params.get('view') }
|
||||
}
|
||||
const asLeft = { path: '/app/person', page: '2', pageSize: '25', q: 'ali', view: null, criteria, orderBys }
|
||||
|
||||
const viewAddress = (view: TableSubView) => `${listAddress}&view=${view.name}${view.name === 'saved' ? '&saved=7' : ''}`
|
||||
const backControl = (view: TableSubView) => document.querySelector<HTMLElement>(`[data-qqq-id="${view.backId}"]`)
|
||||
const listShown = () => waitFor(() => expect(document.querySelector(LIST)).toBeInTheDocument())
|
||||
const viewShown = (view: TableSubView) => waitFor(() => expect(backControl(view)).toBeInTheDocument(), { timeout: 5000 })
|
||||
|
||||
describe('RecordQuery sub-views leave a way back', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
server.use(
|
||||
http.post('/qqq/v1/table/person/:action', ({ params }) =>
|
||||
HttpResponse.json(params.action === 'query' ? { records: [{ tableName: 'person', recordLabel: 'Alice', values: { id: 1, firstName: 'Alice' } }] } : { count: 30 })),
|
||||
http.get('/qqq/v1/table/data_analysis_saved/:id', () => HttpResponse.json({ error: 'not found' }, { status: 404 })),
|
||||
)
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('a link to $name opens it; its back control and the breadcrumb return to the list as it was', async (view) => {
|
||||
const user = userEvent.setup()
|
||||
renderAt(viewAddress(view))
|
||||
await viewShown(view)
|
||||
expect(document.querySelector(LIST)).not.toBeInTheDocument()
|
||||
|
||||
// The breadcrumb names the view, and the table is a link to the list behind it
|
||||
expect(screen.getByText(view.label, { selector: '[aria-current="page"]' })).toBeInTheDocument()
|
||||
const crumb = screen.getByRole('link', { name: 'People' })
|
||||
expect(crumb).toHaveAttribute('href', `/app/person${listSearch(window.location.search)}`)
|
||||
expect(new URLSearchParams(crumb.getAttribute('href')!.split('?')[1]).get('page')).toBe('2')
|
||||
|
||||
// Its own control
|
||||
expect(backControl(view)).toHaveAccessibleName('Back to records')
|
||||
await user.click(backControl(view)!)
|
||||
await listShown()
|
||||
expect(backControl(view)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('People', { selector: '[aria-current="page"]' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
|
||||
// Following the breadcrumb link from the view gives the same list
|
||||
act(() => window.history.pushState(null, '', viewAddress(view)))
|
||||
await viewShown(view)
|
||||
const href = screen.getByRole('link', { name: 'People' }).getAttribute('href')!
|
||||
act(() => window.history.pushState(null, '', href))
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
})
|
||||
|
||||
it.each(EXPLORE_VIEWS.map((name) => TABLE_SUB_VIEWS[name]))('$name opened from the list: browser Back, then its back control, return to the list as it was', async (view) => {
|
||||
const user = userEvent.setup()
|
||||
renderAt(listAddress)
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
const entries = window.history.length
|
||||
|
||||
const open = async () => {
|
||||
await user.click(await screen.findByRole('button', { name: 'Explore' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: EXPLORE_LABELS[view.name as keyof typeof EXPLORE_LABELS] }))
|
||||
await viewShown(view)
|
||||
}
|
||||
|
||||
// Browser Back
|
||||
await open()
|
||||
expect(window.history.length).toBe(entries + 1)
|
||||
expect(listState().view).toBe(view.name)
|
||||
act(() => window.history.back())
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
// the list does not rewrite the address after the step back (that would drop the page)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(listState()).toEqual(asLeft)
|
||||
|
||||
// The view's own control is one step back too: the view stays one Forward away
|
||||
await open()
|
||||
await user.click(backControl(view)!)
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
expect(window.history.length).toBe(entries + 1)
|
||||
act(() => window.history.forward())
|
||||
await viewShown(view)
|
||||
expect(listState().view).toBe(view.name)
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ import { canSortField } from '@/lib/utils/secured-data'
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { X, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -39,9 +39,9 @@ import { getCapabilities } from '@/lib/api/data-analysis'
|
||||
import type { PageSize } from '@/lib/hooks/use-record-query'
|
||||
|
||||
import { QUERY_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
|
||||
import { encodeExplore, EXPLORE_VIEWS, type RelationshipView, type ExploreLayout } from '@/lib/utils/data-analysis-explore'
|
||||
import { encodeExplore, type RelationshipView, type ExploreLayout } from '@/lib/utils/data-analysis-explore'
|
||||
import { listSearch, tableSubView, TABLE_SUB_VIEW_PARAM, type TableSubViewName } from '@/lib/utils/table-sub-views'
|
||||
import { encodePivot, type PivotLayout } from '@/lib/utils/data-analysis-pivot'
|
||||
import { deserializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { queryRecords, type TableVariant } from '@/lib/api/tables'
|
||||
import { useRecordQuery, hasCapability } from '@/lib/hooks/use-record-query'
|
||||
import { useSavedViews, useSavedView } from '@/lib/hooks/use-saved-views'
|
||||
@@ -122,6 +122,7 @@ interface RecordQueryProps {
|
||||
export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, allTables, processes, metaData, savedViewId }: RecordQueryProps) {
|
||||
const tableMetaData = useMemo(() => withReadableExposedJoins(sourceTableMetaData, allTables), [sourceTableMetaData, allTables])
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const fromPath = searchParams.get('from')
|
||||
const fromLabel = searchParams.get('fromLabel')
|
||||
@@ -324,36 +325,59 @@ export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, all
|
||||
const openAnalysis = (field?: { name: string; label: string }) => setAnalysis({ field, filter: rq.filter.baseFilter, returnFocus: field ? document.querySelector<HTMLElement>(`[data-qqq-id="grid-column-menu-${CSS.escape(field.name)}"]`) : document.activeElement instanceof HTMLElement ? document.activeElement : null })
|
||||
const closeAnalysis = () => { const target = analysis?.returnFocus; setAnalysis(null); requestAnimationFrame(() => target?.focus()) }
|
||||
const canAnalyze = capabilities.data?.canAnalyze === true
|
||||
const pivotOpen = searchParams.get('view') === 'pivot'
|
||||
const relationshipView = EXPLORE_VIEWS.find(v => v !== 'pivot' && v === searchParams.get('view')) as RelationshipView | undefined
|
||||
const explorerOpen = pivotOpen || Boolean(relationshipView)
|
||||
const { setUserFilter: restorePivotFilter, setQuickSearch: restorePivotSearch } = rq.filter
|
||||
// Sub-views (the explore views and the saved analysis viewer) are states of this route, named by
|
||||
// the query string. `table-sub-views` is their registry: a view opens only through it, so the
|
||||
// breadcrumb knows every one of them.
|
||||
const subView = tableSubView(searchParams)
|
||||
const pivotOpen = subView?.name === 'pivot'
|
||||
const relationshipView = subView && subView.name !== 'pivot' && subView.name !== 'saved' ? subView.name as RelationshipView : undefined
|
||||
const { restoreFromUrl } = rq
|
||||
// Browser Back and Forward between the list and a sub-view (or two states of the list) change the
|
||||
// address without remounting this page: take the filter, sort, quick search and page from it, so
|
||||
// the list is the one that address names. A step to another page is not this page's to follow.
|
||||
useEffect(() => {
|
||||
const samePath = (a: string, b: string) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '')
|
||||
const restore = () => {
|
||||
if (!samePath(window.location.pathname, pathname)) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (!explorerOpen && !EXPLORE_VIEWS.some(v => v === params.get('view'))) return
|
||||
restorePivotFilter(deserializeFilter(params.get('filter') ?? '', rq.viewState.pageSize))
|
||||
restorePivotSearch(params.get('q') ?? '')
|
||||
restoreFromUrl(window.location.search)
|
||||
setLocalSearchTerm(params.get('q') ?? '')
|
||||
}
|
||||
window.addEventListener('popstate', restore)
|
||||
return () => window.removeEventListener('popstate', restore)
|
||||
}, [explorerOpen, restorePivotFilter, restorePivotSearch, rq.viewState.pageSize])
|
||||
const updatePivotUrl = (open: boolean, request?: PivotLayout) => {
|
||||
}, [pathname, restoreFromUrl])
|
||||
// The list address a sub-view was opened from, while that list is the previous history entry.
|
||||
const openedFromRef = useRef<string | null>(null)
|
||||
// Opens a sub-view, or updates the open one's layout, in the address (a new history entry only
|
||||
// when a view opens, so Back returns to the list and a re-run does not pile up entries).
|
||||
const openSubView = (view: TableSubViewName, layout?: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (open) url.searchParams.set('view', 'pivot')
|
||||
else { url.searchParams.delete('view'); url.searchParams.delete('pivot') }
|
||||
if (request) url.searchParams.set('pivot', encodePivot(request))
|
||||
if (open && !pivotOpen) window.history.pushState(null, '', url)
|
||||
else window.history.replaceState(null, '', url)
|
||||
url.searchParams.set(TABLE_SUB_VIEW_PARAM, view)
|
||||
if (layout !== undefined) url.searchParams.set(view, layout)
|
||||
if (subView) window.history.replaceState(null, '', url)
|
||||
else {
|
||||
openedFromRef.current = `${window.location.pathname}${window.location.search}`
|
||||
window.history.pushState(null, '', url)
|
||||
}
|
||||
}
|
||||
// Returns to the list, with whatever filter, sort and page the address now carries. When that is
|
||||
// still the list this view was opened from, it is one step back in history, so the view stays
|
||||
// one Forward away and Back does not meet the same list twice; otherwise (a deep link, or a
|
||||
// drill changed the filter) the list replaces the view in place.
|
||||
const closeSubView = () => {
|
||||
const list = `${window.location.pathname}${listSearch(window.location.search)}`
|
||||
const openedFrom = openedFromRef.current
|
||||
openedFromRef.current = null
|
||||
if (openedFrom === list) window.history.back()
|
||||
else window.history.replaceState(null, '', `${list}${window.location.hash}`)
|
||||
}
|
||||
const updatePivotUrl = (open: boolean, request?: PivotLayout) => {
|
||||
if (open) openSubView('pivot', request ? encodePivot(request) : undefined)
|
||||
else closeSubView()
|
||||
}
|
||||
const updateExploreUrl = (view?: RelationshipView, layout?: ExploreLayout) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (view) url.searchParams.set('view', view)
|
||||
else { url.searchParams.delete('view'); EXPLORE_VIEWS.forEach(v => url.searchParams.delete(v)) }
|
||||
if (view && layout) url.searchParams.set(view, encodeExplore(view, layout))
|
||||
if (view && !explorerOpen) window.history.pushState(null, '', url)
|
||||
else window.history.replaceState(null, '', url)
|
||||
if (view) openSubView(view, layout ? encodeExplore(view, layout) : undefined)
|
||||
else closeSubView()
|
||||
}
|
||||
// A drill on this table applies its filter here: this page reads the URL only on mount.
|
||||
const applyAnalysisDrill = (drill: DataAnalysisDrillV1) => {
|
||||
@@ -608,13 +632,8 @@ export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, all
|
||||
</>
|
||||
)
|
||||
|
||||
if (searchParams.get('view') === 'saved') {
|
||||
const closeSaved = () => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('view')
|
||||
url.searchParams.delete('saved')
|
||||
window.history.replaceState(null, '', url)
|
||||
}
|
||||
if (subView?.name === 'saved') {
|
||||
const closeSaved = closeSubView
|
||||
return <AnalysisDrillContext.Provider value={drill => {
|
||||
if (!applyAnalysisDrill(drill)) return false
|
||||
closeSaved()
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { Density } from '@/lib/hooks/use-record-query'
|
||||
import type { TableVariant } from '@/lib/api/tables'
|
||||
import type { ColumnPins } from '@/lib/utils/query-columns'
|
||||
import { MATERIAL_BUTTON_VARIANTS } from '@/lib/utils/qqq-id'
|
||||
import { EXPLORE_LABELS, EXPLORE_VIEWS } from '@/lib/utils/data-analysis-explore'
|
||||
|
||||
import { LazyFavoriteToggle } from '@/components/collaboration/LazyFavoriteToggle'
|
||||
import { GotoRecordButton } from '@/components/records/GotoRecordDialog'
|
||||
@@ -189,7 +190,7 @@ export interface RecordQueryToolbarProps {
|
||||
/** Opens table analysis when capabilities permit it. */
|
||||
onAnalyzeTable?: () => void
|
||||
/** Opens an explorer under the table's analysis capability. */
|
||||
onExplore?: (view: 'pivot' | 'compare' | 'correlation' | 'crosstab' | 'shift') => void
|
||||
onExplore?: (view: (typeof EXPLORE_VIEWS)[number]) => void
|
||||
/** The backend table name used in API calls and URL routing. */
|
||||
tableName: string
|
||||
/** Full table metadata from the QQQ backend. */
|
||||
@@ -467,9 +468,9 @@ export function RecordQueryToolbar({
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content align="start" sideOffset={4} className="z-[200] min-w-44 rounded border border-border bg-popover p-1 text-popover-foreground shadow-md">
|
||||
{(['pivot', 'compare', 'correlation', 'crosstab', 'shift'] as const).map(view => (
|
||||
{EXPLORE_VIEWS.map(view => (
|
||||
<DropdownMenuPrimitive.Item key={view} onSelect={() => onExplore(view)} className="cursor-pointer rounded px-3 py-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground" data-qqq-id={`explore-${view}`}>
|
||||
{view === 'shift' ? 'Shift over time' : view[0].toUpperCase() + view.slice(1)}
|
||||
{EXPLORE_LABELS[view]}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
))}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
|
||||
@@ -56,7 +56,6 @@ 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'
|
||||
@@ -142,6 +141,40 @@ export type RecordQueryAction =
|
||||
| { type: 'TOGGLE_FILTER_PANEL' }
|
||||
| { type: 'RESET_FILTER' }
|
||||
| { type: 'APPLY_VIEW'; view: ViewState }
|
||||
| { type: 'RESTORE_URL'; url: UrlQueryState }
|
||||
|
||||
/** The part of the query state the address carries: `page`, `pageSize`, `filter` (with a non-default sort) and `q`. */
|
||||
export interface UrlQueryState {
|
||||
pageNum: number
|
||||
pageSize: PageSize
|
||||
userFilter: QQueryFilter
|
||||
sortOrder: QFilterOrderBy[]
|
||||
quickSearchTerm: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the query state an address carries, the inverse of the hook's URL sync.
|
||||
*
|
||||
* @param params - The address' query parameters.
|
||||
* @param fallbackPageSize - Page size when the address names none (the sync omits the default).
|
||||
* @param defaultSort - Sort when the filter carries none (the sync omits the default).
|
||||
* @param fallbackFilter - Filter when the address carries none; an empty filter by default.
|
||||
* @returns The state.
|
||||
*/
|
||||
export function readUrlQueryState(params: URLSearchParams, fallbackPageSize: PageSize, defaultSort: QFilterOrderBy[], fallbackFilter?: QQueryFilter | null): UrlQueryState {
|
||||
const pageSizeParam = Number(params.get('pageSize'))
|
||||
const pageSize: PageSize = (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(pageSizeParam) ? pageSizeParam as PageSize : fallbackPageSize
|
||||
const filterParam = params.get('filter')
|
||||
const filter = filterParam ? deserializeFilter(filterParam, pageSize) : fallbackFilter ?? emptyFilter(pageSize)
|
||||
const pageParam = parseInt(params.get('page') ?? '', 10)
|
||||
return {
|
||||
pageNum: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1,
|
||||
pageSize,
|
||||
userFilter: { ...filter, orderBys: [] },
|
||||
sortOrder: filter.orderBys?.length ? filter.orderBys : defaultSort,
|
||||
quickSearchTerm: params.get('q') ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for `RecordQueryState`. Any change to the result set returns to page 1.
|
||||
@@ -211,13 +244,20 @@ function recordQueryReducer(state: RecordQueryState, action: RecordQueryAction):
|
||||
selectionMode: 'rows',
|
||||
subsetSize: null,
|
||||
}
|
||||
case 'RESTORE_URL': {
|
||||
// The address is the source here (a history step), so the page is kept, not reset.
|
||||
const { url } = action
|
||||
const unchanged = state.pageNum === url.pageNum && state.pageSize === url.pageSize && state.quickSearchTerm === url.quickSearchTerm
|
||||
&& JSON.stringify(state.userFilter) === JSON.stringify(url.userFilter) && JSON.stringify(state.sortOrder) === JSON.stringify(url.sortOrder)
|
||||
return unchanged ? state : { ...state, ...url }
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions after which basic mode is checked against the filter again. */
|
||||
const BASIC_MODE_ACTIONS: ReadonlySet<RecordQueryAction['type']> = new Set(['SET_USER_FILTER', 'SET_FILTER_MODE', 'APPLY_VIEW', 'RESET_FILTER'])
|
||||
const BASIC_MODE_ACTIONS: ReadonlySet<RecordQueryAction['type']> = new Set(['SET_USER_FILTER', 'SET_FILTER_MODE', 'APPLY_VIEW', 'RESET_FILTER', 'RESTORE_URL'])
|
||||
|
||||
/**
|
||||
* Keeps basic mode consistent with the filter (Material's
|
||||
@@ -303,7 +343,7 @@ interface UseRecordQueryOptions {
|
||||
*
|
||||
* @param options - Configuration including table name, metadata, page size and variant.
|
||||
* @returns Grouped state and actions: `pagination`, `filter`, `columns`, `selection`, `data`,
|
||||
* `joins`, `density`, and `viewState`/`applyView` for saved views.
|
||||
* `joins`, `density`, `viewState`/`applyView` for saved views, and `restoreFromUrl` for history steps.
|
||||
*/
|
||||
export function useRecordQuery({
|
||||
tableName,
|
||||
@@ -321,13 +361,9 @@ export function useRecordQuery({
|
||||
const [storedDensity, setDensity] = useLocalStorage<Density>(DENSITY_STORAGE_KEY, 'standard')
|
||||
const density: Density = (['compact', 'standard', 'comfortable'] as const).includes(storedDensity) ? storedDensity : 'standard'
|
||||
|
||||
/**
|
||||
* 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])
|
||||
const primaryKey = tableMetaData?.primaryKeyField
|
||||
/** Material's default sort: primary key, descending. */
|
||||
const defaultSort = useMemo<QFilterOrderBy[]>(() => (primaryKey ? [{ fieldName: primaryKey, isAscending: false }] : []), [primaryKey])
|
||||
/** 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`
|
||||
@@ -339,25 +375,19 @@ export function useRecordQuery({
|
||||
const initialStateRef = useRef<RecordQueryState | null>(null)
|
||||
if (!initialStateRef.current) {
|
||||
const isPageSize = (value: unknown): value is PageSize => (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(value)
|
||||
const pageSizeParam = Number(searchParams.get('pageSize'))
|
||||
const pageSize: PageSize = isPageSize(pageSizeParam) ? pageSizeParam : isPageSize(initialView?.pageSize) ? initialView.pageSize : initialPageSize
|
||||
const filterParam = searchParams.get('filter')
|
||||
const viewPageSize: PageSize = isPageSize(initialView?.pageSize) ? initialView.pageSize : initialPageSize
|
||||
const pageSize: PageSize = isPageSize(Number(searchParams.get('pageSize'))) ? Number(searchParams.get('pageSize')) as PageSize : viewPageSize
|
||||
const viewFilter = initialView ? { ...initialView.userFilter, orderBys: initialView.sortOrder, skip: 0, limit: pageSize } : null
|
||||
const initialFilter = filterParam ? deserializeFilter(filterParam, pageSize) : viewFilter ?? emptyFilter(pageSize)
|
||||
const pageParam = parseInt(searchParams.get('page') ?? '', 10)
|
||||
const fromUrl = readUrlQueryState(new URLSearchParams(searchParams.toString()), viewPageSize, defaultSort, viewFilter)
|
||||
// the mode and added quick filters are remembered per table (Material keeps them in the stored view)
|
||||
const storedMode = readStored(filterModeKey, (v): v is 'basic' | 'advanced' => v === 'basic' || v === 'advanced')
|
||||
const storedQuickFilters = readStored(quickFilterFieldsKey, (v): v is string[] => Array.isArray(v) && v.every((name) => typeof name === 'string'))
|
||||
// Columns: the view's, else what earlier versions stored per table
|
||||
const legacy = initialView ? null : readLegacyColumnState(tableName)
|
||||
initialStateRef.current = reconcileState({
|
||||
pageNum: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1,
|
||||
pageSize,
|
||||
userFilter: { ...initialFilter, orderBys: [] },
|
||||
quickSearchTerm: searchParams.get('q') ?? '',
|
||||
...fromUrl,
|
||||
filterMode: initialView?.filterMode ?? storedMode ?? 'basic',
|
||||
quickFilterFieldNames: (initialView?.quickFilterFieldNames ?? storedQuickFilters ?? []).filter((name) => Boolean(tableMetaData && resolveField(tableMetaData, name))),
|
||||
sortOrder: initialFilter.orderBys?.length ? initialFilter.orderBys : defaultSort,
|
||||
columnVisibility: initialView?.columnVisibility ?? legacy?.columnVisibility ?? (tableMetaData ? defaultColumnVisibility(tableMetaData) : {}),
|
||||
columnOrder: initialView?.columnOrder ?? legacy?.columnOrder ?? [],
|
||||
columnWidths: initialView?.columnWidths ?? legacy?.columnWidths ?? {},
|
||||
@@ -582,6 +612,14 @@ export function useRecordQuery({
|
||||
const toggleFilterPanel = useCallback(() => dispatch({ type: 'TOGGLE_FILTER_PANEL' }), [])
|
||||
const resetFilter = useCallback(() => dispatch({ type: 'RESET_FILTER' }), [])
|
||||
const applyView = useCallback((view: ViewState) => dispatch({ type: 'APPLY_VIEW', view }), [])
|
||||
/**
|
||||
* Takes the filter, sort, quick search, page and page size from an address the browser moved to
|
||||
* (Back or Forward). Only a history step calls it: the hook's own changes go the other way.
|
||||
*/
|
||||
const restoreFromUrl = useCallback(
|
||||
(search: string) => dispatch({ type: 'RESTORE_URL', url: readUrlQueryState(new URLSearchParams(search), initialPageSize, defaultSort) }),
|
||||
[initialPageSize, defaultSort]
|
||||
)
|
||||
|
||||
const viewState = useMemo<ViewState>(() => ({
|
||||
userFilter: state.userFilter,
|
||||
@@ -654,5 +692,6 @@ export function useRecordQuery({
|
||||
setDensity,
|
||||
viewState,
|
||||
applyView,
|
||||
restoreFromUrl,
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* 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,7 +27,6 @@ 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 {
|
||||
@@ -635,8 +634,9 @@ export function reconcileView(table: QTableMetaData, view: RecordQueryView): Rec
|
||||
removedFields.add(orderBy.fieldName)
|
||||
return 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))
|
||||
if (savedOrderBys.length > 0 && orderBys.length === 0 && table.primaryKeyField) {
|
||||
orderBys.push({ fieldName: table.primaryKeyField, isAscending: false })
|
||||
}
|
||||
const quickFilterFieldNames = (view.quickFilterFieldNames ?? []).filter((name) => {
|
||||
if (exists(name)) return true
|
||||
removedFields.add(name)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 { EXPLORE_VIEWS } from './data-analysis-explore'
|
||||
import { listSearch, TABLE_SUB_VIEWS, tableSubView } from './table-sub-views'
|
||||
|
||||
describe('table sub-views', () => {
|
||||
it('registers every explore view, and the saved analysis viewer', () => {
|
||||
expect(Object.keys(TABLE_SUB_VIEWS).sort()).toEqual([...EXPLORE_VIEWS, 'saved'].sort())
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('$name has a label, a back control and its own parameters', (view) => {
|
||||
expect(TABLE_SUB_VIEWS[view.name]).toBe(view)
|
||||
expect(view.label.trim()).not.toBe('')
|
||||
expect(view.backId).toMatch(/-back$/)
|
||||
expect(view.params.length).toBeGreaterThan(0)
|
||||
expect(tableSubView(`?view=${view.name}`)).toBe(view)
|
||||
})
|
||||
|
||||
it('opens no sub-view for the list, an unknown view or an inherited property name', () => {
|
||||
expect(tableSubView('')).toBeUndefined()
|
||||
expect(tableSubView(null)).toBeUndefined()
|
||||
expect(tableSubView('?view=tabs')).toBeUndefined()
|
||||
expect(tableSubView('?view=constructor')).toBeUndefined()
|
||||
expect(tableSubView(new URLSearchParams({ filter: 'abc' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('the list behind $name keeps filter, sort, page and search', (view) => {
|
||||
const params = new URLSearchParams({ page: '3', pageSize: '50', filter: 'abc', q: 'smith', from: '/app/home', view: view.name })
|
||||
for (const name of view.params) params.set(name, 'layout')
|
||||
expect(listSearch(params)).toBe('?page=3&pageSize=50&filter=abc&q=smith&from=%2Fapp%2Fhome')
|
||||
expect(listSearch(`?${params}`)).toBe('?page=3&pageSize=50&filter=abc&q=smith&from=%2Fapp%2Fhome')
|
||||
})
|
||||
|
||||
it('leaves nothing of one view behind when another view was in the address', () => {
|
||||
expect(listSearch('?view=pivot&pivot=a&correlation=b&saved=7')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 table-sub-views — the views of a table's record list that are selected by the query
|
||||
* string (`?view=pivot`, `?view=correlation`, `?view=saved&saved=7`) instead of a path.
|
||||
*
|
||||
* A sub-view replaces the list on the same route, so nothing in the path says it is open. This
|
||||
* registry is the one place that does: the record list opens a view only through it, the
|
||||
* breadcrumb reads it to show the view as the current page and the table as a link back, and the
|
||||
* tests walk it, so a view cannot be added without a label and a way back.
|
||||
*/
|
||||
|
||||
import { EXPLORE_LABELS, EXPLORE_VIEWS } from './data-analysis-explore'
|
||||
|
||||
/** The query parameter that names the open sub-view. */
|
||||
export const TABLE_SUB_VIEW_PARAM = 'view'
|
||||
|
||||
/** Name of a sub-view: every explore view, and the saved analysis viewer. */
|
||||
export type TableSubViewName = (typeof EXPLORE_VIEWS)[number] | 'saved'
|
||||
|
||||
/** One sub-view of a table's record list. */
|
||||
export interface TableSubView {
|
||||
/** Value of the `view` parameter. */
|
||||
name: TableSubViewName
|
||||
/** The breadcrumb's label for the view. */
|
||||
label: string
|
||||
/** Query parameters that belong to the view (its layout or id), dropped with it on the way back. */
|
||||
params: readonly string[]
|
||||
/** `data-qqq-id` of the control inside the view that returns to the list. */
|
||||
backId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sub-view, by name. The key type makes a new explore view a compile error until it is
|
||||
* registered here.
|
||||
*/
|
||||
export const TABLE_SUB_VIEWS: Record<TableSubViewName, TableSubView> = {
|
||||
pivot: { name: 'pivot', label: EXPLORE_LABELS.pivot, params: ['pivot'], backId: 'pivot-back' },
|
||||
compare: { name: 'compare', label: EXPLORE_LABELS.compare, params: ['compare'], backId: 'explore-back' },
|
||||
correlation: { name: 'correlation', label: EXPLORE_LABELS.correlation, params: ['correlation'], backId: 'explore-back' },
|
||||
crosstab: { name: 'crosstab', label: EXPLORE_LABELS.crosstab, params: ['crosstab'], backId: 'explore-back' },
|
||||
shift: { name: 'shift', label: EXPLORE_LABELS.shift, params: ['shift'], backId: 'explore-back' },
|
||||
saved: { name: 'saved', label: 'Saved analysis', params: ['saved'], backId: 'saved-back' },
|
||||
}
|
||||
|
||||
/** Every parameter a sub-view can own, including `view` itself. */
|
||||
const SUB_VIEW_PARAMS = [TABLE_SUB_VIEW_PARAM, ...new Set(Object.values(TABLE_SUB_VIEWS).flatMap((view) => view.params))]
|
||||
|
||||
/**
|
||||
* The sub-view a query string opens, if any. An unknown `view` value opens none (a record page
|
||||
* uses `view=tabs` for its own layout).
|
||||
*
|
||||
* @param search - The query string or its parameters.
|
||||
* @returns The sub-view, or undefined when the list itself is shown.
|
||||
*/
|
||||
export function tableSubView(search: URLSearchParams | string | null | undefined): TableSubView | undefined {
|
||||
const name = new URLSearchParams(search ?? '').get(TABLE_SUB_VIEW_PARAM)
|
||||
return name !== null && Object.prototype.hasOwnProperty.call(TABLE_SUB_VIEWS, name) ? TABLE_SUB_VIEWS[name as TableSubViewName] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The query string of the list behind a sub-view: the same parameters (filter, sort, page, page
|
||||
* size, quick search and anything else) without the view's own.
|
||||
*
|
||||
* @param search - The current query string or its parameters.
|
||||
* @returns The list's query string, with a leading `?` unless it is empty.
|
||||
*/
|
||||
export function listSearch(search: URLSearchParams | string | null | undefined): string {
|
||||
const params = new URLSearchParams(search ?? '')
|
||||
for (const name of SUB_VIEW_PARAMS) params.delete(name)
|
||||
const rest = params.toString()
|
||||
return rest ? `?${rest}` : ''
|
||||
}
|
||||
@@ -262,3 +262,67 @@ test('Explore is absent without the capability', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Toggle advanced filter panel' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Explore', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
// Every relationship view leaves three ways back to the list it was opened from: the browser's
|
||||
// Back button, its own "Back to records" button and the table's breadcrumb. Each returns the list
|
||||
// with its filter and page as they were.
|
||||
const VIEW_LABELS = {
|
||||
compare: 'Compare',
|
||||
correlation: 'Correlation',
|
||||
crosstab: 'Crosstab',
|
||||
shift: 'Shift over time',
|
||||
} as const
|
||||
for (const view of ['compare', 'correlation', 'crosstab', 'shift'] as const)
|
||||
test(`${view} leaves a way back: browser Back, Back to records and the breadcrumb`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockRelationships(page)
|
||||
const filter = Buffer.from(JSON.stringify(baseFilter)).toString('base64')
|
||||
await page.goto(`/app/person?${new URLSearchParams({ filter, page: '2' })}`)
|
||||
const crumbs = page.getByRole('navigation', { name: 'Breadcrumb', exact: true })
|
||||
const current = crumbs.locator('[aria-current="page"]')
|
||||
const list = page.locator('[data-qqq-id="record-query-person"]')
|
||||
const explorer = page.locator(`[data-qqq-id="${view}-explorer"]`)
|
||||
const address = () => {
|
||||
const params = new URL(page.url()).searchParams
|
||||
const encoded = params.get('filter')
|
||||
return {
|
||||
view: params.get('view'),
|
||||
layout: params.has(view),
|
||||
page: params.get('page'),
|
||||
criteria: encoded ? JSON.parse(Buffer.from(encoded, 'base64').toString()).criteria : null,
|
||||
}
|
||||
}
|
||||
const asLeft = { view: null, layout: false, page: '2', criteria: baseFilter.criteria }
|
||||
const backAtList = async () => {
|
||||
await expect(explorer).toHaveCount(0)
|
||||
await expect(list).toBeVisible()
|
||||
await expect(current).toHaveText('People')
|
||||
await expect.poll(address).toEqual(asLeft)
|
||||
}
|
||||
await expect(list).toBeVisible()
|
||||
await backAtList()
|
||||
|
||||
// The breadcrumb follows the view; the table becomes a link
|
||||
await build(page, view)
|
||||
await expect(current).toHaveText(VIEW_LABELS[view])
|
||||
await expect(crumbs.getByRole('link', { name: 'People', exact: true })).toBeVisible()
|
||||
expect(address()).toMatchObject({ view, layout: true, page: '2' })
|
||||
|
||||
// Browser Back, after the view ran
|
||||
await page.goBack()
|
||||
await backAtList()
|
||||
|
||||
// The view's own button is one step back too, so the view stays one Forward away
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
await explorer.getByRole('button', { name: 'Back to records' }).click()
|
||||
await backAtList()
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
await expect(current).toHaveText(VIEW_LABELS[view])
|
||||
|
||||
// The table's breadcrumb
|
||||
await crumbs.getByRole('link', { name: 'People', exact: true }).click()
|
||||
await backAtList()
|
||||
})
|
||||
@@ -118,6 +118,53 @@ test('browser Back returns to the grid and Forward restores the layout', async (
|
||||
await expect(page.locator(EXPLORER).getByRole('link', { name: '14', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('pivot leaves a way back: browser Back, Back to records and the breadcrumb', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPivot(page)
|
||||
const filter = Buffer.from(JSON.stringify(baseFilter)).toString('base64')
|
||||
await page.goto(`/app/person?${new URLSearchParams({ filter, page: '2' })}`)
|
||||
const crumbs = page.getByRole('navigation', { name: 'Breadcrumb', exact: true })
|
||||
const current = crumbs.locator('[aria-current="page"]')
|
||||
const explorer = page.locator(EXPLORER)
|
||||
const address = () => {
|
||||
const params = new URL(page.url()).searchParams
|
||||
const encoded = params.get('filter')
|
||||
return {
|
||||
view: params.get('view'),
|
||||
layout: params.has('pivot'),
|
||||
page: params.get('page'),
|
||||
criteria: encoded ? JSON.parse(Buffer.from(encoded, 'base64').toString()).criteria : null,
|
||||
}
|
||||
}
|
||||
const backAtList = async () => {
|
||||
await expect(explorer).toHaveCount(0)
|
||||
await expect(page.locator('[data-qqq-id="record-query-person"]')).toBeVisible()
|
||||
await expect(current).toHaveText('People')
|
||||
await expect
|
||||
.poll(address)
|
||||
.toEqual({ view: null, layout: false, page: '2', criteria: baseFilter.criteria })
|
||||
}
|
||||
await backAtList()
|
||||
|
||||
await buildPivot(page)
|
||||
await expect(current).toHaveText('Pivot')
|
||||
await expect(crumbs.getByRole('link', { name: 'People', exact: true })).toBeVisible()
|
||||
await page.goBack()
|
||||
await backAtList()
|
||||
|
||||
// The pivot's own button is one step back too, so the pivot stays one Forward away
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
await explorer.getByRole('button', { name: 'Back to records' }).click()
|
||||
await backAtList()
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
|
||||
await crumbs.getByRole('link', { name: 'People', exact: true }).click()
|
||||
await backAtList()
|
||||
})
|
||||
|
||||
for (const width of [390, 768, 1440]) {
|
||||
test(`pivot stays within ${width}px and passes axe in both themes`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 history-navigation — a `next/navigation` stand-in for unit tests whose hooks follow the
|
||||
* address, as the App Router's do: `usePathname` and `useSearchParams` re-render after
|
||||
* `history.pushState`, `history.replaceState` and a Back or Forward step, and nothing is
|
||||
* remounted. Use it for components whose state lives in the query string:
|
||||
*
|
||||
* ```ts
|
||||
* vi.mock('next/navigation', async () => (await import('../../../tests/helpers/history-navigation')).historyNavigation)
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
const notify = () => listeners.forEach((listener) => listener())
|
||||
let installed = false
|
||||
|
||||
/** Makes every write to the history, and every traversal, tell the hooks. Idempotent. */
|
||||
function install(): void {
|
||||
if (installed) return
|
||||
installed = true
|
||||
for (const method of ['pushState', 'replaceState'] as const) {
|
||||
const original = window.history[method].bind(window.history)
|
||||
window.history[method] = (...args: Parameters<History['pushState']>) => {
|
||||
original(...args)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
window.addEventListener('popstate', notify)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a hook to address changes.
|
||||
*
|
||||
* @param listener - Called after every change.
|
||||
* @returns The unsubscribe function.
|
||||
*/
|
||||
function subscribe(listener: () => void): () => void {
|
||||
install()
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to an address as a router navigation would: a new history entry, and the hooks follow.
|
||||
*
|
||||
* @param href - Path and query string.
|
||||
*/
|
||||
function push(href: string): void {
|
||||
install()
|
||||
window.history.pushState(null, '', href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current address as a router navigation would.
|
||||
*
|
||||
* @param href - Path and query string.
|
||||
*/
|
||||
function replace(href: string): void {
|
||||
install()
|
||||
window.history.replaceState(null, '', href)
|
||||
}
|
||||
|
||||
/** The module to return from `vi.mock('next/navigation', ...)`. */
|
||||
export const historyNavigation = {
|
||||
useRouter: () => ({ push, replace, back: () => window.history.back(), forward: () => window.history.forward(), refresh: () => undefined, prefetch: () => undefined }),
|
||||
usePathname: () => useSyncExternalStore(subscribe, () => window.location.pathname),
|
||||
useSearchParams: () => {
|
||||
const search = useSyncExternalStore(subscribe, () => window.location.search)
|
||||
return useMemo(() => new URLSearchParams(search), [search])
|
||||
},
|
||||
redirect: (href: string) => replace(href),
|
||||
}
|
||||
Reference in new issue
Block a user