Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.2 b2752ac74a fix(explore): explore views show in the breadcrumb and leave a way back to the list
maven / build (push) Successful in 10m51s
build / maven (push) Successful in 10m51s
The explore views (pivot, compare, correlation, crosstab, shift) and the saved
analysis viewer are states of a table's record list, named by the query string.
The breadcrumb read only the path, so inside a view it kept showing the table
as the current page, with no link back to the list.

- table-sub-views: one registry of the query-string views (label, own
  parameters, back control). The list opens a view only through it, the
  breadcrumb reads it, and the tests walk it.
- Breadcrumbs: an open view is the last crumb; the table before it links to
  the list with the rest of the query string (filter, sort, page, search).
  The query string is read inside a Suspense boundary for the static export.
- RecordQuery: "Back to records" is one step back in history when the list it
  returns to is the one the view was opened from, so Back does not meet the
  same list twice; otherwise it replaces the view in place, as before.
- use-record-query: a Back or Forward step restores page and page size from
  the address as well as filter, sort and search, and changes nothing when the
  address already matches. Steps to another path are ignored.
- The Explore menu is built from the shared view list.

Tests: registry, breadcrumb and record list tests for every view (link opens
it, back control, breadcrumb, browser Back, Forward), with a next/navigation
stand-in that follows the history; mocked e2e for all five explore views.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 02:44:55 -06:00
12 changed files with 707 additions and 52 deletions

No files matched your search

+2
View File
@@ -20,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', () => {
+54 -7
View File
@@ -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}`}
>
@@ -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)
})
})
+49 -30
View File
@@ -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()
+4 -3
View File
@@ -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 -12
View File
@@ -141,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.
@@ -210,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
@@ -302,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,
@@ -334,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 ?? {},
@@ -577,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,
@@ -649,5 +692,6 @@ export function useRecordQuery({
setDensity,
viewState,
applyView,
restoreFromUrl,
}
}
+53
View File
@@ -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('')
})
})
+87
View File
@@ -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}` : ''
}
+64
View File
@@ -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()
})
+47
View File
@@ -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 })
+89
View File
@@ -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),
}