Merge pull request #59 from QRun-IO/feature/search-ux
feat(search): search-as-you-type quick search, richer results and a URL-addressable search page
This commit was merged in pull request #59.
This commit is contained in:
46 files changed
+4971
-1017
No files matched your search
@@ -41,6 +41,26 @@
|
||||
"$comment": "The QQQ Assistant panel (qbit-assistant): chat UI, SSE streaming client, react-markdown + rehype-sanitize, loaded on first open through import() in AssistantMount; the shell keeps only the /assistant/config probe, header button and Ctrl/⌘+J (about 1.7 KB first load). Measured 50.8 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "assistant-composer-form",
|
||||
"gzipKB": 55
|
||||
},
|
||||
"searchPage": {
|
||||
"$comment": "The search results page (/app/search: filters, sort, result cards, split preview pane, side sheet), loaded on demand: import() in app/search/page.tsx. Measured 13.4 KB gzip at introduction (QRun-IO/qqq-frontend-next#59); budget about 8% above.",
|
||||
"marker": "search-result-card",
|
||||
"gzipKB": 14.5
|
||||
},
|
||||
"quickSearchPanel": {
|
||||
"$comment": "The quick search results panel of the header box and the '/' dialog (groups, highlights, completions, failure and fallback notices), loaded when the panel first opens: import() in GlobalSearch and SearchDialog. Measured 9.4 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "search-results-status",
|
||||
"gzipKB": 10.2
|
||||
},
|
||||
"commandRecordResults": {
|
||||
"$comment": "The command palette's record groups, loaded when the palette searches records: import() in CommandMenu. Measured 7.5 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "command-menu-searching",
|
||||
"gzipKB": 8.1
|
||||
},
|
||||
"recordPreview": {
|
||||
"$comment": "The search result record preview (first-tier fields as text, Yes/No and links; quick actions), loaded when a preview first shows: import() in SearchPage. Measured after merging main b32a29d: with FieldValue +17 KB, with the useRecord/useTableMetaData hooks +12.4 KB, with file thumbnails and chips +9.3 KB of total JS, because Turbopack re-bundles those shared modules into more screen chunks; so it runs its own queries (same query keys) and renders values itself. Measured 3.7 KB gzip; budget about 8% above.",
|
||||
"marker": "search-record-preview",
|
||||
"gzipKB": 4.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,179 +15,33 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Search page — every page (app, table, process, report) and recently viewed record matching
|
||||
* `?q=`, plus the records a backend record search finds when the metadata advertises searchable tables.
|
||||
* @file Search page — every page (app, table, process, report) and recently viewed record
|
||||
* matching `?q=`, plus the records a backend record search finds when the metadata
|
||||
* advertises searchable tables. URL state: `q`, `tables`, `view=pages`, `sort=recent`.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { Search } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import { searchableTables } from '@/lib/utils/record-search'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Records listed per table on the search page. */
|
||||
const PAGE_RECORDS_PER_TABLE = 25
|
||||
/** The results page loads on demand, so its code stays out of every route's first load. */
|
||||
const SearchPage = dynamic(() => import('@/components/search/SearchPage').then((module) => module.SearchPage), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="search-page-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading search…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the search page at `/app/search`.
|
||||
*
|
||||
* Pages and recently viewed records match locally by label. When the metadata
|
||||
* lists searchable tables, the records a backend record search finds are listed
|
||||
* too; otherwise no search request is made. The `q` parameter is kept in the URL
|
||||
* so results are shareable and survive refresh.
|
||||
*
|
||||
* @returns The search input and matching pages and records.
|
||||
* @returns The search page.
|
||||
*/
|
||||
export default function SearchResultsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
const term = searchParams.get('q') ?? ''
|
||||
const [inputValue, setInputValue] = useState(term)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
|
||||
const { data: metaData } = useQuery({
|
||||
queryKey: queryKeys.metadataAll(),
|
||||
queryFn: loadMetaData,
|
||||
staleTime: 1000 * 60 * 30,
|
||||
})
|
||||
const { navTargets } = useAppTreeRoutes(metaData)
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(term)
|
||||
}, [term])
|
||||
|
||||
useEffect(() => {
|
||||
setRecentRecords(getRecentRecords())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader('Search')
|
||||
}, [setPageHeader])
|
||||
|
||||
const searchTables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
const recordSearch = useRecordSearch(term, searchTables, PAGE_RECORDS_PER_TABLE)
|
||||
const items = useMemo(
|
||||
() => (term.trim() ? buildNavigationSearchItems(navTargets, recentRecords, term, 50, recordSearch.results) : []),
|
||||
[navTargets, recentRecords, term, recordSearch.results]
|
||||
)
|
||||
const pages = items.filter((item) => item.kind === 'page')
|
||||
const found = items.filter((item) => item.kind === 'result')
|
||||
const records = items.filter((item) => item.kind === 'record')
|
||||
const searching = recordSearch.available && recordSearch.isSearching
|
||||
|
||||
/**
|
||||
* Writes the input value to the `q` URL parameter.
|
||||
*
|
||||
* @param event - Form submit event.
|
||||
*/
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const next = inputValue.trim()
|
||||
router.replace(next ? `/app/search?q=${encodeURIComponent(next)}` : '/app/search')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-qqq-id="search-results-page">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{term ? <>Search results for “{term}”</> : 'Search'}
|
||||
</h1>
|
||||
|
||||
<form role="search" onSubmit={handleSubmit} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
placeholder={recordSearch.available ? 'Find an app, table, process or record...' : 'Find an app, table, process or recent record...'}
|
||||
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
className="w-full rounded-xl border border-input bg-card py-3 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="search-input"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{!term && (
|
||||
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-prompt-state">
|
||||
Enter text above to find apps, tables, processes and recently viewed records.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Record search progress and errors, announced politely */}
|
||||
<p role="status" aria-live="polite" className={searching || (recordSearch.available && recordSearch.isError) ? 'text-sm text-muted-foreground' : 'sr-only'} data-qqq-id="search-records-status">
|
||||
{searching ? 'Searching records…' : recordSearch.available && recordSearch.isError ? 'Record search failed. Try again.' : ''}
|
||||
</p>
|
||||
|
||||
{term && items.length === 0 && !searching && (
|
||||
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-empty-state">
|
||||
{recordSearch.available ? 'No pages or records match' : 'No pages or recent records match'} “{term}”.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pages.length > 0 && (
|
||||
<section aria-label="Pages" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-pages">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Pages</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{pages.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-page-${item.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={item.icon} kind={item.nodeType ? ICON_KIND[item.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{found.length > 0 && (
|
||||
<section aria-label="Records" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-found-records">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Records</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{found.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-record-${item.path.replace(/\//g, '-')}`}>
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{records.length > 0 && (
|
||||
<section aria-label="Recently viewed" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-records">
|
||||
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Recently viewed</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{records.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-recent-${item.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={item.icon} kind="table" className="text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.context}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <SearchPage />
|
||||
}
|
||||
@@ -18,17 +18,35 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render as rtlRender, screen } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const pushMock = vi.fn()
|
||||
let pathname = '/app'
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }), usePathname: () => pathname }))
|
||||
|
||||
const searchRecordsMock = vi.fn()
|
||||
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
|
||||
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Renders inside a fresh query client (the palette runs record search through TanStack Query).
|
||||
*
|
||||
* @param ui - Element to render.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function render(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return rtlRender(ui, { wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> })
|
||||
}
|
||||
|
||||
import { CommandMenu } from './CommandMenu'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import type { QInstance, QTableMetaData } from '@/types'
|
||||
import { addRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
|
||||
const app = { label: 'People App', path: '/app/peopleApp' }
|
||||
const targets: NavTarget[] = [
|
||||
@@ -80,12 +98,13 @@ describe('CommandMenu', () => {
|
||||
]
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={more} />)
|
||||
await user.type(screen.getByRole('combobox'), 'pet')
|
||||
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
|
||||
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-navigation"] [role="option"]')).map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
|
||||
await user.type(screen.getByRole('combobox'), ' spe')
|
||||
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
|
||||
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-navigation"] [role="option"]')).map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
|
||||
await user.clear(screen.getByRole('combobox'))
|
||||
await user.type(screen.getByRole('combobox'), 'nothing here')
|
||||
expect(screen.queryAllByRole('option')).toHaveLength(0)
|
||||
// only the search-everything entry remains
|
||||
expect(screen.queryAllByRole('option').map((option) => option.textContent)).toEqual(['Search everything for “nothing here”'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('No results found.')
|
||||
})
|
||||
|
||||
@@ -142,4 +161,62 @@ describe('CommandMenu', () => {
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('record search', () => {
|
||||
const searchMeta = {
|
||||
tables: {
|
||||
person: { name: 'person', label: 'Person', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['firstName'], icon: { name: 'person' } },
|
||||
},
|
||||
processes: {},
|
||||
apps: {},
|
||||
} as unknown as QInstance
|
||||
|
||||
beforeEach(() => {
|
||||
pathname = '/app'
|
||||
pushMock.mockReset()
|
||||
searchRecordsMock.mockReset()
|
||||
clearRecentRecords()
|
||||
window.open = vi.fn() as unknown as typeof window.open
|
||||
})
|
||||
|
||||
it('lists found records under their table with a count, and a search-everything entry', async () => {
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Avery Sample', updatedAt: '2026-10-01T10:00:00Z', highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'avery@example.com', matches: [{ start: 0, end: 5 }] }] }],
|
||||
tables: [{ tableName: 'person', resultCount: 1, hasMore: true, totalCount: 12, source: 'PROVIDER' }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
|
||||
await user.type(screen.getByRole('combobox'), 'avery')
|
||||
const record = await screen.findByRole('option', { name: /Avery Sample/ })
|
||||
expect(record).toHaveTextContent('Email: avery@example.com')
|
||||
expect(Array.from(record.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Avery', 'avery'])
|
||||
expect(document.querySelector('[data-qqq-id="command-group-records-person"]')).toHaveTextContent('Person · 12')
|
||||
expect(screen.getByRole('option', { name: /See all matches in Person/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: /Search everything for/ })).toBeInTheDocument()
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('avery', expect.objectContaining({ tableNames: ['person'], limitPerTable: 5 }))
|
||||
})
|
||||
|
||||
it('opens the highlighted record in a new tab on Ctrl+Enter', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', recordId: '7', recordLabel: 'Avery Sample' }] })
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
|
||||
await user.type(screen.getByRole('combobox'), 'avery')
|
||||
await screen.findByRole('option', { name: /Avery Sample/ })
|
||||
// the record is the first entry, so cmdk highlights it
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(window.open).toHaveBeenCalledWith('/app/person/7', '_blank', 'noopener,noreferrer')
|
||||
expect(pushMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the failure class when record search fails, keeping navigation results', async () => {
|
||||
const { AxiosError } = await import('axios')
|
||||
searchRecordsMock.mockRejectedValue(new AxiosError('Index unavailable', 'ERR_BAD_RESPONSE', undefined, null, { status: 503, data: {}, statusText: '', headers: {}, config: {} as never }))
|
||||
const user = userEvent.setup()
|
||||
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
|
||||
await user.type(screen.getByRole('combobox'), 'pe')
|
||||
expect(await screen.findByText('Record search failed: server error (HTTP 503)')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Index unavailable/)).toBeInTheDocument()
|
||||
expect(document.querySelector('[data-qqq-id="command-item-peopleApp"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Search, Sparkles, X } from 'lucide-react'
|
||||
|
||||
@@ -31,8 +32,13 @@ import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { buildTableActions, commandMatches, compareCommandLabels, tableScreenFor, type TableAction } from '@/lib/utils/command-palette'
|
||||
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { searchableTables } from '@/lib/utils/record-search'
|
||||
import { openInNewTab, searchPagePath } from '@/lib/search/search-url'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
|
||||
/** Record search results, loaded when the palette opens with searchable tables. */
|
||||
const CommandRecordResults = dynamic(() => import('@/components/search/CommandRecordResults').then((module) => module.CommandRecordResults), { ssr: false })
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
@@ -96,6 +102,8 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
const [search, setSearch] = useState('')
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const [hash, setHash] = useState('')
|
||||
// value of the highlighted entry (cmdk), for Ctrl/Cmd+Enter
|
||||
const [selectedValue, setSelectedValue] = useState('')
|
||||
|
||||
// Recently viewed records and the hash (an open audit dialog hides the table actions) are read on open
|
||||
useEffect(() => {
|
||||
@@ -168,6 +176,24 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
const screen = useMemo(() => (open ? tableScreenFor(pathname ?? '', hash, metaData) : null), [open, pathname, hash, metaData])
|
||||
const tableActions = useMemo(() => (screen && metaData ? buildTableActions(screen, metaData) : []), [screen, metaData])
|
||||
|
||||
// Records found by record search (only when the metadata lists searchable tables)
|
||||
const tables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
const [recordState, setRecordState] = useState({ count: 0, searching: false })
|
||||
const trimmedSearch = search.trim()
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd+Enter opens the highlighted entry in a new tab (cmdk handles plain Enter).
|
||||
*
|
||||
* @param event - Keydown inside the palette (runs before cmdk's own handler).
|
||||
*/
|
||||
const handlePaletteKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return
|
||||
const prefix = selectedValue.slice(0, selectedValue.indexOf(':') + 1)
|
||||
if (!['page:', 'recent:', 'record:', 'search:'].includes(prefix)) return
|
||||
event.preventDefault()
|
||||
openInNewTab(selectedValue.slice(prefix.length))
|
||||
}
|
||||
|
||||
// Material's filter (per-word substring) and ranking (labels that start with the search first)
|
||||
const visibleActions = search ? tableActions.filter((action) => commandMatches(action.label, search)) : tableActions
|
||||
const visibleTargets = useMemo(() => {
|
||||
@@ -183,6 +209,12 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
.sort((a, b) => compareCommandLabels(a.recordLabel, b.recordLabel, search))
|
||||
}, [recentRecords, search])
|
||||
|
||||
// first entries around the records, for moving cmdk's highlight when records arrive
|
||||
const firstValueBefore = (screen && visibleActions[0] && `action:${visibleActions[0].key}`)
|
||||
|| (visibleTargets[0] && `page:${visibleTargets[0].path}`)
|
||||
|| undefined
|
||||
const firstValueAfter = visibleRecent[0] ? `recent:${visibleRecent[0].path}` : undefined
|
||||
|
||||
/**
|
||||
* Runs a table action: navigates to it, or opens the record's audit dialog (`#audit`).
|
||||
*
|
||||
@@ -224,14 +256,14 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
ref={dialogRef}
|
||||
onKeyDown={trapFocus}
|
||||
>
|
||||
<Command shouldFilter={false} label="Command palette">
|
||||
<Command shouldFilter={false} label="Command palette" value={selectedValue} onValueChange={setSelectedValue} onKeyDown={handlePaletteKeyDown}>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search for tables, actions, or recently viewed items..."
|
||||
placeholder={tables.length > 0 ? 'Search for tables, actions, records, or recently viewed items...' : 'Search for tables, actions, or recently viewed items...'}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autoFocus
|
||||
data-qqq-id="command-menu-search"
|
||||
@@ -254,7 +286,7 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
aria-live="polite"
|
||||
data-qqq-id="command-menu-results"
|
||||
>
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && !(onAskAssistant && search.trim()) && (
|
||||
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && recordState.count === 0 && !recordState.searching && !(onAskAssistant && search.trim()) && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground" role="status" data-qqq-id="command-menu-empty">
|
||||
No results found.
|
||||
</p>
|
||||
@@ -301,6 +333,22 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{open && tables.length > 0 && (
|
||||
<CommandRecordResults
|
||||
search={search}
|
||||
open={open}
|
||||
tables={tables}
|
||||
selectedValue={selectedValue}
|
||||
setSelectedValue={setSelectedValue}
|
||||
firstValueBefore={firstValueBefore}
|
||||
firstValueAfter={firstValueAfter}
|
||||
onSelect={handleSelect}
|
||||
onState={setRecordState}
|
||||
itemClassName={ITEM_CLASS}
|
||||
heading={(text) => <GroupHeading>{text}</GroupHeading>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleRecent.length > 0 && (
|
||||
<Command.Group heading={<GroupHeading>Recently Viewed Records</GroupHeading>} data-qqq-id="command-group-recent">
|
||||
{visibleRecent.map((record) => (
|
||||
@@ -318,6 +366,21 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{trimmedSearch && (
|
||||
<Command.Group heading={<GroupHeading>Search</GroupHeading>} data-qqq-id="command-group-search">
|
||||
<Command.Item
|
||||
value={`search:${searchPagePath({ q: trimmedSearch })}`}
|
||||
onSelect={() => handleSelect(searchPagePath({ q: trimmedSearch }))}
|
||||
className={ITEM_CLASS}
|
||||
data-qqq-id="command-search-all"
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate">Search everything for <span className="font-medium">“{trimmedSearch}”</span></span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* Last, so Enter still opens the best navigation match; alone when nothing else matches */}
|
||||
{onAskAssistant && search.trim() && (
|
||||
<Command.Group heading={<GroupHeading>Assistant</GroupHeading>} data-qqq-id="command-group-assistant">
|
||||
@@ -348,6 +411,10 @@ export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistan
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
|
||||
open
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">Ctrl/⌘ ↵</kbd>{' '}
|
||||
new tab
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">esc</kbd>{' '}
|
||||
close
|
||||
|
||||
@@ -38,16 +38,18 @@ function Harness() {
|
||||
describe('SearchDialog keyboard access', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('keeps both Tab directions in an empty dialog', async () => {
|
||||
it('keeps both Tab directions inside an empty dialog (input and the close button)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Harness />)
|
||||
await user.click(screen.getByRole('button', { name: 'Open search' }))
|
||||
const input = screen.getByRole('combobox')
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(input).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('wraps between the first and last controls when the query adds buttons', async () => {
|
||||
@@ -57,13 +59,13 @@ describe('SearchDialog keyboard access', () => {
|
||||
const input = screen.getByRole('combobox')
|
||||
await user.type(input, 'no matches')
|
||||
await user.tab({ shift: true })
|
||||
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Clear search' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('dismisses with Escape from a button and returns focus to the opener', async () => {
|
||||
@@ -90,7 +92,7 @@ describe('SearchDialog keyboard access', () => {
|
||||
expect(input).toHaveValue('')
|
||||
expect(input).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
@@ -15,30 +15,32 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file SearchDialog — "/" key search dialog over pages and recently viewed records, plus
|
||||
* records found by the backend record search when the metadata advertises searchable tables.
|
||||
* @file SearchDialog — "/" key (and mobile header) search dialog: search-as-you-type over
|
||||
* pages, recently viewed records and recent searches, plus records found by the backend
|
||||
* record search when the metadata advertises searchable tables.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import React, { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, ArrowRight, X } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { NavigationSearchResults } from '@/components/layout/NavigationSearchResults'
|
||||
import { searchPagePath } from '@/lib/search/search-url'
|
||||
import type { QuickSearchPanelController } from '@/components/search/QuickSearchPanel'
|
||||
|
||||
/** Records shown per table in the dialog. */
|
||||
const DIALOG_RECORDS_PER_TABLE = 5
|
||||
/** The results panel, loaded when the dialog first opens. */
|
||||
const QuickSearchPanel = dynamic(() => import('@/components/search/QuickSearchPanel').then((module) => module.QuickSearchPanel), { ssr: false })
|
||||
|
||||
/** Stable empty default for `searchTables`. */
|
||||
const NO_SEARCH_TABLES: SearchableTable[] = []
|
||||
|
||||
/** DOM id of the results listbox. */
|
||||
const LISTBOX_ID = 'search-dialog-results'
|
||||
|
||||
/**
|
||||
* Props for the SearchDialog component.
|
||||
*/
|
||||
@@ -54,13 +56,11 @@ interface SearchDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal search dialog triggered by the `/` keyboard shortcut.
|
||||
* Modal search dialog triggered by the `/` keyboard shortcut and the mobile header button.
|
||||
*
|
||||
* Lists recently viewed records until the user types, then matching pages
|
||||
* (apps, tables, processes, reports by label), records found by record search
|
||||
* (when available) and matching recent records.
|
||||
* ArrowUp/Down move the selection, Enter opens it (or the search page when
|
||||
* nothing is selected), Escape closes.
|
||||
* Lists recent searches and recently viewed records until the user types, then grouped
|
||||
* results (top hits, pages, records per table, recently viewed). ↑/↓ move the selection,
|
||||
* Enter opens it, Ctrl/⌘+Enter opens it in a new tab, Escape closes.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The dialog overlay, or `null` when closed.
|
||||
@@ -70,78 +70,48 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
onClose()
|
||||
router.push(path)
|
||||
}, [router, onClose])
|
||||
|
||||
// Load recent records when the dialog opens; reset state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const returnTo = document.activeElement
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
setSearchTerm('')
|
||||
setSelectedIndex(-1)
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
|
||||
}
|
||||
const [term, setTerm] = useState('')
|
||||
const controllerRef = useRef<QuickSearchPanelController | null>(null)
|
||||
const [activeDescendant, setActiveDescendant] = useState<string>()
|
||||
const recordSearchAvailable = searchTables.length > 0
|
||||
|
||||
const handleController = useCallback((controller: QuickSearchPanelController | null) => {
|
||||
controllerRef.current = controller
|
||||
setActiveDescendant(controller?.activeDescendant)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Defers ↑↓, Enter and Ctrl/⌘+Enter to the results panel (Enter searches everything while it loads).
|
||||
*
|
||||
* @param event - The input's keydown event.
|
||||
*/
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (controllerRef.current) {
|
||||
controllerRef.current.onInputKeyDown(event)
|
||||
} else if (event.key === 'Enter' && term.trim()) {
|
||||
event.preventDefault()
|
||||
handleNavigate(searchPagePath({ q: term }))
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const recordSearch = useRecordSearch(open ? searchTerm : '', searchTables, DIALOG_RECORDS_PER_TABLE)
|
||||
const items = useMemo(
|
||||
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm, 8, recordSearch.results),
|
||||
[navTargets, recentRecords, searchTerm, recordSearch.results]
|
||||
)
|
||||
}
|
||||
|
||||
// Reset and focus on open; give focus back on close
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* Closes the dialog and navigates to the given path.
|
||||
*
|
||||
* @param path - The URL path to navigate to.
|
||||
*/
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
onClose()
|
||||
router.push(path)
|
||||
},
|
||||
[router, onClose]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation within the result list.
|
||||
*
|
||||
* @param e - The synthetic keyboard event from the search input.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
handleNavigate(items[selectedIndex].path)
|
||||
} else if (searchTerm.trim()) {
|
||||
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[selectedIndex, items, searchTerm, handleNavigate]
|
||||
)
|
||||
if (!open) return
|
||||
const returnTo = document.activeElement
|
||||
setTerm('')
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
|
||||
}
|
||||
}, [open, setTerm])
|
||||
|
||||
/**
|
||||
* Handles dismissal from every control and keeps keyboard focus inside the modal.
|
||||
@@ -168,104 +138,82 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const hasTerm = searchTerm.trim().length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
|
||||
data-qqq-id="search-dialog-backdrop"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-0 z-[2000] flex items-start justify-center px-2 pt-[6vh] sm:px-4 sm:pt-[12vh]" data-qqq-id="search-dialog-backdrop">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} aria-hidden="true" />
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
|
||||
className="relative z-10 flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg sm:max-h-[76vh]"
|
||||
data-qqq-id="search-dialog"
|
||||
role="dialog"
|
||||
aria-label="Search"
|
||||
aria-modal="true"
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={recordSearch.available ? 'Search pages and records...' : 'Jump to a page or recent record...'}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={recordSearchAvailable ? 'Search pages and records…' : 'Jump to a page or recent record…'}
|
||||
className="min-w-0 flex-1 bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none sm:text-sm"
|
||||
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
aria-expanded={true}
|
||||
aria-controls="search-dialog-results"
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-qqq-id="search-dialog-input"
|
||||
/>
|
||||
{searchTerm && (
|
||||
{term && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus()
|
||||
setSearchTerm('')
|
||||
setTerm('')
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="hidden sm:inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close search"
|
||||
>
|
||||
esc
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{hasTerm || items.length > 0 ? (
|
||||
<NavigationSearchResults
|
||||
id="search-dialog-results"
|
||||
items={items}
|
||||
term={searchTerm}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleNavigate}
|
||||
onHover={setSelectedIndex}
|
||||
recordSearch={recordSearch}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<QuickSearchPanel
|
||||
listboxId={LISTBOX_ID}
|
||||
open={open}
|
||||
term={term}
|
||||
setTerm={setTerm}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
onNavigate={handleNavigate}
|
||||
onController={handleController}
|
||||
listClassName="min-h-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<div id="search-dialog-results" className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{recordSearch.available ? 'Type to find an app, table, process or record...' : 'Type to find an app, table, process or recent record...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex gap-4">
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}open
|
||||
</span>
|
||||
</div>
|
||||
{hasTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
Show all matches
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden items-center gap-4 border-t border-border px-4 py-2 text-xs text-muted-foreground sm:flex" aria-hidden="true">
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd> open</span>
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">Ctrl/⌘ ↵</kbd> new tab</span>
|
||||
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">⇧ ↵</kbd> all results</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for GlobalSearch: local "jump to" search over navigation targets and recent records,
|
||||
// plus backend record search when (and only when) searchable tables are given
|
||||
// Tests for GlobalSearch: search-as-you-type over pages, recent records and recent searches,
|
||||
// plus backend record search when (and only when) searchable tables are given: grouping,
|
||||
// top hits, counts, highlights, keyboard (incl. Ctrl+Enter), failure classes and fallbacks
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError, type AxiosResponse } from 'axios'
|
||||
|
||||
const pushMock = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -32,11 +34,15 @@ vi.mock('next/navigation', () => ({
|
||||
}))
|
||||
|
||||
const searchRecordsMock = vi.fn()
|
||||
vi.mock('@/lib/api/tables', () => ({ searchRecords: (...args: unknown[]) => searchRecordsMock(...args) }))
|
||||
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
|
||||
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
|
||||
}))
|
||||
|
||||
import { GlobalSearch } from './GlobalSearch'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
import { addRecentSearch, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
|
||||
const peopleApp = { label: 'People App', path: '/app/peopleApp' }
|
||||
@@ -49,6 +55,12 @@ const navTargets: NavTarget[] = [
|
||||
{ key: 'greetInteractive', label: 'Greet Interactive', path: '/app/greetInteractive', nodeType: 'PROCESS', ancestors: [peopleApp, greetingsApp] },
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders the header search in a fresh query client.
|
||||
*
|
||||
* @param searchTables - Searchable tables (omit: no record search).
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderSearch(searchTables?: SearchableTable[]) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
@@ -60,7 +72,19 @@ function renderSearch(searchTables?: SearchableTable[]) {
|
||||
|
||||
const input = () => screen.getByRole('combobox', { name: 'Search pages and recent records' })
|
||||
const recordInput = () => screen.getByRole('combobox', { name: 'Search pages and records' })
|
||||
const people: SearchableTable[] = [{ name: 'person', label: 'Person' }, { name: 'pet', label: 'Pet' }]
|
||||
const people: SearchableTable[] = [{ name: 'person', label: 'Person', icon: { name: 'person' } }, { name: 'pet', label: 'Pet' }]
|
||||
const optionLabels = (container: HTMLElement) => within(container).getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent ?? option.textContent)
|
||||
// jsdom's accessible name splits highlighted text ("Greet Inter active"): find options by text content
|
||||
const option = (pattern: RegExp, container: HTMLElement = document.body) => {
|
||||
const found = within(container).queryAllByRole('option').find((candidate) => pattern.test(candidate.textContent ?? ''))
|
||||
if (!found) throw new Error(`No option matches ${pattern}`)
|
||||
return found
|
||||
}
|
||||
const findOption = async (pattern: RegExp) => {
|
||||
await screen.findAllByRole('option')
|
||||
return vi.waitFor(() => option(pattern))
|
||||
}
|
||||
const selected = () => screen.getAllByRole('option').find((option) => option.getAttribute('aria-selected') === 'true')
|
||||
|
||||
describe('GlobalSearch', () => {
|
||||
const fetchSpy = vi.fn()
|
||||
@@ -68,9 +92,11 @@ describe('GlobalSearch', () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset()
|
||||
clearRecentRecords()
|
||||
localStorage.removeItem('qqq-recent-searches')
|
||||
fetchSpy.mockReset()
|
||||
searchRecordsMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
window.open = vi.fn() as unknown as typeof window.open
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -83,86 +109,152 @@ describe('GlobalSearch', () => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches navigation targets by label and shows their app context', async () => {
|
||||
it('lists matching pages, best label matches first, with their type and app context', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
const options = within(screen.getByRole('group', { name: 'Pages' })).getAllByRole('option')
|
||||
expect(options.map((option) => option.querySelector('span span')?.textContent)).toEqual(['People App', 'Person', 'Pet', 'Greetings App', 'Greet Interactive'])
|
||||
expect(options[1]).toHaveTextContent('People App / Greetings App')
|
||||
expect(options[1].querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
|
||||
// "People App", "Person" and "Pet" start with the term: top hits (at most three, closest first)
|
||||
expect(optionLabels(await screen.findByRole('group', { name: 'Top hits' }))).toEqual(['Pet', 'Person', 'People App'])
|
||||
const pages = screen.getByRole('group', { name: 'Pages' })
|
||||
expect(optionLabels(pages)).toEqual(['Greetings App', 'Greet Interactive'])
|
||||
expect(within(pages).getAllByRole('option')[1]).toHaveTextContent('Process · People App / Greetings App')
|
||||
expect(within(screen.getByRole('group', { name: 'Top hits' })).getAllByRole('option')[1].querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
|
||||
})
|
||||
|
||||
it('matches targets through their enclosing app label', async () => {
|
||||
it('highlights the matched text', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'greetings')
|
||||
const labels = screen.getAllByRole('option').map((option) => option.querySelector('span span')?.textContent)
|
||||
expect(labels).toEqual(['Greetings App', 'Person', 'Pet', 'Greet Interactive'])
|
||||
await user.type(input(), 'inter')
|
||||
expect((await findOption(/Greet Interactive/)).querySelector('mark')).toHaveTextContent('Inter')
|
||||
})
|
||||
|
||||
it('navigates to the clicked target and clears the input', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'Greet Inter')
|
||||
await user.click(screen.getByRole('option', { name: /^Greet/ }))
|
||||
await user.click(option(/^Greet Interactive/))
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/greetInteractive')
|
||||
expect(input()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('supports arrow keys and Enter', async () => {
|
||||
it('selects the first result while typing so Enter opens the top hit, and arrows move', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pet')
|
||||
expect(selected()).toHaveTextContent('Pet')
|
||||
expect(input()).toHaveAttribute('aria-activedescendant', selected()!.id)
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(selected()).toHaveTextContent('Search everything for “pet”')
|
||||
await user.keyboard('{ArrowUp}{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/pet')
|
||||
})
|
||||
|
||||
it('opens the search page on Enter with nothing selected', async () => {
|
||||
it('opens the selected result in a new tab with Ctrl+Enter and keeps the search open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'zzz{Enter}')
|
||||
await user.type(input(), 'pet')
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(window.open).toHaveBeenCalledWith('/app/pet', '_blank', 'noopener,noreferrer')
|
||||
expect(pushMock).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers "search everything" as the only result when nothing matches, and Enter opens the search page', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'zzz')
|
||||
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or recent records match “zzz”')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=zzz')
|
||||
expect(getRecentSearches()).toEqual(['zzz'])
|
||||
})
|
||||
|
||||
it('keeps the keyboard selection when results appear under a resting pointer', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
const options = screen.getAllByRole('option')
|
||||
// Results rendering under the pointer fire mouseenter without pointer movement
|
||||
fireEvent.mouseEnter(screen.getAllByRole('option')[0])
|
||||
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'false')
|
||||
fireEvent.mouseMove(screen.getAllByRole('option')[1])
|
||||
expect(screen.getAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.mouseEnter(options[2])
|
||||
expect(options[0]).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.mouseMove(options[2])
|
||||
expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('shows an empty message when nothing matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'zzz')
|
||||
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or recent records match “zzz”')
|
||||
})
|
||||
|
||||
it('lists recently viewed records before typing and filters them by label', async () => {
|
||||
it('lists recent searches and recently viewed records before typing; a recent search fills the box', async () => {
|
||||
addRecentRecord({ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Sample', path: '/app/person/1' })
|
||||
addRecentSearch('greet')
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.click(input())
|
||||
expect(within(screen.getByRole('group', { name: 'Recently viewed' })).getByRole('option', { name: /Avery Sample/ })).toBeInTheDocument()
|
||||
await user.type(input(), 'avery')
|
||||
await user.click(screen.getByRole('option', { name: /Avery Sample/ }))
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/1')
|
||||
expect(selected()).toBeUndefined()
|
||||
await user.click(within(screen.getByRole('group', { name: 'Recent searches' })).getByRole('option', { name: 'greet' }))
|
||||
expect(input()).toHaveValue('greet')
|
||||
expect(option(/Greet Interactive/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
it('clears recent searches', async () => {
|
||||
addRecentSearch('greet')
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.click(input())
|
||||
await user.click(screen.getByRole('button', { name: 'Clear recent searches' }))
|
||||
expect(screen.queryByRole('group', { name: 'Recent searches' })).not.toBeInTheDocument()
|
||||
expect(getRecentSearches()).toEqual([])
|
||||
})
|
||||
|
||||
it('closes on Escape, and a second Escape clears the text', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(input()).toHaveValue('pe')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(input()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('after Escape closes the dropdown, Enter searches everything instead of opening a hidden option', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pet')
|
||||
await screen.findByRole('listbox')
|
||||
await user.keyboard('{Escape}{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
|
||||
})
|
||||
|
||||
it('typing a new term drops a selection made by pointer for the previous one', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pe')
|
||||
await screen.findByRole('listbox')
|
||||
fireEvent.mouseMove(option(/Search everything/))
|
||||
expect(selected()).toHaveTextContent('Search everything')
|
||||
await user.type(input(), 't')
|
||||
expect(selected()).toHaveTextContent('Pet')
|
||||
})
|
||||
|
||||
it('suggests completions of the last word from matching labels; choosing one fills the box', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'greet in')
|
||||
const suggestions = await screen.findByRole('group', { name: 'Suggestions' })
|
||||
expect(optionLabels(suggestions)).toEqual(['greet interactive'])
|
||||
// a completion is never preselected: Enter still opens the top hit
|
||||
expect(selected()).toHaveTextContent('Greet Interactive')
|
||||
await user.click(option(/greet interactive/, suggestions))
|
||||
expect(input()).toHaveValue('greet interactive')
|
||||
})
|
||||
|
||||
it('Shift+Enter opens the results page even with a result selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSearch()
|
||||
await user.type(input(), 'pet')
|
||||
await screen.findByRole('listbox')
|
||||
await user.keyboard('{Shift>}{Enter}{/Shift}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
|
||||
})
|
||||
|
||||
it('never calls the backend without searchable tables (no record search capability)', async () => {
|
||||
@@ -176,59 +268,119 @@ describe('GlobalSearch', () => {
|
||||
})
|
||||
|
||||
describe('with record search', () => {
|
||||
it('labels the box for records and lists found records by label with their table, after pages', async () => {
|
||||
searchRecordsMock.mockResolvedValue([
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Petersen' },
|
||||
{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Pepper' },
|
||||
])
|
||||
it('groups found records by table with counts, highlights matched fields and links to more', async () => {
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Petersen', highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'avery@pets.io', matches: [{ start: 6, end: 8 }] }] },
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '2', recordLabel: 'Sam Pepe', details: [{ fieldName: 'city', displayValue: 'Austin' }] },
|
||||
{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Biscuit' },
|
||||
],
|
||||
tables: [{ tableName: 'person', resultCount: 2, hasMore: true, totalCount: 31 }, { tableName: 'pet', resultCount: 1, hasMore: false }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'pe')
|
||||
const records = within(await screen.findByRole('group', { name: 'Records' })).getAllByRole('option')
|
||||
expect(records.map((option) => option.querySelector('span span')?.textContent)).toEqual(['Avery Petersen', 'Pepper'])
|
||||
expect(records[0].querySelectorAll('span span')[1]).toHaveTextContent('Person')
|
||||
expect(records[1].querySelectorAll('span span')[1]).toHaveTextContent('Pet')
|
||||
const personGroup = await screen.findByRole('group', { name: /^Person/ })
|
||||
expect(personGroup).toHaveAccessibleName('Person 31 matches')
|
||||
expect(optionLabels(personGroup)).toEqual(['Avery Petersen', 'Sam Pepe', 'See all 31 matches in Person'])
|
||||
const avery = option(/Avery Petersen/, personGroup)
|
||||
expect(avery).toHaveTextContent('Email: avery@pets.io')
|
||||
expect(Array.from(avery.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Pe', 'pe'])
|
||||
expect(option(/Sam Pepe/, personGroup)).toHaveTextContent('Austin')
|
||||
expect(within(personGroup).getByRole('option', { name: /See all 31/ })).toHaveAttribute('href', '/app/search?q=pe&tables=person')
|
||||
expect(screen.getByRole('group', { name: /^Pet/ })).toHaveAccessibleName('Pet 1 matches')
|
||||
expect(searchRecordsMock).toHaveBeenCalledTimes(1)
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('pe', { tableNames: ['person', 'pet'], limitPerTable: 5 })
|
||||
const groups = screen.getAllByRole('group').map((group) => group.getAttribute('aria-label'))
|
||||
expect(groups).toEqual(['Pages', 'Records'])
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('pe', expect.objectContaining({ tableNames: ['person', 'pet'], limitPerTable: 6, offset: 0, sort: 'relevance', signal: expect.any(AbortSignal) }))
|
||||
})
|
||||
|
||||
it('opens a found record by keyboard', async () => {
|
||||
searchRecordsMock.mockResolvedValue([{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }])
|
||||
it('keeps "nothing selected" after ArrowUp when records arrive later, so Enter opens the results page', async () => {
|
||||
// records answer well after the page matches are listed and the user pressed ArrowUp
|
||||
searchRecordsMock.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
return { results: [{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Petunia' }] }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'zulu')
|
||||
await screen.findByRole('option', { name: /Zed Zulu/ })
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(screen.getByRole('option', { name: /Zed Zulu/ })).toHaveAttribute('aria-selected', 'true')
|
||||
await user.type(recordInput(), 'pet')
|
||||
await vi.waitFor(() => expect(selected()).toHaveTextContent('Pet'))
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(selected()).toBeUndefined()
|
||||
await vi.waitFor(() => option(/Petunia/), { timeout: 3000 })
|
||||
expect(selected()).toBeUndefined()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
|
||||
})
|
||||
|
||||
it('promotes an exact record match to top hits and opens it with Enter', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', recordId: '7', recordLabel: 'Zed Zulu' }] })
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'zed zulu')
|
||||
const top = await screen.findByRole('group', { name: 'Top hits' })
|
||||
expect(option(/Zed Zulu/, top)).toHaveTextContent('Person')
|
||||
expect(selected()).toHaveTextContent('Zed Zulu')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
|
||||
expect(getRecentSearches()).toEqual(['zed zulu'])
|
||||
})
|
||||
|
||||
it('never preselects a record left over from the previous term while the new search runs', async () => {
|
||||
searchRecordsMock.mockImplementation(async (term: string) => (term === 'acme'
|
||||
? { results: [{ tableName: 'person', recordId: '1', recordLabel: 'Acme Buyer' }] }
|
||||
: new Promise(() => undefined)))
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'acme')
|
||||
await findOption(/Acme Buyer/)
|
||||
await user.clear(recordInput())
|
||||
await user.type(recordInput(), 'zeta')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pushMock).toHaveBeenCalledWith('/app/search?q=zeta')
|
||||
expect(pushMock).not.toHaveBeenCalledWith('/app/person/1')
|
||||
})
|
||||
|
||||
it('does not search a one-character term, and drops recent records that were found', async () => {
|
||||
addRecentRecord({ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu', path: '/app/person/7' })
|
||||
searchRecordsMock.mockResolvedValue([{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }])
|
||||
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }] })
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'z')
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
expect(searchRecordsMock).not.toHaveBeenCalled()
|
||||
await user.type(recordInput(), 'e')
|
||||
await screen.findByRole('group', { name: 'Records' })
|
||||
await user.type(recordInput(), 'u')
|
||||
await screen.findByRole('group', { name: /^Person/ })
|
||||
expect(screen.queryByRole('group', { name: 'Recently viewed' })).not.toBeInTheDocument()
|
||||
// (jsdom names the highlighted label "Ze d Zulu": match the unhighlighted part)
|
||||
expect(screen.getAllByRole('option', { name: /Zulu/ })).toHaveLength(1)
|
||||
expect(screen.getAllByRole('option').filter((candidate) => /Zulu/.test(candidate.textContent ?? ''))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces searching and failures, and says what nothing matched', async () => {
|
||||
searchRecordsMock.mockRejectedValue(new Error('boom'))
|
||||
it('names the failure class and the backend message, keeps page matches, and retries', async () => {
|
||||
searchRecordsMock.mockRejectedValueOnce(new AxiosError('Error in record search provider', 'ERR_BAD_RESPONSE', undefined, null, { status: 500, data: {}, statusText: '', headers: {}, config: {} } as unknown as AxiosResponse))
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'qqqq')
|
||||
await user.type(recordInput(), 'pet')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching records…')
|
||||
await screen.findByText('Record search failed. Try again.')
|
||||
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or records match “qqqq”')
|
||||
const failure = await screen.findByText('Record search failed: server error (HTTP 500)')
|
||||
expect(failure.closest('[data-qqq-id="search-failure"]')).toHaveAttribute('data-failure-kind', 'server')
|
||||
expect(screen.getByText('Error in record search provider')).toBeInTheDocument()
|
||||
expect(option(/^Pet/)).toBeInTheDocument()
|
||||
searchRecordsMock.mockResolvedValueOnce({ results: [{ tableName: 'pet', recordId: '4', recordLabel: 'Petunia' }] })
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await findOption(/Petunia/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Record search failed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows fallback results with the provider failure class when the index failed', async () => {
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [{ tableName: 'pet', recordId: '4', recordLabel: 'Petunia' }],
|
||||
tables: [{ tableName: 'pet', resultCount: 1, hasMore: false, source: 'FALLBACK' }],
|
||||
provider: { status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused' },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderSearch(people)
|
||||
await user.type(recordInput(), 'petu')
|
||||
expect(await screen.findByText('Search index unavailable — showing basic matches instead')).toBeInTheDocument()
|
||||
expect(screen.getByText('ConnectException: Connection refused')).toBeInTheDocument()
|
||||
expect(option(/Petunia/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,32 +15,45 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file GlobalSearch — header search over apps, tables, processes, reports and recently
|
||||
* viewed records (matched locally), plus records found by the backend record search when
|
||||
* the metadata advertises searchable tables. Without searchable tables no search request is made.
|
||||
* @file GlobalSearch — header search-as-you-type over apps, tables, processes, reports and
|
||||
* recently viewed records (matched locally), plus records found by the backend record
|
||||
* search when the metadata advertises searchable tables (without them no search request
|
||||
* is made). The input is part of every page; the results panel loads on first focus.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, ArrowRight } from 'lucide-react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { getRecentRecords } from '@/lib/utils/recent-records'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { NavigationSearchResults } from './NavigationSearchResults'
|
||||
import { searchPagePath } from '@/lib/search/search-url'
|
||||
import type { QuickSearchPanelController } from '@/components/search/QuickSearchPanel'
|
||||
|
||||
/** Records shown per table in the dropdown. */
|
||||
const DROPDOWN_RECORDS_PER_TABLE = 5
|
||||
/** The results panel, loaded when the search first opens. */
|
||||
const QuickSearchPanel = dynamic(() => import('@/components/search/QuickSearchPanel').then((module) => module.QuickSearchPanel), { ssr: false })
|
||||
|
||||
/** Stable empty default for `searchTables`. */
|
||||
const NO_SEARCH_TABLES: SearchableTable[] = []
|
||||
|
||||
/** DOM id of the results listbox. */
|
||||
const LISTBOX_ID = 'header-search-results'
|
||||
|
||||
/** Keyboard hints under the results. */
|
||||
const FOOTER = (
|
||||
<div className="hidden items-center gap-4 border-t border-border px-3 py-1.5 text-[0.7rem] text-muted-foreground sm:flex" aria-hidden="true">
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">↵</kbd> open</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">Ctrl/⌘ ↵</kbd> new tab</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">⇧ ↵</kbd> all results</span>
|
||||
<span><kbd className="rounded border border-border px-1 font-mono">esc</kbd> close</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Props for the GlobalSearch component.
|
||||
*/
|
||||
@@ -54,41 +67,46 @@ export interface GlobalSearchProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline header search with a dropdown of matching pages (apps, tables,
|
||||
* processes, reports by label), records found by record search (when available)
|
||||
* and recently viewed records.
|
||||
* Inline header search with a dropdown of grouped results: top hits, pages, records per
|
||||
* table (with counts and "see all" links), recently viewed records and recent searches.
|
||||
*
|
||||
* - With no text, the dropdown lists recently viewed records.
|
||||
* - ArrowUp/ArrowDown move the selection; Enter opens the selected result, or
|
||||
* the search page (`/app/search?q=…`) when nothing is selected; Escape closes.
|
||||
* Keyboard: ↓/↑ move the selection (the first result is selected while typing), Enter
|
||||
* opens it, Ctrl/⌘+Enter opens it in a new tab, Escape closes (and then clears).
|
||||
* "Search everything" (or Enter before results load) opens the search page.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A `<div>` containing the search combobox and, when open, a listbox dropdown.
|
||||
* @returns A `<div>` containing the search combobox and, when open, its results.
|
||||
*/
|
||||
export function GlobalSearch({ navTargets, searchTables = NO_SEARCH_TABLES, className }: GlobalSearchProps) {
|
||||
const router = useRouter()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
// mounted once opened, so the panel keeps its state (recents, cache) between openings
|
||||
const [panelMounted, setPanelMounted] = useState(false)
|
||||
const [term, setTerm] = useState('')
|
||||
const controllerRef = useRef<QuickSearchPanelController | null>(null)
|
||||
const [activeDescendant, setActiveDescendant] = useState<string>()
|
||||
const [hasContent, setHasContent] = useState(false)
|
||||
const recordSearchAvailable = searchTables.length > 0
|
||||
|
||||
// Load recent records when the dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
}
|
||||
if (isOpen) setPanelMounted(true)
|
||||
}, [isOpen])
|
||||
|
||||
const recordSearch = useRecordSearch(isOpen ? searchTerm : '', searchTables, DROPDOWN_RECORDS_PER_TABLE)
|
||||
const items = useMemo(
|
||||
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm, 8, recordSearch.results),
|
||||
[navTargets, recentRecords, searchTerm, recordSearch.results]
|
||||
)
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
router.push(path)
|
||||
}, [router])
|
||||
|
||||
// Close dropdown on outside click
|
||||
const handleController = useCallback((controller: QuickSearchPanelController | null) => {
|
||||
controllerRef.current = controller
|
||||
setActiveDescendant(controller?.activeDescendant)
|
||||
setHasContent(controller?.hasContent ?? false)
|
||||
}, [])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Closes the dropdown when the user clicks outside the search container.
|
||||
@@ -97,130 +115,105 @@ export function GlobalSearch({ navTargets, searchTables = NO_SEARCH_TABLES, clas
|
||||
*/
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!(event.target instanceof Node)) return
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) setIsOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Reset selection when the result list changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
}, [items])
|
||||
|
||||
/**
|
||||
* Navigates to the given path, closes the dropdown, and clears the search term.
|
||||
* Keyboard handling: opens the dropdown, closes it on Escape (a second Escape clears the
|
||||
* text), and otherwise defers to the results panel (↑↓, Enter, Ctrl/⌘+Enter).
|
||||
*
|
||||
* @param path - The URL path to navigate to.
|
||||
* @param event - The input's keydown event.
|
||||
*/
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
setIsOpen(false)
|
||||
setSearchTerm('')
|
||||
router.push(path)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (isOpen) setIsOpen(false)
|
||||
else if (term) setTerm('')
|
||||
else inputRef.current?.blur()
|
||||
return
|
||||
}
|
||||
if (!isOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
|
||||
event.preventDefault()
|
||||
setIsOpen(true)
|
||||
return
|
||||
}
|
||||
if (controllerRef.current && isOpen) {
|
||||
controllerRef.current.onInputKeyDown(event)
|
||||
} else if (event.key === 'Enter' && term.trim()) {
|
||||
// the panel is closed or still loading: Enter searches everything (never a hidden option)
|
||||
event.preventDefault()
|
||||
setTerm('')
|
||||
handleNavigate(searchPagePath({ q: term }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation within the search dropdown.
|
||||
*
|
||||
* @param e - The synthetic keyboard event from the text input.
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
handleNavigate(items[selectedIndex].path)
|
||||
} else if (searchTerm.trim()) {
|
||||
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
inputRef.current?.blur()
|
||||
break
|
||||
}
|
||||
},
|
||||
[isOpen, selectedIndex, items, searchTerm, handleNavigate]
|
||||
)
|
||||
|
||||
const hasTerm = searchTerm.trim().length > 0
|
||||
const showDropdown = isOpen && (hasTerm || items.length > 0)
|
||||
const showDropdown = isOpen && (term.trim().length > 0 || hasContent)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative', className)} data-qqq-id="header-search">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-input bg-background px-4 py-2 text-sm transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1">
|
||||
<Search className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
value={term}
|
||||
onChange={(event) => {
|
||||
setTerm(event.target.value)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={recordSearch.available ? 'Search...' : 'Jump to...'}
|
||||
className="w-36 bg-transparent text-foreground placeholder:text-muted-foreground outline-none"
|
||||
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
placeholder={recordSearchAvailable ? 'Search…' : 'Jump to…'}
|
||||
className="w-36 bg-transparent text-foreground outline-none placeholder:text-muted-foreground lg:w-48 2xl:w-64"
|
||||
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
aria-expanded={showDropdown}
|
||||
aria-controls="header-search-results"
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-activedescendant={showDropdown ? activeDescendant : undefined}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-qqq-id="input-header-search"
|
||||
/>
|
||||
{term && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTerm('')
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Clear search"
|
||||
data-qqq-id="button-header-search-clear"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-2 w-[22rem] max-w-[80vw] overflow-hidden rounded-xl border border-border bg-card shadow-lg z-50">
|
||||
<NavigationSearchResults
|
||||
id="header-search-results"
|
||||
items={items}
|
||||
term={searchTerm}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleNavigate}
|
||||
onHover={setSelectedIndex}
|
||||
recordSearch={recordSearch}
|
||||
{panelMounted && (
|
||||
<div
|
||||
hidden={!showDropdown}
|
||||
className="absolute right-0 top-full z-50 mt-2 w-[min(36rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-border bg-card shadow-lg"
|
||||
data-qqq-id="header-search-dropdown"
|
||||
>
|
||||
<QuickSearchPanel
|
||||
listboxId={LISTBOX_ID}
|
||||
open={isOpen}
|
||||
term={term}
|
||||
setTerm={setTerm}
|
||||
navTargets={navTargets}
|
||||
searchTables={searchTables}
|
||||
onNavigate={handleNavigate}
|
||||
onController={handleController}
|
||||
listClassName="max-h-[min(70vh,32rem)]"
|
||||
footer={FOOTER}
|
||||
hideWhenEmpty
|
||||
/>
|
||||
{hasTerm && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
data-qqq-id="button-header-search-all"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
Show all matches
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,151 +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 NavigationSearchResults — listbox of "jump to" results shared by the header search and the search dialog.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
import type { RecordSearchState } from '@/lib/hooks/use-record-search'
|
||||
import type { NavigationSearchItem } from '@/lib/utils/navigation-search'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon, type MetadataIconKind } from './MetadataIcon'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Props for {@link NavigationSearchResults}. */
|
||||
export interface NavigationSearchResultsProps {
|
||||
/** DOM id of the listbox (referenced by the combobox's `aria-controls`). */
|
||||
id: string
|
||||
/** Ordered results: pages first, then recent records. */
|
||||
items: NavigationSearchItem[]
|
||||
/** Current search text (for the empty message). */
|
||||
term: string
|
||||
/** Index of the highlighted item, or -1. */
|
||||
selectedIndex: number
|
||||
/** Called with a result path when an item is chosen. */
|
||||
onSelect: (path: string) => void
|
||||
/** Called with an index when the pointer moves over an item. */
|
||||
onHover: (index: number) => void
|
||||
/** Record search status; omit when the backend has no record search. */
|
||||
recordSearch?: Pick<RecordSearchState, 'available' | 'isSearching' | 'isError'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the matched part of a label.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.text - Label text.
|
||||
* @param props.term - Search text.
|
||||
* @returns The label with the first match wrapped in `<mark>`.
|
||||
*/
|
||||
function Highlighted({ text, term }: { text: string; term: string }) {
|
||||
const needle = term.trim().toLowerCase()
|
||||
const at = needle ? text.toLowerCase().indexOf(needle) : -1
|
||||
if (at < 0) return <>{text}</>
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, at)}
|
||||
<mark className="rounded-sm bg-primary/20 px-0.5 text-foreground">{text.slice(at, at + needle.length)}</mark>
|
||||
{text.slice(at + needle.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders grouped results ("Pages", "Records" found by record search, "Recently
|
||||
* viewed") as a listbox of options, with a polite status line for record search.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A `role="listbox"` element, with an empty message when nothing matches.
|
||||
*/
|
||||
export function NavigationSearchResults({ id, items, term, selectedIndex, onSelect, onHover, recordSearch }: NavigationSearchResultsProps) {
|
||||
const groups: Array<{ kind: NavigationSearchItem['kind']; heading: string }> = [
|
||||
{ kind: 'page', heading: 'Pages' },
|
||||
{ kind: 'result', heading: 'Records' },
|
||||
{ kind: 'record', heading: 'Recently viewed' },
|
||||
]
|
||||
const searching = recordSearch?.available === true && recordSearch.isSearching
|
||||
const status = searching
|
||||
? 'Searching records…'
|
||||
: recordSearch?.available && recordSearch.isError ? 'Record search failed. Try again.' : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Record search progress and errors, announced politely (outside the listbox: not an option) */}
|
||||
<p role="status" aria-live="polite" className={cn('px-3 text-xs text-muted-foreground', status ? 'pt-2' : 'sr-only')} data-qqq-id="search-results-status">
|
||||
{status}
|
||||
</p>
|
||||
<div id={id} role="listbox" aria-label="Search results" aria-busy={searching} className="max-h-[400px] overflow-y-auto py-1" data-qqq-id="search-results">
|
||||
{items.length === 0 && !searching && (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground" data-qqq-id="search-results-empty">
|
||||
{recordSearch?.available ? 'No pages or records match' : 'No pages or recent records match'} “{term.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{groups.map(({ kind, heading }) => {
|
||||
const group = items.map((item, index) => ({ item, index })).filter(({ item }) => item.kind === kind)
|
||||
if (group.length === 0) return null
|
||||
return (
|
||||
<div key={kind} role="group" aria-label={heading}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground" aria-hidden="true">
|
||||
{kind === 'record' && <Clock className="h-3 w-3" aria-hidden="true" />}
|
||||
{heading}
|
||||
</div>
|
||||
{group.map(({ item, index }) => (
|
||||
<button
|
||||
key={`${item.kind}-${item.path}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => onSelect(item.path)}
|
||||
// mousemove, not mouseenter: results rendering under a resting pointer must not steal the keyboard selection
|
||||
onMouseMove={() => { if (selectedIndex !== index) onHover(index) }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors',
|
||||
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
data-qqq-id={`search-result-${item.kind}-${item.path.replace(/\//g, '-')}`}
|
||||
>
|
||||
{item.kind === 'page' ? (
|
||||
<MetadataIcon icon={item.icon} kind={item.nodeType ? ICON_KIND[item.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
) : item.kind === 'record' ? (
|
||||
// the record's table icon, as in Material's recently viewed menu
|
||||
<MetadataIcon icon={item.icon} kind="table" className="text-muted-foreground" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[0.6rem] font-medium text-primary" aria-hidden="true">
|
||||
{item.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
<Highlighted text={item.label} term={term} />
|
||||
</span>
|
||||
{item.context && <span className="block truncate text-xs text-muted-foreground">{item.context}</span>}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 CommandRecordResults — record search inside the command palette: status (searching,
|
||||
* failure class, provider fallback) and one cmdk group per table with its count, records
|
||||
* with highlighted snippets, and a "see all" entry. Loaded on demand by the palette, only
|
||||
* when the metadata lists searchable tables.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { AlertTriangle, ArrowRight, Loader2 } from 'lucide-react'
|
||||
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { recordSearchResultPath, type SearchableTable } from '@/lib/utils/record-search'
|
||||
import { groupResultsByTable, tableCountLabel, tableHasMore } from '@/lib/search/search-model'
|
||||
import { searchPagePath } from '@/lib/search/search-url'
|
||||
import { SearchOptionContent } from './SearchOptionContent'
|
||||
|
||||
/** Records listed per table in the palette. */
|
||||
export const PALETTE_RECORDS_PER_TABLE = 5
|
||||
|
||||
/** Props for {@link CommandRecordResults}. */
|
||||
export interface CommandRecordResultsProps {
|
||||
/** Palette search text. */
|
||||
search: string
|
||||
/** Whether the palette is open. */
|
||||
open: boolean
|
||||
/** Searchable tables (non-empty). */
|
||||
tables: SearchableTable[]
|
||||
/** cmdk's highlighted value. */
|
||||
selectedValue: string
|
||||
/** Sets cmdk's highlighted value. */
|
||||
setSelectedValue: (value: string) => void
|
||||
/** Value of the first entry listed before the records (actions, pages), if any. */
|
||||
firstValueBefore?: string
|
||||
/** Value of the first entry listed after the records (recently viewed), if any. */
|
||||
firstValueAfter?: string
|
||||
/** Opens a path. */
|
||||
onSelect: (path: string) => void
|
||||
/** Reports how many records are listed and whether a search is pending. */
|
||||
onState: (state: { count: number; searching: boolean }) => void
|
||||
/** Class of a palette entry. */
|
||||
itemClassName: string
|
||||
/** Renders a group heading. */
|
||||
heading: (text: string) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders record search status and results as cmdk groups.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns Status and record groups.
|
||||
*/
|
||||
export function CommandRecordResults(props: CommandRecordResultsProps) {
|
||||
const { search, open, tables, selectedValue, setSelectedValue, firstValueBefore, firstValueAfter, onSelect, onState, itemClassName, heading } = props
|
||||
const trimmed = search.trim()
|
||||
const recordSearch = useRecordSearch(open ? search : '', tables, PALETTE_RECORDS_PER_TABLE)
|
||||
const groups = useMemo(
|
||||
() => groupResultsByTable(recordSearch.results, recordSearch.summaries, tables, trimmed),
|
||||
[recordSearch.results, recordSearch.summaries, tables, trimmed]
|
||||
)
|
||||
const count = groups.reduce((sum, group) => sum + group.results.length, 0)
|
||||
|
||||
useEffect(() => onState({ count, searching: recordSearch.isSearching }), [count, recordSearch.isSearching, onState])
|
||||
|
||||
// Records arrive after cmdk highlighted the first entry on typing; when that was the
|
||||
// search-everything entry (nothing else matched yet), highlight the new first entry instead.
|
||||
useEffect(() => {
|
||||
const first = firstValueBefore
|
||||
?? (groups[0] ? `record:${recordSearchResultPath(groups[0].results[0])}` : undefined)
|
||||
?? firstValueAfter
|
||||
if (first && selectedValue === `search:${searchPagePath({ q: trimmed })}`) setSelectedValue(first)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only new record results move the highlight
|
||||
}, [groups])
|
||||
|
||||
const notice = recordSearch.failure ?? recordSearch.fallback
|
||||
return (
|
||||
<>
|
||||
{recordSearch.isSearching && !recordSearch.failure && (
|
||||
<p className="flex items-center gap-2 px-4 py-1 text-xs text-muted-foreground" data-qqq-id="command-menu-searching">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
Searching records…
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="mx-3 my-1 flex items-start gap-2 rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground" data-qqq-id="command-menu-search-status">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="font-medium text-foreground">{notice.title}</span>
|
||||
{notice.detail && <> — {notice.detail}</>}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<Command.Group
|
||||
key={group.tableName}
|
||||
heading={heading(`${group.tableLabel} · ${tableCountLabel(group.results.length, group.summary)}`)}
|
||||
data-qqq-id={`command-group-records-${group.tableName}`}
|
||||
>
|
||||
{group.results.map((result) => {
|
||||
const path = recordSearchResultPath(result)
|
||||
return (
|
||||
<Command.Item
|
||||
key={path}
|
||||
value={`record:${path}`}
|
||||
onSelect={() => onSelect(path)}
|
||||
className={itemClassName}
|
||||
data-qqq-id={`command-record-${result.tableName}-${result.recordId}`}
|
||||
>
|
||||
<SearchOptionContent
|
||||
option={{ id: path, kind: 'record', path, label: result.recordLabel, tableLabel: group.tableLabel, icon: group.icon, result }}
|
||||
term={trimmed}
|
||||
/>
|
||||
</Command.Item>
|
||||
)
|
||||
})}
|
||||
{tableHasMore(group.results.length, group.summary, PALETTE_RECORDS_PER_TABLE) && (
|
||||
<Command.Item
|
||||
value={`search:${searchPagePath({ q: trimmed, tables: [group.tableName] })}`}
|
||||
onSelect={() => onSelect(searchPagePath({ q: trimmed, tables: [group.tableName] }))}
|
||||
className={itemClassName}
|
||||
data-qqq-id={`command-more-${group.tableName}`}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-muted-foreground">See all matches in {group.tableLabel}</span>
|
||||
</Command.Item>
|
||||
)}
|
||||
</Command.Group>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 HighlightedText — text with its matches wrapped in `<mark>`. Matches come from the
|
||||
* backend (ranges) or are found locally from the search term. Rendered as text nodes only.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { RecordSearchMatch } from '@/lib/api/record-search'
|
||||
import { findMatches, segmentText } from '@/lib/search/highlight'
|
||||
|
||||
/** Props for {@link HighlightedText}. */
|
||||
export interface HighlightedTextProps {
|
||||
/** Text to show. */
|
||||
text: string
|
||||
/** Match ranges from the backend; when omitted, the term's words are found locally. */
|
||||
matches?: RecordSearchMatch[]
|
||||
/** Search text (used when `matches` is omitted). */
|
||||
term?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text with matched runs marked.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns Text and `<mark>` elements.
|
||||
*/
|
||||
export function HighlightedText({ text, matches, term = '' }: HighlightedTextProps) {
|
||||
const segments = segmentText(text, matches ?? findMatches(text, term))
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (segment.match
|
||||
? <mark key={index} className="rounded-sm bg-primary/20 px-px font-semibold text-foreground">{segment.text}</mark>
|
||||
: <React.Fragment key={index}>{segment.text}</React.Fragment>))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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 QuickSearchListbox — the results of a search-as-you-type combobox: record search
|
||||
* status (searching, the failure class with a retry, or a provider fallback notice), then
|
||||
* grouped options (Top hits, Pages, one group per table with its count, Recently viewed,
|
||||
* "search everything"), as an ARIA listbox driven by `aria-activedescendant`.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle, Clock, History, Loader2, RotateCw } from 'lucide-react'
|
||||
|
||||
import type { QuickSearchController } from '@/lib/hooks/use-quick-search'
|
||||
import type { QuickSearchOption } from '@/lib/search/search-model'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { SearchOptionContent } from './SearchOptionContent'
|
||||
|
||||
/**
|
||||
* DOM id of an option (referenced by the combobox's `aria-activedescendant`).
|
||||
*
|
||||
* @param listboxId - The listbox id.
|
||||
* @param optionId - The option id.
|
||||
* @returns A DOM-safe id.
|
||||
*/
|
||||
export function quickSearchOptionDomId(listboxId: string, optionId: string): string {
|
||||
return `${listboxId}-${optionId.replace(/[^A-Za-z0-9_-]/g, (char) => `_${char.charCodeAt(0).toString(16)}`)}`
|
||||
}
|
||||
|
||||
/** Props for {@link QuickSearchListbox}. */
|
||||
export interface QuickSearchListboxProps {
|
||||
/** DOM id of the listbox (the combobox's `aria-controls`). */
|
||||
id: string
|
||||
/** The search controller. */
|
||||
search: QuickSearchController
|
||||
/** Classes for the scrolling list (e.g. its max height). */
|
||||
listClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link click should be left to the browser (new tab or window, download).
|
||||
*
|
||||
* @param event - The click.
|
||||
* @returns `true` for modified or non-primary clicks.
|
||||
*/
|
||||
function isBrowserClick(event: React.MouseEvent): boolean {
|
||||
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the status line, the grouped options and the empty state.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The status region and the listbox.
|
||||
*/
|
||||
export function QuickSearchListbox({ id, search, listClassName }: QuickSearchListboxProps) {
|
||||
const { term, groups, activeId, setActiveId, choose, recordSearch, clearRecent } = search
|
||||
const trimmed = term.trim()
|
||||
const activeDomId = activeId ? quickSearchOptionDomId(id, activeId) : undefined
|
||||
|
||||
// keep the active option visible while moving with the keyboard
|
||||
useEffect(() => {
|
||||
if (activeDomId) document.getElementById(activeDomId)?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [activeDomId])
|
||||
|
||||
const searching = recordSearch.isSearching
|
||||
const { failure, fallback } = recordSearch
|
||||
const matches = groups.some((group) => group.kind !== 'actions' && group.options.length > 0)
|
||||
const recentSearchGroup = !trimmed && groups.some((group) => group.kind === 'recent-searches')
|
||||
|
||||
/**
|
||||
* Renders one option.
|
||||
*
|
||||
* @param option - The option.
|
||||
* @param showTable - Show the record's table (mixed-table groups).
|
||||
* @returns The option element.
|
||||
*/
|
||||
const renderOption = (option: QuickSearchOption, showTable: boolean) => {
|
||||
const active = option.id === activeId
|
||||
const className = cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left outline-none transition-colors pointer-coarse:min-h-11',
|
||||
active ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)
|
||||
const common = {
|
||||
id: quickSearchOptionDomId(id, option.id),
|
||||
role: 'option' as const,
|
||||
'aria-selected': active,
|
||||
// mousemove, not mouseenter: results rendering under a resting pointer must not steal the keyboard selection
|
||||
onMouseMove: () => { if (!active) setActiveId(option.id) },
|
||||
className,
|
||||
'data-qqq-id': `search-option-${option.kind}`,
|
||||
'data-option-id': option.id,
|
||||
}
|
||||
if (option.kind === 'query') {
|
||||
return (
|
||||
<div key={option.id} {...common} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>
|
||||
<SearchOptionContent option={option} term={trimmed} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={option.id}
|
||||
{...common}
|
||||
href={option.path}
|
||||
prefetch={false}
|
||||
tabIndex={-1}
|
||||
// keep focus in the input
|
||||
onMouseDown={(event) => { if (event.button === 0) event.preventDefault() }}
|
||||
onClick={(event) => {
|
||||
if (isBrowserClick(event)) return
|
||||
event.preventDefault()
|
||||
choose(option)
|
||||
}}
|
||||
>
|
||||
<SearchOptionContent option={option} term={trimmed} showTable={showTable} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Record search progress, failure and fallback, announced politely (outside the listbox: not options) */}
|
||||
<div role="status" aria-live="polite" data-qqq-id="search-results-status">
|
||||
{searching && !failure && (
|
||||
<p className="flex items-center gap-2 px-3 pt-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
Searching records…
|
||||
</p>
|
||||
)}
|
||||
{failure && (
|
||||
<div className="mx-2 mt-2 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" data-qqq-id="search-failure" data-failure-kind={failure.kind}>
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{failure.title}</p>
|
||||
{failure.detail && <p className="break-words text-muted-foreground">{failure.detail}</p>}
|
||||
<p className="text-muted-foreground">Showing pages and recently viewed records only.</p>
|
||||
</div>
|
||||
{failure.retryable && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={recordSearch.retry}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1 font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-search-retry"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fallback && !failure && (
|
||||
<div className="mx-2 mt-2 flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs" data-qqq-id="search-fallback">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{fallback.title}</p>
|
||||
{fallback.detail && <p className="break-words text-muted-foreground">{fallback.detail}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={id}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
aria-busy={searching}
|
||||
className={cn('overflow-y-auto overscroll-contain py-1', recordSearch.isStale && 'opacity-80', listClassName)}
|
||||
data-qqq-id="search-results"
|
||||
>
|
||||
{trimmed && !matches && !searching && (
|
||||
<p className="px-3 py-5 text-center text-sm text-muted-foreground" data-qqq-id="search-results-empty">
|
||||
{recordSearch.available && !failure ? 'No pages or records match' : 'No pages or recent records match'} “{trimmed}”
|
||||
</p>
|
||||
)}
|
||||
{!trimmed && groups.length === 0 && (
|
||||
<p className="px-3 py-5 text-center text-sm text-muted-foreground" data-qqq-id="search-results-hint">
|
||||
{recordSearch.available ? 'Type to find an app, table, process or record.' : 'Type to find an app, table, process or recent record.'}
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<div key={group.id} role="group" aria-labelledby={`${id}-group-${group.id.replace(/[^A-Za-z0-9_-]/g, '_')}`} data-qqq-id={`search-group-${group.kind}`}>
|
||||
<div
|
||||
id={`${id}-group-${group.id.replace(/[^A-Za-z0-9_-]/g, '_')}`}
|
||||
className="flex items-center gap-2 px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{group.kind === 'recent' && <Clock className="h-3 w-3" aria-hidden="true" />}
|
||||
{group.kind === 'recent-searches' && <History className="h-3 w-3" aria-hidden="true" />}
|
||||
{group.kind === 'table' && <MetadataIcon icon={group.icon} kind="table" className="h-3 w-3" />}
|
||||
<span className="truncate">{group.heading}</span>
|
||||
{group.count && (
|
||||
<>
|
||||
<span className="ml-auto rounded-full bg-muted px-1.5 py-px text-[0.65rem] font-medium normal-case tracking-normal tabular-nums" aria-hidden="true">
|
||||
{group.count}
|
||||
</span>
|
||||
<span className="sr-only">{` ${group.count} matches`}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{group.options.map((option) => renderOption(option, group.kind === 'top'))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{recentSearchGroup && (
|
||||
<div className="flex justify-end border-t border-border px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={clearRecent}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-clear-recent-searches"
|
||||
>
|
||||
Clear recent searches
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 QuickSearchPanel — the results half of a search-as-you-type combobox (record
|
||||
* search, ranking, grouping, listbox). Loaded on demand by the header search and the `/`
|
||||
* dialog, which own only the input: the panel hands its keyboard handler and active option
|
||||
* back through `onController`, so every page's first load stays small.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { useQuickSearch } from '@/lib/hooks/use-quick-search'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { QuickSearchListbox, quickSearchOptionDomId } from './QuickSearchListbox'
|
||||
|
||||
/** What the input needs from a mounted panel. */
|
||||
export interface QuickSearchPanelController {
|
||||
/** Keyboard handler for the combobox input (↑↓, Enter, Ctrl/⌘+Enter). */
|
||||
onInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
/** DOM id of the active option (`aria-activedescendant`), if any. */
|
||||
activeDescendant?: string
|
||||
/** Whether the panel has anything to show (results, recents or a hint). */
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
/** Props for {@link QuickSearchPanel}. */
|
||||
export interface QuickSearchPanelProps {
|
||||
/** DOM id of the listbox (the combobox's `aria-controls`). */
|
||||
listboxId: string
|
||||
/** Whether the search is open. */
|
||||
open: boolean
|
||||
/** Search text (owned by the input). */
|
||||
term: string
|
||||
/** Sets the search text. */
|
||||
setTerm: (term: string) => void
|
||||
/** Navigable pages. */
|
||||
navTargets: NavTarget[]
|
||||
/** Searchable tables (empty: record search unavailable, never called). */
|
||||
searchTables: SearchableTable[]
|
||||
/** Navigates in this tab (the caller closes the search). */
|
||||
onNavigate: (path: string) => void
|
||||
/** Receives the controller whenever it changes (`null` on unmount). */
|
||||
onController: (controller: QuickSearchPanelController | null) => void
|
||||
/** Classes for the scrolling list. */
|
||||
listClassName?: string
|
||||
/** Rendered below the results (e.g. keyboard hints) when there is content. */
|
||||
footer?: React.ReactNode
|
||||
/** Render nothing (not even the typing hint) while there is nothing to show (a dropdown). */
|
||||
hideWhenEmpty?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the quick search results and wires them to the caller's input.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The results (or the typing hint), or nothing when empty and `hideWhenEmpty`.
|
||||
*/
|
||||
export function QuickSearchPanel({ listboxId, open, term, setTerm, navTargets, searchTables, onNavigate, onController, listClassName, footer, hideWhenEmpty = false }: QuickSearchPanelProps) {
|
||||
const search = useQuickSearch({ open, term, setTerm, navTargets, searchTables, onNavigate })
|
||||
const hasContent = term.trim().length > 0 || search.groups.length > 0
|
||||
const activeDescendant = search.activeId ? quickSearchOptionDomId(listboxId, search.activeId) : undefined
|
||||
const { onInputKeyDown } = search
|
||||
|
||||
useEffect(() => {
|
||||
onController({ onInputKeyDown, activeDescendant, hasContent })
|
||||
}, [onController, onInputKeyDown, activeDescendant, hasContent])
|
||||
useEffect(() => () => onController(null), [onController])
|
||||
|
||||
if (!hasContent && hideWhenEmpty) return null
|
||||
return (
|
||||
<>
|
||||
<QuickSearchListbox id={listboxId} search={search} listClassName={listClassName} />
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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, it, expect } from 'vitest'
|
||||
|
||||
import type { QFieldMetaData, QTableMetaData } from '@/types'
|
||||
import { previewFields } from './RecordPreview'
|
||||
|
||||
/** A field for the fixture. */
|
||||
const field = (name: string, extra: Partial<QFieldMetaData> = {}) => ({ name, label: name, type: 'STRING', adornments: [], ...extra }) as unknown as QFieldMetaData
|
||||
|
||||
describe('previewFields', () => {
|
||||
it('shows the first-tier fields but never the key, heavy fields, secrets, widgets, code, HTML or binary data', () => {
|
||||
const fields = {
|
||||
id: field('id', { type: 'INTEGER' }),
|
||||
name: field('name'),
|
||||
apiKey: field('apiKey', { adornments: [{ type: 'REVEAL' }] } as Partial<QFieldMetaData>),
|
||||
chart: field('chart', { adornments: [{ type: 'WIDGET' }] } as Partial<QFieldMetaData>),
|
||||
script: field('script', { adornments: [{ type: 'CODE_EDITOR' }] } as Partial<QFieldMetaData>),
|
||||
body: field('body', { type: 'HTML' } as Partial<QFieldMetaData>),
|
||||
blob: field('blob', { type: 'BLOB' } as Partial<QFieldMetaData>),
|
||||
notes: field('notes', { isHeavy: true }),
|
||||
status: field('status'),
|
||||
other: field('other'),
|
||||
}
|
||||
const table = {
|
||||
name: 'thing',
|
||||
primaryKeyField: 'id',
|
||||
fields,
|
||||
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'name', 'apiKey', 'chart', 'script', 'body', 'blob', 'notes', 'status'] }, { name: 'more', tier: 'T2', fieldNames: ['other'] }],
|
||||
} as unknown as QTableMetaData
|
||||
expect(previewFields(table).map((f) => f.name)).toEqual(['name', 'status'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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 RecordPreview — a search result's record without leaving the results: its key
|
||||
* fields (the table's first-tier sections, as on the record view) as text, Yes/No and links,
|
||||
* the fields that matched the search, and quick actions (open, open in a new tab, copy link,
|
||||
* edit when permitted). Loaded on demand by the search page. File thumbnails and chips are
|
||||
* left to the record view: their modules measured +9.3 KB total JS here.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink, Link2, Loader2, Pencil, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QFieldMetaData, QIcon, QRecord, QTableMetaData } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import { loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { findAdornment } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { isEmail, isHttpUrl } from '@/lib/utils/string-utils'
|
||||
import { recordSearchResultPath } from '@/lib/utils/record-search'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Most fields shown in a preview. */
|
||||
const MAX_PREVIEW_FIELDS = 10
|
||||
|
||||
/**
|
||||
* The fields a preview shows: the visible fields of the table's first-tier sections (as the
|
||||
* record view's T1 cards), else all visible fields; never heavy fields, the primary key, secrets,
|
||||
* widgets, code, HTML or binary data.
|
||||
*
|
||||
* @param table - Full table metadata.
|
||||
* @returns Up to {@link MAX_PREVIEW_FIELDS} fields.
|
||||
*/
|
||||
export function previewFields(table: QTableMetaData): QFieldMetaData[] {
|
||||
const fields = table.fields ?? {}
|
||||
const t1 = (table.sections ?? []).filter((section) => !section.isHidden && (!section.tier || section.tier === 'T1' || section.tier === 'basic'))
|
||||
const names = t1.length > 0 ? t1.flatMap((section) => section.fieldNames ?? []) : Object.keys(fields)
|
||||
return [...new Set(names)]
|
||||
.map((name) => fields[name])
|
||||
.filter((field): field is QFieldMetaData => Boolean(field) && !field.isHidden && !field.isHeavy && field.name !== table.primaryKeyField && previewable(field))
|
||||
.slice(0, MAX_PREVIEW_FIELDS)
|
||||
}
|
||||
|
||||
/** Adornments whose fields a glance does not show: secrets, embedded widgets, code and markup. */
|
||||
const NOT_PREVIEWED = ['REVEAL', 'WIDGET', 'CODE_EDITOR', 'RENDER_HTML'] as const
|
||||
|
||||
/**
|
||||
* Whether a preview shows a field: never secrets (REVEAL), widgets, code, HTML or binary data.
|
||||
*
|
||||
* @param field - The field.
|
||||
* @returns True when the preview shows it.
|
||||
*/
|
||||
function previewable(field: QFieldMetaData): boolean {
|
||||
if (field.type === 'BLOB' || field.type === 'HTML') return false
|
||||
return !NOT_PREVIEWED.some((type) => findAdornment(field, type))
|
||||
}
|
||||
|
||||
/** Link classes (taller touch targets on coarse pointers). */
|
||||
const LINK_CLASS = 'text-primary hover:text-primary/80 hover:underline pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center'
|
||||
|
||||
/**
|
||||
* A field's value at a glance: Yes/No, email and web links, else the display value (which
|
||||
* QQQ formats for dates, possible values and files). Lighter than the record view's
|
||||
* FieldValue (no widgets), so the preview loads quickly.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.field - The field.
|
||||
* @param props.record - The record.
|
||||
* @param props.table - Its table.
|
||||
* @returns The value.
|
||||
*/
|
||||
function PreviewValue({ field, record, table }: { field: QFieldMetaData; record: QRecord; table: QTableMetaData }) {
|
||||
const rawValue = record.values[field.name]
|
||||
const displayValue = record.displayValues?.[field.name]
|
||||
const value = displayValue ?? rawValue
|
||||
const dataQqqId = `search-preview-value-${field.name}`
|
||||
|
||||
if (value === null || value === undefined || value === '') return <span className="text-muted-foreground" data-qqq-id={dataQqqId}>—</span>
|
||||
|
||||
if (field.type === 'BOOLEAN') {
|
||||
const yes = rawValue === true || rawValue === 'true' || rawValue === 1
|
||||
return <span data-qqq-id={dataQqqId}>{yes ? 'Yes' : 'No'}</span>
|
||||
}
|
||||
const text = String(value)
|
||||
if (field.type === 'STRING' && isHttpUrl(text)) {
|
||||
return <a href={text} target="_blank" rel="noopener noreferrer" className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}<span className="sr-only"> (opens in a new tab)</span></a>
|
||||
}
|
||||
if (field.type === 'STRING' && isEmail(text)) {
|
||||
return <a href={`mailto:${text}`} className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}</a>
|
||||
}
|
||||
return <span className={cn('whitespace-pre-wrap', field.type === 'TEXT' && 'line-clamp-3')} data-qqq-id={dataQqqId}>{text}</span>
|
||||
}
|
||||
|
||||
/** Props for {@link RecordPreview}. */
|
||||
export interface RecordPreviewProps {
|
||||
/** The search result to preview. */
|
||||
result: RecordSearchResult
|
||||
/** Table label. */
|
||||
tableLabel: string
|
||||
/** Table icon. */
|
||||
icon?: QIcon
|
||||
/** Search text (highlights the label and matched fields). */
|
||||
term: string
|
||||
/** Heading level of the record label (2 in the split view, 2 in the sheet). */
|
||||
headingId?: string
|
||||
}
|
||||
|
||||
/** Classes of a quick action. */
|
||||
const ACTION_CLASS = 'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11'
|
||||
|
||||
/**
|
||||
* Renders a record preview: header, quick actions, matched fields and key fields.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The preview.
|
||||
*/
|
||||
export function RecordPreview({ result, tableLabel, icon, term, headingId }: RecordPreviewProps) {
|
||||
const path = recordSearchResultPath(result)
|
||||
// The queries of useTableMetaData and useRecord (same keys, so the caches are shared),
|
||||
// called directly: importing those hooks here re-bundles them into more screen chunks
|
||||
// (measured +12 KB total JS with Turbopack)
|
||||
const metadata = useQuery<QTableMetaData>({
|
||||
queryKey: queryKeys.tableMetadata(result.tableName),
|
||||
queryFn: () => loadTableMetaData(result.tableName),
|
||||
staleTime: 30 * 60_000, // as useTableMetaData
|
||||
})
|
||||
const recordQuery = useQuery({
|
||||
queryKey: [...queryKeys.tableRecord(result.tableName, result.recordId), { includeAssociations: false }],
|
||||
queryFn: () => getRecord(result.tableName, result.recordId, { includeAssociations: false }),
|
||||
enabled: Boolean(metadata.data) && !metadata.isError,
|
||||
staleTime: 60_000,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const record = recordQuery.data
|
||||
const { isLoading, isError } = recordQuery
|
||||
const table = metadata.data
|
||||
const fields = table ? previewFields(table) : []
|
||||
// as canEditRecords: the edit permission and the TABLE_UPDATE capability
|
||||
const canEdit = Boolean(table?.editPermission) && Boolean(table?.capabilities?.includes('TABLE_UPDATE'))
|
||||
const highlights = (result.highlights ?? []).filter((highlight) => highlight.text.trim())
|
||||
|
||||
/** Copies the record's link. */
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(path, window.location.origin).toString())
|
||||
toast.success('Link copied')
|
||||
} catch {
|
||||
toast.error('Could not copy the link')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="space-y-4" aria-labelledby={headingId} data-qqq-id="search-record-preview">
|
||||
<header className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<MetadataIcon icon={icon} kind="table" className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id={headingId} className="break-words text-lg font-semibold leading-tight text-foreground">
|
||||
<HighlightedText text={record?.recordLabel || result.recordLabel} term={term} />
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{tableLabel} · #{result.recordId}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2" data-qqq-id="search-preview-actions">
|
||||
<Link href={path} prefetch={false} className={`${ACTION_CLASS} border-primary bg-primary text-primary-foreground hover:bg-primary/90`} data-qqq-id="search-preview-open">
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
Open
|
||||
</Link>
|
||||
<a href={path} target="_blank" rel="noopener noreferrer" className={ACTION_CLASS} data-qqq-id="search-preview-new-tab">
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
New tab
|
||||
</a>
|
||||
<button type="button" onClick={() => void copyLink()} className={ACTION_CLASS} data-qqq-id="search-preview-copy-link">
|
||||
<Link2 className="h-4 w-4" aria-hidden="true" />
|
||||
Copy link
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Link href={`${path}/edit`} prefetch={false} className={ACTION_CLASS} data-qqq-id="search-preview-edit">
|
||||
<Pencil className="h-4 w-4" aria-hidden="true" />
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{highlights.length > 0 && (
|
||||
<section aria-label="Why it matched" className="rounded-lg bg-muted/50 px-3 py-2">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Matched</h3>
|
||||
<dl className="space-y-0.5 text-sm">
|
||||
{highlights.map((highlight) => (
|
||||
<div key={highlight.fieldName} className="flex min-w-0 gap-1.5">
|
||||
<dt className="shrink-0 text-muted-foreground">{highlight.fieldLabel ?? highlight.fieldName}:</dt>
|
||||
<dd className="min-w-0 break-words text-foreground">
|
||||
<HighlightedText text={highlight.text} matches={highlight.matches.length > 0 ? highlight.matches : undefined} term={term} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{metadata.isError || isError ? (
|
||||
<p role="alert" className="text-sm text-muted-foreground" data-qqq-id="search-preview-error">
|
||||
The record’s details could not be loaded. Open it to see why.
|
||||
</p>
|
||||
) : !table || isLoading || !record ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading details…
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2" data-qqq-id="search-preview-fields">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="min-w-0">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{field.label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-foreground">
|
||||
<PreviewValue field={field} record={record} table={table} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 SearchOptionContent — the inside of one quick search entry: icon, highlighted label,
|
||||
* a secondary line (matched field snippet, secondary values, or where a page lives) and,
|
||||
* for records, when it was last updated. Shared by the header search, the `/` dialog and
|
||||
* the command palette.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { ArrowRight, History, Search } from 'lucide-react'
|
||||
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import type { QuickSearchOption } from '@/lib/search/search-model'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Type shown for each app-tree node type. */
|
||||
const TYPE_LABEL: Record<string, string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report' }
|
||||
|
||||
/**
|
||||
* The secondary line of a record result: its first matched field as a snippet, else its
|
||||
* secondary values.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.result - The record search result.
|
||||
* @param props.term - Search text.
|
||||
* @returns The line, or `null` when the result has neither.
|
||||
*/
|
||||
export function RecordSnippet({ result, term }: { result: RecordSearchResult; term: string }) {
|
||||
const highlight = result.highlights?.find((candidate) => candidate.text.trim())
|
||||
if (highlight) {
|
||||
return (
|
||||
<>
|
||||
{highlight.fieldLabel && <span className="text-muted-foreground/80">{highlight.fieldLabel}: </span>}
|
||||
<HighlightedText text={highlight.text} matches={highlight.matches.length > 0 ? highlight.matches : undefined} term={term} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
const details = result.details?.filter((detail) => detail.displayValue.trim()).slice(0, 3)
|
||||
if (details && details.length > 0) {
|
||||
return <>{details.map((detail) => detail.displayValue).join(' · ')}</>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Props for {@link SearchOptionContent}. */
|
||||
export interface SearchOptionContentProps {
|
||||
/** The entry. */
|
||||
option: QuickSearchOption
|
||||
/** Search text (for highlighting). */
|
||||
term: string
|
||||
/** Show the table label beside a record (where records of several tables mix, e.g. "Top hits"). */
|
||||
showTable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one quick search entry's content (the caller supplies the interactive element).
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns Icon, label and secondary content.
|
||||
*/
|
||||
export function SearchOptionContent({ option, term, showTable = false }: SearchOptionContentProps) {
|
||||
if (option.kind === 'query') {
|
||||
const Icon = option.completion ? Search : History
|
||||
return (
|
||||
<>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{option.completion && option.term.toLowerCase().startsWith(term.trim().toLowerCase())
|
||||
? <>{option.term.slice(0, term.trim().length)}<span className="font-semibold">{option.term.slice(term.trim().length)}</span></>
|
||||
: option.term}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (option.kind === 'more') {
|
||||
return (
|
||||
<>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
See all {option.count ? `${option.count} ` : ''}matches in {option.tableLabel}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (option.kind === 'all') {
|
||||
return (
|
||||
<>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
Search everything for <span className="font-medium">“{option.term}”</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let icon: React.ReactNode
|
||||
let secondary: React.ReactNode = null
|
||||
let aside: React.ReactNode = null
|
||||
if (option.kind === 'page') {
|
||||
icon = <MetadataIcon icon={option.icon} kind={option.nodeType ? ICON_KIND[option.nodeType] : 'app'} className="text-muted-foreground" />
|
||||
const type = option.nodeType ? TYPE_LABEL[option.nodeType] : undefined
|
||||
secondary = [type, option.context].filter(Boolean).join(' · ') || null
|
||||
} else if (option.kind === 'recent') {
|
||||
icon = <MetadataIcon icon={option.icon} kind="table" className="text-muted-foreground" />
|
||||
secondary = option.tableLabel
|
||||
} else {
|
||||
icon = <MetadataIcon icon={option.icon} kind="table" className="text-muted-foreground" />
|
||||
secondary = <RecordSnippet result={option.result} term={term} />
|
||||
aside = (
|
||||
<span className="flex shrink-0 flex-col items-end gap-0.5 text-xs text-muted-foreground">
|
||||
{showTable && <span className="max-w-[9rem] truncate">{option.tableLabel}</span>}
|
||||
{option.result.updatedAt && <DateTimeText value={option.result.updatedAt} type="DATE_TIME" relative className="text-xs text-muted-foreground" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
<HighlightedText text={option.label} term={term} />
|
||||
</span>
|
||||
{secondary && <span className="block truncate text-xs text-muted-foreground">{secondary}</span>}
|
||||
</span>
|
||||
{aside}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Tests for the search page: URL state (q, tables, view, sort), per-table sections and
|
||||
// filter counts, one-table paging, honest failure and fallback states, quick actions
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError, type AxiosResponse } from 'axios'
|
||||
|
||||
const replaceMock = vi.fn()
|
||||
const urlState = vi.hoisted(() => ({ params: new URLSearchParams() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: replaceMock, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/app/search',
|
||||
useSearchParams: () => urlState.params,
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
|
||||
const ACCOUNT_TABLE = {
|
||||
name: 'account', label: 'Accounts', isHidden: false, readPermission: true, editPermission: true, capabilities: ['TABLE_GET', 'TABLE_QUERY', 'TABLE_UPDATE'],
|
||||
primaryKeyField: 'id',
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id', type: 'INTEGER', isHidden: false, isHeavy: false, adornments: [] },
|
||||
name: { name: 'name', label: 'Name', type: 'STRING', isHidden: false, isHeavy: false, adornments: [] },
|
||||
status: { name: 'status', label: 'Status', type: 'STRING', isHidden: false, isHeavy: false, adornments: [] },
|
||||
owner: { name: 'owner', label: 'Owner', type: 'STRING', isHidden: false, isHeavy: false, adornments: [] },
|
||||
notes: { name: 'notes', label: 'Notes', type: 'TEXT', isHidden: false, isHeavy: true, adornments: [] },
|
||||
},
|
||||
sections: [
|
||||
{ name: 'identity', label: 'Identity', tier: 'T1', isHidden: false, fieldNames: ['id', 'name', 'status', 'owner', 'notes'] },
|
||||
{ name: 'more', label: 'More', tier: 'T2', isHidden: false, fieldNames: ['notes'] },
|
||||
],
|
||||
}
|
||||
const media = vi.hoisted(() => ({ wide: false }))
|
||||
vi.mock('@/lib/hooks/use-media-query', () => ({ useMediaQuery: () => media.wide, PHONE_MEDIA_QUERY: '(max-width: 767px)' }))
|
||||
vi.mock('@/lib/api/tables', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/tables')>()),
|
||||
getRecord: vi.fn(async (tableName: string, id: string) => ({
|
||||
tableName, recordLabel: `Acme ${id}`,
|
||||
values: { id: Number(id), name: `Acme ${id}`, status: 'Active', owner: 'Avery' },
|
||||
displayValues: { id, name: `Acme ${id}`, status: 'Active', owner: 'Avery' },
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/lib/api/metadata', () => ({
|
||||
loadTableMetaData: vi.fn(async () => ACCOUNT_TABLE),
|
||||
loadMetaData: vi.fn(async () => ({
|
||||
appTree: [{ name: 'crm', label: 'CRM', type: 'APP', children: [
|
||||
{ name: 'account', label: 'Accounts', type: 'TABLE' },
|
||||
{ name: 'acmeSync', label: 'Acme Sync', type: 'PROCESS' },
|
||||
] }],
|
||||
apps: {},
|
||||
processes: {},
|
||||
tables: {
|
||||
account: { name: 'account', label: 'Accounts', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['name'], icon: { name: 'business' } },
|
||||
contact: { name: 'contact', label: 'Contacts', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['name'] },
|
||||
},
|
||||
})),
|
||||
}))
|
||||
const searchRecordsMock = vi.fn()
|
||||
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
|
||||
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
import { SearchPage } from './SearchPage'
|
||||
import { addRecentSearch, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
|
||||
/**
|
||||
* Renders the page for a URL query string.
|
||||
*
|
||||
* @param query - e.g. `q=acme&tables=account`.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderPage(query: string) {
|
||||
urlState.params = new URLSearchParams(query)
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><SearchPage /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Account results with ids `from`..`to`.
|
||||
*
|
||||
* @param from - First id.
|
||||
* @param to - Last id.
|
||||
* @returns Results.
|
||||
*/
|
||||
function accounts(from: number, to: number) {
|
||||
return Array.from({ length: to - from + 1 }, (_, index) => ({ tableName: 'account', recordId: String(from + index), recordLabel: `Acme ${from + index}` }))
|
||||
}
|
||||
|
||||
const OVERVIEW = {
|
||||
results: [
|
||||
{ tableName: 'account', tableLabel: 'Accounts', recordId: '1', recordLabel: 'Acme Corp', updatedAt: '2026-10-01T10:00:00Z', highlights: [{ fieldName: 'domain', fieldLabel: 'Domain', text: 'acme.com', matches: [{ start: 0, end: 4 }] }], details: [{ fieldName: 'city', fieldLabel: 'City', displayValue: 'Austin' }] },
|
||||
{ tableName: 'contact', tableLabel: 'Contacts', recordId: '9', recordLabel: 'Wile Coyote' },
|
||||
],
|
||||
tables: [
|
||||
{ tableName: 'account', resultCount: 1, hasMore: true, totalCount: 42, source: 'PROVIDER' },
|
||||
{ tableName: 'contact', resultCount: 1, hasMore: false, source: 'PROVIDER' },
|
||||
],
|
||||
provider: { status: 'OK' },
|
||||
}
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
media.wide = false
|
||||
replaceMock.mockReset()
|
||||
searchRecordsMock.mockReset()
|
||||
localStorage.removeItem('qqq-recent-searches')
|
||||
clearRecentRecords()
|
||||
})
|
||||
|
||||
it('shows pages, then each table with its count, highlighted snippets, details and filters with counts', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
renderPage('q=acme')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Results for “acme”')
|
||||
const accountsSection = await screen.findByRole('region', { name: 'Accounts' })
|
||||
expect(accountsSection).toHaveTextContent('42')
|
||||
const card = within(accountsSection).getAllByRole('listitem')[0]
|
||||
expect(card).toHaveTextContent('Domain:acme.com')
|
||||
expect(card).toHaveTextContent('City: Austin')
|
||||
const chips = card.querySelectorAll('[data-qqq-id="search-result-detail-chip"]')
|
||||
expect(Array.from(chips).map((chip) => chip.textContent)).toEqual(['City: Austin'])
|
||||
expect(card.querySelector('time')).toHaveAttribute('dateTime')
|
||||
expect(Array.from(card.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Acme', 'acme'])
|
||||
expect(within(accountsSection).getByRole('link', { name: 'Show all' })).toHaveAttribute('href', '/app/search?q=acme&tables=account')
|
||||
expect(screen.getByRole('region', { name: 'Pages' })).toHaveTextContent('Acme Sync')
|
||||
const filters = screen.getByRole('navigation', { name: 'Filter results' })
|
||||
expect(within(filters).getByRole('link', { name: /^All/ })).toHaveAttribute('aria-current', 'page')
|
||||
expect(within(filters).getByRole('link', { name: /Accounts/ })).toHaveTextContent('42')
|
||||
expect(within(filters).getByRole('link', { name: /Pages/ })).toHaveAttribute('href', '/app/search?q=acme&view=pages')
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('acme', expect.objectContaining({ tableNames: ['account', 'contact'], limitPerTable: 8, sort: 'relevance' }))
|
||||
})
|
||||
|
||||
it('pages one table 25 at a time while the backend reports more, without duplicates', async () => {
|
||||
searchRecordsMock.mockImplementation(async (_term: string, options: { tableNames: string[]; offset?: number }) => {
|
||||
if (options.tableNames.length > 1) return OVERVIEW
|
||||
if (!options.offset) return { results: accounts(1, 25), tables: [{ tableName: 'account', hasMore: true, totalCount: 30 }] }
|
||||
return { results: [...accounts(25, 30)], tables: [{ tableName: 'account', hasMore: false, totalCount: 30 }] }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme&tables=account')
|
||||
const section = await screen.findByRole('region', { name: 'Accounts' })
|
||||
await waitFor(() => expect(within(section).getAllByRole('listitem')).toHaveLength(25))
|
||||
expect(section).toHaveTextContent('25 of 30')
|
||||
await user.click(screen.getByRole('button', { name: 'Show more' }))
|
||||
await waitFor(() => expect(within(section).getAllByRole('listitem')).toHaveLength(30))
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
expect(searchRecordsMock).toHaveBeenCalledWith('acme', expect.objectContaining({ tableNames: ['account'], limitPerTable: 25, offset: 25 }))
|
||||
})
|
||||
|
||||
it('says only the first page is shown when an older backend sends no summaries', async () => {
|
||||
searchRecordsMock.mockImplementation(async (_term: string, options: { tableNames: string[] }) => (options.tableNames.length > 1 ? { results: [] } : { results: accounts(1, 25) }))
|
||||
renderPage('q=acme&tables=account')
|
||||
expect(await screen.findByText('Showing the first 25 matches. Add words to narrow the search.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes the order through the URL', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
await screen.findByRole('region', { name: 'Accounts' })
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort' }), 'recent')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/app/search?q=acme&sort=recent', { scroll: false })
|
||||
})
|
||||
|
||||
it('updates the URL as the user types, and remembers a submitted search', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [] })
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const input = await screen.findByRole('searchbox', { name: 'Search pages and records' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'bolt')
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith('/app/search?q=bolt', { scroll: false }))
|
||||
await user.keyboard('{Enter}')
|
||||
expect(getRecentSearches()).toEqual(['bolt'])
|
||||
})
|
||||
|
||||
it('keeps text typed after a debounced URL update when that update arrives late', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [] })
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = renderPage('q=')
|
||||
const input = await screen.findByRole('searchbox', { name: 'Search pages and records' })
|
||||
await user.type(input, 'acme')
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith('/app/search?q=acme', { scroll: false }))
|
||||
await user.type(input, ' co')
|
||||
// the router now delivers the earlier update
|
||||
urlState.params = new URLSearchParams('q=acme')
|
||||
rerender(<QueryClientProvider client={new QueryClient()}><SearchPage /></QueryClientProvider>)
|
||||
expect(input).toHaveValue('acme co')
|
||||
})
|
||||
|
||||
it('names the failure class, keeps page matches and retries', async () => {
|
||||
searchRecordsMock.mockRejectedValueOnce(new AxiosError('timeout of 30000ms exceeded', 'ECONNABORTED'))
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const failure = await screen.findByText('Record search failed: the server took too long to answer')
|
||||
expect(failure.closest('[data-qqq-id="search-failure"]')).toHaveAttribute('data-failure-kind', 'timeout')
|
||||
expect(screen.getByRole('region', { name: 'Pages' })).toHaveTextContent('Acme Sync')
|
||||
searchRecordsMock.mockResolvedValueOnce(OVERVIEW)
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('region', { name: 'Accounts' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a permission failure without offering a retry', async () => {
|
||||
searchRecordsMock.mockRejectedValue(new AxiosError('Permission denied.', 'ERR_BAD_REQUEST', undefined, null, { status: 403, data: {}, statusText: '', headers: {}, config: {} } as unknown as AxiosResponse))
|
||||
renderPage('q=acme')
|
||||
expect(await screen.findByText('Record search failed: permission denied')).toBeInTheDocument()
|
||||
expect(screen.getByText('Permission denied.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the provider failure above its fallback results', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ ...OVERVIEW, provider: { status: 'FAILED', failureClass: 'SocketTimeoutException', message: 'Read timed out' } })
|
||||
renderPage('q=acme')
|
||||
expect(await screen.findByText('Search index unavailable — showing basic matches instead')).toBeInTheDocument()
|
||||
expect(screen.getByText('SocketTimeoutException: Read timed out')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'Accounts' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says what matched nothing and offers to search everything from a filter', async () => {
|
||||
searchRecordsMock.mockResolvedValue({ results: [], tables: [] })
|
||||
renderPage('q=zzz&tables=contact')
|
||||
expect(await screen.findByText(/No Contacts records match/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Search everything instead' })).toHaveAttribute('href', '/app/search?q=zzz')
|
||||
})
|
||||
|
||||
it('without a term lists recent searches and recently viewed records', async () => {
|
||||
addRecentSearch('acme')
|
||||
addRecentRecord({ tableName: 'account', tableLabel: 'Accounts', recordId: '1', recordLabel: 'Acme Corp', path: '/app/account/1' })
|
||||
renderPage('')
|
||||
expect(await screen.findByRole('link', { name: 'acme' })).toHaveAttribute('href', '/app/search?q=acme')
|
||||
expect(screen.getByRole('region', { name: 'Recently viewed' })).toHaveTextContent('Acme Corp')
|
||||
expect(searchRecordsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('wide screens preview the first result beside the list, follow hover, focus and arrow keys, and offer edit when permitted', async () => {
|
||||
media.wide = true
|
||||
searchRecordsMock.mockResolvedValue({
|
||||
results: [
|
||||
{ tableName: 'account', recordId: '1', recordLabel: 'Acme 1', score: 10, highlights: [{ fieldName: 'name', fieldLabel: 'Name', text: 'Acme 1', matches: [{ start: 0, end: 4 }] }] },
|
||||
{ tableName: 'account', recordId: '2', recordLabel: 'Acme 2', score: 3 },
|
||||
],
|
||||
tables: [{ tableName: 'account', resultCount: 2, hasMore: false }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const pane = await screen.findByRole('complementary', { name: 'Record preview' })
|
||||
// T1 fields, without the primary key or heavy fields
|
||||
// the preview chunk loads on demand
|
||||
expect(await within(pane).findByText('Owner', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(within(pane).getByText('Avery')).toBeInTheDocument()
|
||||
expect(within(pane).queryByText('Notes')).not.toBeInTheDocument()
|
||||
expect(within(pane).getByRole('heading', { level: 2 })).toHaveTextContent('Acme 1')
|
||||
expect(within(pane).getByText('Matched')).toBeInTheDocument()
|
||||
expect(within(pane).getByRole('link', { name: 'Edit' })).toHaveAttribute('href', '/app/account/1/edit')
|
||||
expect(within(pane).getByRole('link', { name: 'New tab' })).toHaveAttribute('target', '_blank')
|
||||
// relevance relative to the best result
|
||||
const cards = screen.getAllByRole('listitem').filter((item) => item.getAttribute('data-qqq-id') === 'search-result-card')
|
||||
expect(cards[0]).toHaveAttribute('data-active', 'true')
|
||||
expect(within(cards[0]).getByText('Strong match')).toBeInTheDocument()
|
||||
expect(within(cards[1]).getByText('Partial match')).toBeInTheDocument()
|
||||
// keyboard: focus a result, ArrowDown moves to the next and previews it
|
||||
const links = screen.getAllByRole('link').filter((link) => link.getAttribute('data-qqq-id') === 'search-result-link')
|
||||
links[0].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(links[1]).toHaveFocus()
|
||||
await waitFor(() => expect(within(pane).getByRole('heading', { level: 2 })).toHaveTextContent('Acme 2'))
|
||||
// pointer: hovering a result previews it
|
||||
await user.hover(cards[0])
|
||||
await waitFor(() => expect(within(pane).getByRole('heading', { level: 2 })).toHaveTextContent('Acme 1'))
|
||||
})
|
||||
|
||||
it('narrow screens open the preview in a side sheet from the Preview button', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
const user = userEvent.setup()
|
||||
renderPage('q=acme')
|
||||
const accountsSection = await screen.findByRole('region', { name: 'Accounts' })
|
||||
expect(screen.queryByRole('complementary', { name: 'Record preview' })).not.toBeInTheDocument()
|
||||
await user.click(within(accountsSection).getByRole('button', { name: 'Preview Acme Corp' }))
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Preview' })
|
||||
expect(await within(sheet).findByText('Owner', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
await user.click(within(sheet).getByRole('button', { name: 'Close preview' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Preview' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('copies a record link and offers it in a new tab', async () => {
|
||||
searchRecordsMock.mockResolvedValue(OVERVIEW)
|
||||
const user = userEvent.setup()
|
||||
// after setup: user-event installs its own clipboard
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
renderPage('q=acme')
|
||||
const accountsSection = await screen.findByRole('region', { name: 'Accounts' })
|
||||
expect(within(accountsSection).getByRole('link', { name: 'Open Acme Corp in a new tab' })).toHaveAttribute('target', '_blank')
|
||||
await user.click(within(accountsSection).getByRole('button', { name: 'Copy link to Acme Corp' }))
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/app/account/1`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* 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 SearchPage — the full search results page (`/app/search`). Its state lives in the
|
||||
* URL (`q`, `tables`, `view=pages`, `sort=recent`), so searches can be shared and bookmarked.
|
||||
*
|
||||
* - "All" shows matching pages, then each table's best matches with its count and a link
|
||||
* to that table's results, then matching recently viewed records.
|
||||
* - A table filter lists that table's matches 25 at a time, with "Show more" while the
|
||||
* backend reports more (older backends return one page; the page says so).
|
||||
* - Relevance or most-recent order; result cards with matched-field snippets, secondary
|
||||
* values, last update and quick actions (open, new tab, copy link).
|
||||
* - A failed record search names its failure class and keeps the page and recently viewed
|
||||
* matches; a failed search provider (index) is reported above its fallback results.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { AlertTriangle, Clock, History, Loader2, RotateCw, Search, X } from 'lucide-react'
|
||||
|
||||
import { loadMetaData } from '@/lib/api/metadata'
|
||||
import { searchRecords, type RecordSearchResult } from '@/lib/api/record-search'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch } from '@/lib/hooks/use-record-search'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { searchNavTargets, searchRecentRecords } from '@/lib/utils/navigation-search'
|
||||
import { isRecordSearchTerm, searchableTables } from '@/lib/utils/record-search'
|
||||
import { addRecentSearch, clearRecentSearches, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import { describeProviderFallback, describeSearchFailure } from '@/lib/search/search-failure'
|
||||
import {
|
||||
groupResultsByTable,
|
||||
labelQuality,
|
||||
orderTableResults,
|
||||
parseSearchPageState,
|
||||
searchPagePath,
|
||||
tableCountLabel,
|
||||
tableHasMore,
|
||||
type SearchPageState,
|
||||
} from '@/lib/search/search-model'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
import { SearchResultCard } from './SearchResultCard'
|
||||
|
||||
/** The record preview (key fields, thumbnails, quick actions), loaded when first shown. */
|
||||
const RecordPreview = dynamic(() => import('./RecordPreview').then((module) => module.RecordPreview), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading preview…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/** Wide enough for results and preview side by side. */
|
||||
const SPLIT_VIEW_QUERY = '(min-width: 1280px)'
|
||||
|
||||
/**
|
||||
* A result's key for preview state.
|
||||
*
|
||||
* @param result - The result.
|
||||
* @returns `table:id`.
|
||||
*/
|
||||
function resultKey(result: RecordSearchResult): string {
|
||||
return `${result.tableName}:${result.recordId}`
|
||||
}
|
||||
|
||||
/** Fallback icon kind for each app-tree node type. */
|
||||
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
|
||||
|
||||
/** Type shown for each app-tree node type. */
|
||||
const TYPE_LABEL: Record<string, string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report' }
|
||||
|
||||
/** Records per table in the "All" overview. */
|
||||
const OVERVIEW_PER_TABLE = 8
|
||||
|
||||
/** Records per page in a one-table view (the QQQ 4.1 maximum per table). */
|
||||
const TABLE_PAGE_SIZE = 25
|
||||
|
||||
/** Pages listed in the "All" overview. */
|
||||
const OVERVIEW_PAGES = 6
|
||||
|
||||
/** Delay between typing and updating the URL (which runs the search). */
|
||||
const URL_DEBOUNCE_MS = 250
|
||||
|
||||
/** Classes of a section card. */
|
||||
const SECTION_CLASS = 'overflow-hidden rounded-xl border border-border bg-card shadow-sm'
|
||||
|
||||
/**
|
||||
* A filter chip (a link: filters are URL state).
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.href - Target URL.
|
||||
* @param props.active - Whether this filter is applied.
|
||||
* @param props.label - Visible label.
|
||||
* @param props.count - Count badge.
|
||||
* @param props.icon - Leading icon.
|
||||
* @param props.qqqId - `data-qqq-id`.
|
||||
* @returns The chip.
|
||||
*/
|
||||
function FilterChip({ href, active, label, count, icon, qqqId }: { href: string; active: boolean; label: string; count?: string; icon?: React.ReactNode; qqqId: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
replace
|
||||
scroll={false}
|
||||
prefetch={false}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm transition-colors pointer-coarse:min-h-11',
|
||||
active ? 'border-primary bg-primary/10 font-medium text-primary' : 'border-border bg-card text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-qqq-id={qqqId}
|
||||
>
|
||||
{icon}
|
||||
<span className="max-w-[12rem] truncate">{label}</span>
|
||||
{count !== undefined && <span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{count}</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the search page.
|
||||
*
|
||||
* @returns The search form, filters and results.
|
||||
*/
|
||||
export function SearchPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { setPageHeader } = useQContext()
|
||||
const state = useMemo(() => parseSearchPageState(searchParams), [searchParams])
|
||||
const { q, sort, view } = state
|
||||
const term = q.trim()
|
||||
const [inputValue, setInputValue] = useState(q)
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
|
||||
const { data: metaData } = useQuery({ queryKey: queryKeys.metadataAll(), queryFn: loadMetaData, staleTime: 1000 * 60 * 30 })
|
||||
const { navTargets } = useAppTreeRoutes(metaData)
|
||||
const allTables = useMemo(() => searchableTables(metaData), [metaData])
|
||||
// tables named in the URL that this user can search (unknown names are ignored)
|
||||
const filterTables = useMemo(() => state.tables.filter((name) => allTables.some((table) => table.name === name)), [state.tables, allTables])
|
||||
const singleTable = filterTables.length === 1 ? allTables.find((table) => table.name === filterTables[0]) : undefined
|
||||
|
||||
useEffect(() => setPageHeader('Search'), [setPageHeader])
|
||||
useEffect(() => {
|
||||
setRecentRecords(getRecentRecords())
|
||||
setRecentSearches(getRecentSearches())
|
||||
}, [])
|
||||
|
||||
// URL → input (back/forward, links), but not the echo of our own debounced update: the
|
||||
// user may have typed more since it was sent
|
||||
const pushedQRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (pushedQRef.current !== null && q.trim() === pushedQRef.current) return
|
||||
pushedQRef.current = null
|
||||
setInputValue(q)
|
||||
}, [q])
|
||||
|
||||
// the latest URL state, for a debounced update that fires after a filter or sort change
|
||||
const latestRef = useRef({ state, filterTables })
|
||||
latestRef.current = { state, filterTables }
|
||||
|
||||
/**
|
||||
* Replaces the URL state (merging with the current state).
|
||||
*
|
||||
* @param next - Changed fields.
|
||||
*/
|
||||
const navigate = (next: Partial<SearchPageState>) => {
|
||||
router.replace(searchPagePath({ ...state, tables: filterTables, ...next }), { scroll: false })
|
||||
}
|
||||
|
||||
// input → URL, debounced: searching as you type
|
||||
useEffect(() => {
|
||||
if (inputValue.trim() === term) return
|
||||
const timer = setTimeout(() => {
|
||||
const latest = latestRef.current
|
||||
pushedQRef.current = inputValue.trim()
|
||||
router.replace(searchPagePath({ ...latest.state, tables: latest.filterTables, q: inputValue }), { scroll: false })
|
||||
}, URL_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only typing schedules a URL update
|
||||
}, [inputValue])
|
||||
|
||||
/**
|
||||
* Commits the search immediately and remembers it.
|
||||
*
|
||||
* @param event - Form submit.
|
||||
*/
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (inputValue.trim()) setRecentSearches(addRecentSearch(inputValue))
|
||||
navigate({ q: inputValue })
|
||||
}
|
||||
|
||||
// Overview: every searchable table (or the URL's tables), a few results each, for counts
|
||||
const overviewTables = filterTables.length > 1 ? allTables.filter((table) => filterTables.includes(table.name)) : allTables
|
||||
const overview = useRecordSearch(term, overviewTables, OVERVIEW_PER_TABLE, { sort, debounceMs: 0 })
|
||||
const overviewGroups = useMemo(
|
||||
() => groupResultsByTable(overview.results, overview.summaries, allTables, term, sort),
|
||||
[overview.results, overview.summaries, allTables, term, sort]
|
||||
)
|
||||
|
||||
// One table: pages of 25 while the backend reports more
|
||||
const tablePages = useInfiniteQuery({
|
||||
queryKey: [...queryKeys.search(), 'table-pages', term, singleTable?.name ?? '', sort],
|
||||
queryFn: ({ pageParam, signal }) => searchRecords(term, { tableNames: singleTable ? [singleTable.name] : [], limitPerTable: TABLE_PAGE_SIZE, offset: pageParam, sort, signal }),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, _pages, lastOffset) => {
|
||||
const summary = last.tables?.find((table) => table.tableName === singleTable?.name)
|
||||
return summary?.hasMore && last.results.length > 0 ? lastOffset + TABLE_PAGE_SIZE : undefined
|
||||
},
|
||||
enabled: Boolean(singleTable) && isRecordSearchTerm(term),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
const tableResults = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const all: RecordSearchResult[] = []
|
||||
for (const page of tablePages.data?.pages ?? []) {
|
||||
for (const result of page.results) {
|
||||
if (result.tableName !== singleTable?.name || seen.has(result.recordId)) continue
|
||||
seen.add(result.recordId)
|
||||
all.push(result)
|
||||
}
|
||||
}
|
||||
return orderTableResults(all, term, sort)
|
||||
}, [tablePages.data, singleTable, term, sort])
|
||||
const tableSummary = tablePages.data?.pages[0]?.tables?.find((table) => table.tableName === singleTable?.name)
|
||||
const tableFailure = useMemo(() => (tablePages.isError ? describeSearchFailure(tablePages.error) : undefined), [tablePages.isError, tablePages.error])
|
||||
const tableFallback = describeProviderFallback(tablePages.data?.pages[0]?.provider)
|
||||
|
||||
// Pages and recently viewed records match locally
|
||||
const pages = useMemo(() => {
|
||||
if (!term) return []
|
||||
return searchNavTargets(navTargets, term)
|
||||
.map((target, index) => ({ target, index, quality: labelQuality(target.label, term) }))
|
||||
.sort((a, b) => b.quality - a.quality || a.index - b.index)
|
||||
.map(({ target }) => target)
|
||||
}, [navTargets, term])
|
||||
const foundPaths = useMemo(() => new Set(overview.results.map((result) => `/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`)), [overview.results])
|
||||
const recentMatches = useMemo(() => (term ? searchRecentRecords(recentRecords, term).filter((record) => !foundPaths.has(record.path)).slice(0, 5) : []), [recentRecords, term, foundPaths])
|
||||
|
||||
const recordSearchAvailable = allTables.length > 0
|
||||
const failure = singleTable ? tableFailure : overview.failure
|
||||
const fallback = singleTable ? tableFallback : overview.fallback
|
||||
const searching = singleTable ? tablePages.isFetching && !tablePages.isFetchingNextPage : overview.isSearching
|
||||
const recordCount = overviewGroups.reduce((sum, group) => sum + group.results.length, 0)
|
||||
const pagesCount = pages.length
|
||||
const nothingFound = Boolean(term) && !searching && !failure
|
||||
&& (view === 'pages' ? pagesCount === 0 : singleTable ? tableResults.length === 0 : pagesCount === 0 && recordCount === 0 && recentMatches.length === 0)
|
||||
|
||||
/**
|
||||
* Remembers the search when the user opens a result.
|
||||
*
|
||||
* @param event - Click anywhere in the results.
|
||||
*/
|
||||
const rememberOnOpen = (event: React.MouseEvent) => {
|
||||
if (term && event.target instanceof Element && event.target.closest('a[href]')) setRecentSearches(addRecentSearch(term))
|
||||
}
|
||||
|
||||
const filterHref = (next: Partial<SearchPageState>) => searchPagePath({ q: term, sort, ...next })
|
||||
|
||||
// Record preview: a split view beside the results on wide screens (following hover, focus
|
||||
// and the arrow keys), a side sheet elsewhere (its button, or a long press)
|
||||
const splitView = useMediaQuery(SPLIT_VIEW_QUERY)
|
||||
const [previewKey, setPreviewKey] = useState<string | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const shownResults = useMemo(() => {
|
||||
if (view === 'pages') return []
|
||||
if (singleTable) return tableResults.map((result) => ({ result, tableLabel: singleTable.label, icon: singleTable.icon }))
|
||||
return overviewGroups.flatMap((group) => group.results.map((result) => ({ result, tableLabel: group.tableLabel, icon: group.icon })))
|
||||
}, [view, singleTable, tableResults, overviewGroups])
|
||||
const previewed = shownResults.find((entry) => resultKey(entry.result) === previewKey) ?? (splitView ? shownResults[0] : undefined)
|
||||
const showSplit = splitView && Boolean(term) && shownResults.length > 0
|
||||
// relevance relative to the best score per table (only providers send scores)
|
||||
const bestScore = useMemo(() => {
|
||||
const best = new Map<string, number>()
|
||||
for (const { result } of shownResults) {
|
||||
if (result.score !== undefined && result.score > (best.get(result.tableName) ?? 0)) best.set(result.tableName, result.score)
|
||||
}
|
||||
return best
|
||||
}, [shownResults])
|
||||
|
||||
/**
|
||||
* Props that tie a result card to the preview.
|
||||
*
|
||||
* @param result - The card's result.
|
||||
* @returns Active state, activation and preview handlers, and relevance.
|
||||
*/
|
||||
const previewProps = (result: RecordSearchResult) => {
|
||||
const key = resultKey(result)
|
||||
const best = bestScore.get(result.tableName)
|
||||
return {
|
||||
active: showSplit && previewed !== undefined && resultKey(previewed.result) === key,
|
||||
onActivate: showSplit ? () => setPreviewKey(key) : undefined,
|
||||
onPreview: () => {
|
||||
setPreviewKey(key)
|
||||
if (!splitView) setSheetOpen(true)
|
||||
},
|
||||
relevance: result.score !== undefined && best ? Math.max(0, Math.min(1, result.score / best)) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ↑/↓ on a result moves to the previous or next result (and so the preview).
|
||||
*
|
||||
* @param event - Keydown within the results.
|
||||
*/
|
||||
const handleResultsKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
if (!(event.target instanceof HTMLElement) || !event.target.matches('[data-qqq-id="search-result-link"]')) return
|
||||
const links = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-qqq-id="search-result-link"]'))
|
||||
const next = links[links.indexOf(event.target) + (event.key === 'ArrowDown' ? 1 : -1)]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
next.scrollIntoView?.({ block: 'nearest' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto w-full space-y-5', showSplit ? 'max-w-[110rem]' : 'max-w-5xl')} data-qqq-id="search-results-page">
|
||||
<h1 className="break-words text-2xl font-bold tracking-tight text-foreground">
|
||||
{term ? <>Results for “{term}”</> : 'Search'}
|
||||
</h1>
|
||||
|
||||
<form role="search" onSubmit={handleSubmit} className="relative" aria-label="Search">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
placeholder={recordSearchAvailable ? 'Find an app, table, process or record…' : 'Find an app, table, process or recent record…'}
|
||||
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
|
||||
className="w-full rounded-xl border border-input bg-card py-3 pl-10 pr-10 text-base focus:outline-none focus:ring-2 focus:ring-ring sm:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
data-qqq-id="search-input"
|
||||
/>
|
||||
{searching && <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" aria-hidden="true" />}
|
||||
</form>
|
||||
|
||||
{term && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<nav aria-label="Filter results" className="-mx-1 flex min-w-0 gap-2 overflow-x-auto px-1 pb-1" data-qqq-id="search-filters">
|
||||
<FilterChip href={filterHref({})} active={view !== 'pages' && filterTables.length === 0} label="All" qqqId="search-filter-all" />
|
||||
<FilterChip href={filterHref({ view: 'pages' })} active={view === 'pages'} label="Pages" count={String(pagesCount)} qqqId="search-filter-pages" />
|
||||
{overviewGroups.map((group) => (
|
||||
<FilterChip
|
||||
key={group.tableName}
|
||||
href={filterHref({ tables: [group.tableName] })}
|
||||
active={singleTable?.name === group.tableName}
|
||||
label={group.tableLabel}
|
||||
count={tableCountLabel(group.results.length, group.summary)}
|
||||
icon={<MetadataIcon icon={group.icon} kind="table" className="h-3.5 w-3.5" />}
|
||||
qqqId={`search-filter-table-${group.tableName}`}
|
||||
/>
|
||||
))}
|
||||
{singleTable && !overviewGroups.some((group) => group.tableName === singleTable.name) && (
|
||||
<FilterChip href={filterHref({ tables: [singleTable.name] })} active label={singleTable.label} qqqId={`search-filter-table-${singleTable.name}`} />
|
||||
)}
|
||||
</nav>
|
||||
{view !== 'pages' && recordSearchAvailable && (
|
||||
<label className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
Sort
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => navigate({ sort: event.target.value === 'recent' ? 'recent' : 'relevance' })}
|
||||
className="rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
data-qqq-id="search-sort"
|
||||
>
|
||||
<option value="relevance">Best match</option>
|
||||
<option value="recent">Recently updated</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record search progress and problems, announced politely */}
|
||||
<div role="status" aria-live="polite" data-qqq-id="search-records-status">
|
||||
{searching && <span className="sr-only">Searching records…</span>}
|
||||
{failure && (
|
||||
<div className="flex flex-wrap items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" data-qqq-id="search-failure" data-failure-kind={failure.kind}>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground">{failure.title}</p>
|
||||
{failure.detail && <p className="break-words text-muted-foreground">{failure.detail}</p>}
|
||||
<p className="text-muted-foreground">Pages and recently viewed records below still match.</p>
|
||||
</div>
|
||||
{failure.retryable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (singleTable ? void tablePages.refetch() : overview.retry())}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-qqq-id="button-search-retry"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fallback && !failure && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm" data-qqq-id="search-fallback">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{fallback.title}</p>
|
||||
{fallback.detail && <p className="break-words text-muted-foreground">{fallback.detail}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!term && (
|
||||
<div className="space-y-5" data-qqq-id="search-prompt-state">
|
||||
<p className="rounded-xl border border-border bg-card px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
{recordSearchAvailable ? 'Enter text above to find apps, tables, processes and records.' : 'Enter text above to find apps, tables, processes and recently viewed records.'}
|
||||
</p>
|
||||
{recentSearches.length > 0 && (
|
||||
<section aria-labelledby="search-recent-searches" className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 id="search-recent-searches" className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<History className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Recent searches
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearRecentSearches()
|
||||
setRecentSearches([])
|
||||
}}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
data-qqq-id="button-clear-recent-searches"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((recent) => (
|
||||
<FilterChip key={recent} href={searchPagePath({ q: recent })} active={false} label={recent} qqqId="search-recent-search" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{recentRecords.length > 0 && (
|
||||
<section aria-labelledby="search-recently-viewed" className={SECTION_CLASS}>
|
||||
<h2 id="search-recently-viewed" className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-semibold text-foreground sm:px-6">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Recently viewed
|
||||
</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{recentRecords.slice(0, 8).map((record) => (
|
||||
<li key={record.path}>
|
||||
<Link href={record.path} prefetch={false} className="flex min-w-0 items-center gap-3 px-4 py-3 text-sm hover:bg-accent sm:px-6">
|
||||
<MetadataIcon icon={record.tableIcon} kind="table" className="text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-medium text-foreground">{record.recordLabel}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{record.tableLabel}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nothingFound && (
|
||||
<div className="rounded-xl border border-border bg-card px-6 py-10 text-center" data-qqq-id="search-empty-state">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{view === 'pages' ? 'No pages match' : singleTable ? `No ${singleTable.label} records match` : recordSearchAvailable ? 'No pages or records match' : 'No pages or recent records match'} “{term}”.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Check the spelling, or try fewer or different words.</p>
|
||||
{(singleTable || view === 'pages' || filterTables.length > 1) && (
|
||||
<Link href={filterHref({})} replace className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-foreground hover:bg-accent" data-qqq-id="button-search-everywhere">
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Search everything instead
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{term && (
|
||||
<div className={cn(showSplit && 'grid grid-cols-[minmax(0,1fr)_minmax(24rem,34rem)] items-start gap-6')}>
|
||||
{/* delegated click: remembers the search when a result link is followed; ↑/↓ move between results */}
|
||||
<div className="min-w-0 space-y-5" onClick={rememberOnOpen} onKeyDown={handleResultsKeyDown}>
|
||||
{view !== 'pages' && !singleTable && pages.length > 0 && (
|
||||
<PagesSection pages={pages.slice(0, OVERVIEW_PAGES)} term={term} moreHref={pages.length > OVERVIEW_PAGES ? filterHref({ view: 'pages' }) : undefined} total={pages.length} />
|
||||
)}
|
||||
{view === 'pages' && pages.length > 0 && <PagesSection pages={pages} term={term} total={pages.length} />}
|
||||
|
||||
{view !== 'pages' && !singleTable && overviewGroups.map((group) => {
|
||||
const more = tableHasMore(group.results.length, group.summary, OVERVIEW_PER_TABLE)
|
||||
return (
|
||||
<section key={group.tableName} aria-labelledby={`search-table-${group.tableName}`} className={cn(SECTION_CLASS, overview.isStale && 'opacity-80')} data-qqq-id={`search-group-table-${group.tableName}`}>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
|
||||
<MetadataIcon icon={group.icon} kind="table" className="text-muted-foreground" />
|
||||
<h2 id={`search-table-${group.tableName}`} className="min-w-0 truncate text-sm font-semibold text-foreground">{group.tableLabel}</h2>
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{tableCountLabel(group.results.length, group.summary)}</span>
|
||||
{more && (
|
||||
<Link href={filterHref({ tables: [group.tableName] })} replace scroll={false} prefetch={false} className="ml-auto shrink-0 text-sm font-medium text-primary hover:underline" data-qqq-id={`search-more-${group.tableName}`}>
|
||||
Show all
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{group.results.map((result) => (
|
||||
<SearchResultCard key={result.recordId} result={result} tableLabel={group.tableLabel} icon={group.icon} term={term} {...previewProps(result)} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{singleTable && tableResults.length > 0 && (
|
||||
<section aria-labelledby="search-table-results" className={SECTION_CLASS} data-qqq-id={`search-group-table-${singleTable.name}`}>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
|
||||
<MetadataIcon icon={singleTable.icon} kind="table" className="text-muted-foreground" />
|
||||
<h2 id="search-table-results" className="min-w-0 truncate text-sm font-semibold text-foreground">{singleTable.label}</h2>
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">
|
||||
{tableSummary?.totalCount !== undefined ? `${tableResults.length} of ${tableSummary.totalCount}` : tableCountLabel(tableResults.length, tablePages.hasNextPage ? { tableName: singleTable.name, hasMore: true } : undefined)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{tableResults.map((result) => (
|
||||
<SearchResultCard key={result.recordId} result={result} tableLabel={singleTable.label} icon={singleTable.icon} term={term} {...previewProps(result)} />
|
||||
))}
|
||||
</ul>
|
||||
{tablePages.hasNextPage && (
|
||||
<div className="border-t border-border px-4 py-3 text-center sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void tablePages.fetchNextPage()}
|
||||
disabled={tablePages.isFetchingNextPage}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-60"
|
||||
data-qqq-id="button-search-show-more"
|
||||
>
|
||||
{tablePages.isFetchingNextPage && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!tablePages.hasNextPage && !tableSummary && tableResults.length >= TABLE_PAGE_SIZE && (
|
||||
<p className="border-t border-border px-4 py-3 text-center text-sm text-muted-foreground sm:px-6" data-qqq-id="search-first-page-only">
|
||||
Showing the first {TABLE_PAGE_SIZE} matches. Add words to narrow the search.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view !== 'pages' && !singleTable && recentMatches.length > 0 && (
|
||||
<section aria-labelledby="search-recent-matches" className={SECTION_CLASS} data-qqq-id="search-group-records">
|
||||
<h2 id="search-recent-matches" className="flex items-center gap-2 border-b border-border px-4 py-3 text-sm font-semibold text-foreground sm:px-6">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Recently viewed
|
||||
</h2>
|
||||
<ul className="divide-y divide-border">
|
||||
{recentMatches.map((record) => (
|
||||
<li key={record.path}>
|
||||
<Link href={record.path} prefetch={false} className="flex min-w-0 items-center gap-3 px-4 py-3 text-sm hover:bg-accent sm:px-6" data-qqq-id={`search-recent-${record.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={record.tableIcon} kind="table" className="text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-medium text-foreground"><HighlightedText text={record.recordLabel} term={term} /></span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{record.tableLabel}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{showSplit && previewed && (
|
||||
<aside
|
||||
aria-label="Record preview"
|
||||
className="sticky top-4 max-h-[calc(100dvh-6rem)] overflow-y-auto overscroll-contain rounded-xl border border-border bg-card p-5 shadow-sm"
|
||||
data-qqq-id="search-preview-pane"
|
||||
>
|
||||
<RecordPreview key={resultKey(previewed.result)} result={previewed.result} tableLabel={previewed.tableLabel} icon={previewed.icon} term={term} headingId="search-preview-title" />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Narrow screens: the preview opens as a side sheet */}
|
||||
<Dialog.Root open={sheetOpen && !splitView && previewed !== undefined} onOpenChange={setSheetOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-[2000] bg-black/40 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className="fixed inset-y-0 right-0 z-[2001] flex w-full max-w-md flex-col bg-card shadow-xl focus:outline-none"
|
||||
aria-describedby={undefined}
|
||||
data-qqq-id="search-preview-sheet"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<Dialog.Title className="text-sm font-semibold text-muted-foreground">Preview</Dialog.Title>
|
||||
<Dialog.Close className="flex h-11 w-11 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" aria-label="Close preview">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-5">
|
||||
{previewed && <RecordPreview key={resultKey(previewed.result)} result={previewed.result} tableLabel={previewed.tableLabel} icon={previewed.icon} term={term} headingId="search-preview-sheet-title" />}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The matching pages section.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.pages - Pages to list.
|
||||
* @param props.term - Search text.
|
||||
* @param props.total - All matching pages.
|
||||
* @param props.moreHref - Link to every matching page, when not all are listed.
|
||||
* @returns The section.
|
||||
*/
|
||||
function PagesSection({ pages, term, total, moreHref }: { pages: ReturnType<typeof searchNavTargets>; term: string; total: number; moreHref?: string }) {
|
||||
return (
|
||||
<section aria-labelledby="search-pages" className={SECTION_CLASS} data-qqq-id="search-group-pages">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
|
||||
<h2 id="search-pages" className="text-sm font-semibold text-foreground">Pages</h2>
|
||||
<span className="rounded-full bg-muted px-1.5 text-xs tabular-nums text-muted-foreground">{total}</span>
|
||||
{moreHref && (
|
||||
<Link href={moreHref} replace scroll={false} prefetch={false} className="ml-auto text-sm font-medium text-primary hover:underline" data-qqq-id="search-more-pages">
|
||||
Show all
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{pages.map((target) => {
|
||||
const context = target.ancestors.map((ancestor) => ancestor.label).join(' / ')
|
||||
return (
|
||||
<li key={target.path}>
|
||||
<Link href={target.path} prefetch={false} className="flex min-w-0 items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent sm:px-6" data-qqq-id={`search-page-${target.path.replace(/\//g, '-')}`}>
|
||||
<MetadataIcon icon={target.icon} kind={ICON_KIND[target.nodeType] ?? 'app'} className="text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-medium text-foreground"><HighlightedText text={target.label} term={term} /></span>
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">{[TYPE_LABEL[target.nodeType], context].filter(Boolean).join(' · ')}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* 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 SearchResultCard — one record on the search page: highlighted label, matched field
|
||||
* snippets, secondary values, last update, and quick actions (open, open in a new tab,
|
||||
* copy link).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Eye, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QIcon } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import { recordSearchResultPath } from '@/lib/utils/record-search'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Props for {@link SearchResultCard}. */
|
||||
export interface SearchResultCardProps {
|
||||
/** The record. */
|
||||
result: RecordSearchResult
|
||||
/** Table label (shown when `showTable`). */
|
||||
tableLabel: string
|
||||
/** Table icon. */
|
||||
icon?: QIcon
|
||||
/** Search text (highlights labels and snippets without backend ranges). */
|
||||
term: string
|
||||
/** Show the table label (results of several tables mix). */
|
||||
showTable?: boolean
|
||||
/** Whether this result is the one previewed. */
|
||||
active?: boolean
|
||||
/** Makes this result the previewed one (pointer over it, or keyboard focus on it). */
|
||||
onActivate?: () => void
|
||||
/** Opens the preview (its button, or a long press on touch screens). */
|
||||
onPreview?: () => void
|
||||
/** Relevance relative to the best result shown, 0..1, when the provider sent scores. */
|
||||
relevance?: number
|
||||
}
|
||||
|
||||
/** Long-press duration that opens the preview on touch screens. */
|
||||
const LONG_PRESS_MS = 500
|
||||
|
||||
/**
|
||||
* A three-step relevance meter.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.value - Relevance, 0..1.
|
||||
* @returns The meter.
|
||||
*/
|
||||
function RelevanceMeter({ value }: { value: number }) {
|
||||
const steps = value >= 0.75 ? 3 : value >= 0.4 ? 2 : 1
|
||||
const label = steps === 3 ? 'Strong match' : steps === 2 ? 'Good match' : 'Partial match'
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1" title={`${label} (${Math.round(value * 100)}% of the best result)`} data-qqq-id="search-result-relevance">
|
||||
<span className="flex items-end gap-0.5" aria-hidden="true">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<span key={step} className={cn('w-1 rounded-sm', step === 1 ? 'h-1.5' : step === 2 ? 'h-2' : 'h-2.5', step <= steps ? 'bg-primary' : 'bg-muted-foreground/30')} />
|
||||
))}
|
||||
</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a record link to the clipboard and confirms with a toast.
|
||||
*
|
||||
* @param path - App path of the record.
|
||||
*/
|
||||
async function copyLink(path: string): Promise<void> {
|
||||
const url = new URL(path, window.location.origin).toString()
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('Link copied')
|
||||
} catch {
|
||||
toast.error('Could not copy the link')
|
||||
}
|
||||
}
|
||||
|
||||
/** Classes of a quick action button. */
|
||||
const ACTION_CLASS = 'h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
|
||||
/**
|
||||
* Renders one record result as a list item.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns An `<li>` with the record link and its quick actions.
|
||||
*/
|
||||
export function SearchResultCard({ result, tableLabel, icon, term, showTable = false, active = false, onActivate, onPreview, relevance }: SearchResultCardProps) {
|
||||
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const longPressed = useRef(false)
|
||||
|
||||
/** Cancels a pending long press. */
|
||||
const cancelPress = () => {
|
||||
if (pressTimer.current) clearTimeout(pressTimer.current)
|
||||
pressTimer.current = null
|
||||
}
|
||||
const path = recordSearchResultPath(result)
|
||||
// On phones the preview sheet offers New tab and Copy link, so the card keeps only Preview
|
||||
// and its text gets the width
|
||||
const secondaryActionClass = cn(ACTION_CLASS, onPreview ? 'hidden sm:inline-flex' : 'inline-flex')
|
||||
const highlights = (result.highlights ?? []).filter((highlight) => highlight.text.trim()).slice(0, 3)
|
||||
const details = (result.details ?? []).filter((detail) => detail.displayValue.trim()).slice(0, 4)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn('group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/50 sm:px-6', active && 'bg-accent shadow-[inset_3px_0_0_var(--color-primary)]')}
|
||||
data-qqq-id="search-result-card"
|
||||
data-active={active || undefined}
|
||||
onMouseEnter={onActivate}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== 'touch' || !onPreview) return
|
||||
longPressed.current = false
|
||||
cancelPress()
|
||||
pressTimer.current = setTimeout(() => {
|
||||
longPressed.current = true
|
||||
onPreview()
|
||||
}, LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerUp={cancelPress}
|
||||
onPointerMove={(event) => { if (event.pointerType === 'touch' && (Math.abs(event.movementX) > 4 || Math.abs(event.movementY) > 4)) cancelPress() }}
|
||||
onPointerCancel={cancelPress}
|
||||
onClickCapture={(event) => {
|
||||
// a long press opened the preview: do not also follow the link
|
||||
if (longPressed.current) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
longPressed.current = false
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { if (longPressed.current) event.preventDefault() }}
|
||||
>
|
||||
<MetadataIcon icon={icon} kind="table" className="mt-0.5 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<Link
|
||||
href={path}
|
||||
prefetch={false}
|
||||
className="min-w-0 break-words font-medium text-foreground after:absolute after:inset-0 after:content-[''] hover:underline focus:outline-none focus-visible:underline"
|
||||
data-qqq-id="search-result-link"
|
||||
data-result-key={`${result.tableName}:${result.recordId}`}
|
||||
onFocus={onActivate}
|
||||
>
|
||||
<HighlightedText text={result.recordLabel} term={term} />
|
||||
</Link>
|
||||
{showTable && <span className="text-xs text-muted-foreground">{tableLabel}</span>}
|
||||
{relevance !== undefined && <RelevanceMeter value={relevance} />}
|
||||
</div>
|
||||
{highlights.length > 0 && (
|
||||
<dl className="mt-1 space-y-0.5 text-sm text-muted-foreground">
|
||||
{highlights.map((highlight) => (
|
||||
<div key={highlight.fieldName} className="flex min-w-0 gap-1.5">
|
||||
{highlight.fieldLabel && <dt className="shrink-0 text-muted-foreground/80">{highlight.fieldLabel}:</dt>}
|
||||
<dd className="min-w-0 break-words">
|
||||
<HighlightedText text={highlight.text} matches={highlight.matches.length > 0 ? highlight.matches : undefined} term={term} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{(details.length > 0 || result.updatedAt) && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{details.map((detail) => (
|
||||
<span
|
||||
key={detail.fieldName}
|
||||
className="inline-flex min-w-0 max-w-full items-baseline gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5"
|
||||
data-qqq-id="search-result-detail-chip"
|
||||
>
|
||||
{detail.fieldLabel && <span className="shrink-0 text-muted-foreground">{detail.fieldLabel}<span className="sr-only">: </span></span>}
|
||||
<span className="min-w-0 break-words font-medium text-foreground">{detail.displayValue}</span>
|
||||
</span>
|
||||
))}
|
||||
{result.updatedAt && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Updated <DateTimeText value={result.updatedAt} type="DATE_TIME" relative className="text-xs text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick actions sit above the stretched link */}
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-0.5 sm:opacity-60 sm:group-hover:opacity-100 sm:focus-within:opacity-100">
|
||||
{onPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPreview}
|
||||
className={cn(ACTION_CLASS, 'inline-flex')}
|
||||
aria-label={`Preview ${result.recordLabel}`}
|
||||
aria-pressed={active}
|
||||
title="Preview"
|
||||
data-qqq-id="search-result-preview"
|
||||
>
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={secondaryActionClass}
|
||||
aria-label={`Open ${result.recordLabel} in a new tab`}
|
||||
title="Open in new tab"
|
||||
data-qqq-id="search-result-new-tab"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyLink(path)}
|
||||
className={secondaryActionClass}
|
||||
aria-label={`Copy link to ${result.recordLabel}`}
|
||||
title="Copy link"
|
||||
data-qqq-id="search-result-copy-link"
|
||||
>
|
||||
<Link2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Tests for searchRecords (POST /search): request body compatibility, the extended response
|
||||
// (scores, highlights, details, per-table summaries, provider status) and invalid responses
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('./client', () => ({ default: { post: vi.fn() } }))
|
||||
|
||||
import apiClient from './client'
|
||||
import { RecordSearchResponseError, searchRecords, validMatches } from './record-search'
|
||||
|
||||
describe('searchRecords', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('posts only the term, table scope and limit by default (the original contract)', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [{ tableName: 'person', tableLabel: 'People', recordId: '1', recordLabel: 'Alice' }] })
|
||||
const response = await searchRecords('Alice', { tableNames: ['person'], limitPerTable: 5 })
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search', { searchTerm: 'Alice', tableNames: ['person'], limitPerTable: 5 })
|
||||
expect(response).toEqual({ results: [{ tableName: 'person', tableLabel: 'People', recordId: '1', recordLabel: 'Alice' }], tables: undefined, provider: undefined })
|
||||
})
|
||||
|
||||
it('sends only the term when no options are given', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [] })
|
||||
await expect(searchRecords('x')).resolves.toMatchObject({ results: [] })
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search', { searchTerm: 'x' })
|
||||
})
|
||||
|
||||
it('adds offset and RECENT order only when requested, and passes the abort signal', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [] })
|
||||
const controller = new AbortController()
|
||||
await searchRecords('x', { tableNames: ['person'], limitPerTable: 25, offset: 25, sort: 'recent', signal: controller.signal })
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search', { searchTerm: 'x', tableNames: ['person'], limitPerTable: 25, offset: 25, sort: 'RECENT' }, { signal: controller.signal })
|
||||
})
|
||||
|
||||
it('normalizes the extended response: scores, highlights (invalid ranges dropped), details, summaries, provider', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
results: [{
|
||||
tableName: 'person', tableLabel: 'People', recordId: 7, recordLabel: null, score: 4.5, updatedAt: '2026-10-01T12:00:00Z',
|
||||
highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'alice@acme.io', matches: [{ start: 6, end: 10 }, { start: 8, end: 9 }, { start: 11, end: 99 }] }],
|
||||
details: [{ fieldName: 'city', fieldLabel: null, displayValue: 'Austin' }],
|
||||
}],
|
||||
tables: [{ tableName: 'person', tableLabel: 'People', resultCount: 1, hasMore: true, totalCount: 40, source: 'FALLBACK' }],
|
||||
provider: { status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused' },
|
||||
})
|
||||
const response = await searchRecords('acme')
|
||||
expect(response.results[0]).toEqual({
|
||||
tableName: 'person', tableLabel: 'People', recordId: '7', recordLabel: 'People 7', score: 4.5, updatedAt: '2026-10-01T12:00:00Z',
|
||||
highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'alice@acme.io', matches: [{ start: 6, end: 10 }] }],
|
||||
details: [{ fieldName: 'city', fieldLabel: undefined, displayValue: 'Austin' }],
|
||||
})
|
||||
expect(response.tables).toEqual([{ tableName: 'person', tableLabel: 'People', resultCount: 1, hasMore: true, totalCount: 40, source: 'FALLBACK' }])
|
||||
expect(response.provider).toEqual({ status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused' })
|
||||
})
|
||||
|
||||
it('labels a result whose label was omitted, and skips (with a warning) one that cannot link, keeping the others', async () => {
|
||||
// the admin.kof22.com response for "maes": QQQ left recordLabel out of results whose label was ""
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [
|
||||
{ tableName: 'author', tableLabel: 'Authors', recordId: '2', recordLabel: 'jm' },
|
||||
{ tableName: 'page_section', tableLabel: 'Page Sections', recordId: '1' },
|
||||
{ tableName: 'page_section', recordId: 11, recordLabel: '' },
|
||||
{ tableName: 'person' },
|
||||
] })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const response = await searchRecords('maes')
|
||||
expect(response.results.map((result) => result.recordLabel)).toEqual(['jm', 'Page Sections 1', 'page_section 11'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped 1 malformed result'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it.each([[[]], [{}], ['<html>Dashboard</html>'], [{ results: 'nope' }]])('rejects an invalid response: %j', async (response) => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response)
|
||||
await expect(searchRecords('x')).rejects.toBeInstanceOf(RecordSearchResponseError)
|
||||
})
|
||||
|
||||
it('re-throws request errors, including 404 (callers only search when the capability is advertised)', async () => {
|
||||
const notFound = Object.assign(new Error('Not Found'), { isAxiosError: true, response: { status: 404 } })
|
||||
vi.mocked(apiClient.post).mockRejectedValue(notFound)
|
||||
await expect(searchRecords('x')).rejects.toBe(notFound)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validMatches', () => {
|
||||
it('keeps in-bounds, sorted, non-overlapping, non-empty ranges', () => {
|
||||
expect(validMatches('abcdef', [{ start: 0, end: 2 }, { start: 1, end: 3 }, { start: 3, end: 3 }, { start: 4, end: 6 }, { start: 5, end: 7 }])).toEqual([{ start: 0, end: 2 }, { start: 4, end: 6 }])
|
||||
expect(validMatches('abc', null)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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 record-search API — `POST /search` (QQQ v1 record search). Every backend returns
|
||||
* each match's table, id and label; backends with the extended contract also return a
|
||||
* relevance score, plain-text highlights with match ranges, secondary field values, the
|
||||
* record's modify date, per-table summaries (counts, more-available, source) and the
|
||||
* status of the search provider (so a UI can say when an index failed and it is showing
|
||||
* fallback matches). Every extension field is optional.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import apiClient from './client'
|
||||
|
||||
// The response schemas live here, not in ./schemas: only record search uses them, and
|
||||
// keeping them out of the shared module keeps them out of every screen's chunks.
|
||||
|
||||
/** A `[start, end)` character range of a match inside a highlight's text. */
|
||||
const RecordSearchMatchSchema = z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
|
||||
|
||||
/**
|
||||
* One highlighted field of a record search result: plain text (never HTML) plus the
|
||||
* ranges that matched the search term.
|
||||
*/
|
||||
export const RecordSearchHighlightSchema = z.object({
|
||||
fieldName: z.string(),
|
||||
fieldLabel: z.string().nullish(),
|
||||
text: z.string(),
|
||||
matches: z.array(RecordSearchMatchSchema).nullish(),
|
||||
})
|
||||
|
||||
/** A secondary (non-matching) field value shown under a result's label. */
|
||||
export const RecordSearchDetailSchema = z.object({
|
||||
fieldName: z.string(),
|
||||
fieldLabel: z.string().nullish(),
|
||||
displayValue: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for one record search result. `tableName` and `recordId` are required (a
|
||||
* result cannot link anywhere without them); `recordId` may arrive as a number. Labels may
|
||||
* be missing, null or empty: QQQ leaves empty values out of its JSON, so a record whose
|
||||
* label fields are empty arrives without `recordLabel`, and callers label it themselves.
|
||||
* The extension fields (score, highlights, details, updatedAt) are optional, and a
|
||||
* malformed one is dropped (`catch`) instead of failing the result.
|
||||
*/
|
||||
export const RecordSearchResultSchema = z.object({
|
||||
tableName: z.string().min(1),
|
||||
tableLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
recordId: z.union([z.string().min(1), z.number()]).transform(String),
|
||||
recordLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
score: z.number().nullish().catch(undefined),
|
||||
updatedAt: z.string().nullish().catch(undefined),
|
||||
highlights: z.array(RecordSearchHighlightSchema).nullish().catch(undefined),
|
||||
details: z.array(RecordSearchDetailSchema).nullish().catch(undefined),
|
||||
})
|
||||
|
||||
/** Per-table summary of a record search (newer backends). */
|
||||
export const RecordSearchTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
tableLabel: z.string().nullish(),
|
||||
resultCount: z.number().nullish(),
|
||||
hasMore: z.boolean().nullish(),
|
||||
totalCount: z.number().nullish(),
|
||||
source: z.string().nullish(),
|
||||
})
|
||||
|
||||
/** Status of the instance's record search provider (e.g. a search index), newer backends. */
|
||||
export const RecordSearchProviderStatusSchema = z.object({
|
||||
status: z.string(),
|
||||
failureClass: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for the record search response: `{ results: [...] }`, plus the optional
|
||||
* per-table summaries and provider status of newer backends. Each result is validated
|
||||
* separately ({@link RecordSearchResultSchema}), so one malformed result is skipped
|
||||
* rather than failing the whole search.
|
||||
*/
|
||||
export const RecordSearchResponseSchema = z.object({
|
||||
results: z.array(z.unknown()),
|
||||
tables: z.array(RecordSearchTableSchema).nullish().catch(undefined),
|
||||
provider: RecordSearchProviderStatusSchema.nullish().catch(undefined),
|
||||
})
|
||||
|
||||
/** A `[start, end)` character range of a match inside a highlight's text. */
|
||||
export interface RecordSearchMatch {
|
||||
/** Offset of the first matched character. */
|
||||
start: number
|
||||
/** Offset just past the last matched character. */
|
||||
end: number
|
||||
}
|
||||
|
||||
/** One highlighted field of a record search result (plain text, never HTML). */
|
||||
export interface RecordSearchHighlight {
|
||||
/** Backend field name. */
|
||||
fieldName: string
|
||||
/** Field label, when the backend sent one. */
|
||||
fieldLabel?: string
|
||||
/** Plain-text snippet of the field value. */
|
||||
text: string
|
||||
/** Matched ranges inside `text` (validated: in bounds, sorted, non-overlapping). */
|
||||
matches: RecordSearchMatch[]
|
||||
}
|
||||
|
||||
/** A secondary field value of a record search result. */
|
||||
export interface RecordSearchDetail {
|
||||
/** Backend field name. */
|
||||
fieldName: string
|
||||
/** Field label, when the backend sent one. */
|
||||
fieldLabel?: string
|
||||
/** Display value. */
|
||||
displayValue: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One record matched by record search. Only the table, id and label come from every
|
||||
* backend; the rest come from backends with the extended contract.
|
||||
*/
|
||||
export interface RecordSearchResult {
|
||||
/** Backend-registered name of the table containing this record. */
|
||||
tableName: string
|
||||
/** User-facing label of the table. */
|
||||
tableLabel?: string
|
||||
/** Primary key of the matching record, serialised as a string. */
|
||||
recordId: string
|
||||
/** User-facing label of the matching record (falls back to `{table} {id}`). */
|
||||
recordLabel: string
|
||||
/** Provider relevance score (higher is better); absent for the default search. */
|
||||
score?: number
|
||||
/** When the record was last modified (ISO-8601), when the table has a visible modify date. */
|
||||
updatedAt?: string
|
||||
/** Fields that matched, as plain-text snippets with match ranges. */
|
||||
highlights?: RecordSearchHighlight[]
|
||||
/** Secondary field values (not matched, not part of the label). */
|
||||
details?: RecordSearchDetail[]
|
||||
}
|
||||
|
||||
/** Where a table's results came from: the provider, the default search, or the default search after the provider failed. */
|
||||
export type RecordSearchSource = 'PROVIDER' | 'DEFAULT' | 'FALLBACK'
|
||||
|
||||
/** Per-table summary of a record search (extended contract). */
|
||||
export interface RecordSearchTableSummary {
|
||||
/** Backend table name. */
|
||||
tableName: string
|
||||
/** Table label. */
|
||||
tableLabel?: string
|
||||
/** Results returned for the table. */
|
||||
resultCount?: number
|
||||
/** Whether the table has more matches than were returned. */
|
||||
hasMore?: boolean
|
||||
/** Total matches, when known (approximate when it comes from a search index). */
|
||||
totalCount?: number
|
||||
/** Which search produced the table's results. */
|
||||
source?: string
|
||||
}
|
||||
|
||||
/** Status of the instance's record search provider (extended contract). */
|
||||
export interface RecordSearchProviderStatus {
|
||||
/** `OK`, `FAILED` (its tables fell back to the default search) or `NONE`. */
|
||||
status: string
|
||||
/** Simple class name of the provider's failure, e.g. `ConnectException`. */
|
||||
failureClass?: string
|
||||
/** Short failure message. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** A record search response. */
|
||||
export interface RecordSearchResponse {
|
||||
/** Matching records, grouped by table in backend order. */
|
||||
results: RecordSearchResult[]
|
||||
/** Per-table summaries (extended contract; absent from older backends). */
|
||||
tables?: RecordSearchTableSummary[]
|
||||
/** Provider status (extended contract; absent from older backends). */
|
||||
provider?: RecordSearchProviderStatus
|
||||
}
|
||||
|
||||
/** Result order requested from record search. */
|
||||
export type RecordSearchSort = 'relevance' | 'recent'
|
||||
|
||||
/** Options for {@link searchRecords}. */
|
||||
export interface RecordSearchOptions {
|
||||
/** Tables to search; when omitted the backend searches every searchable table the user may read. */
|
||||
tableNames?: string[]
|
||||
/** Maximum records per table (the backend caps it, at 25 in QQQ 4.1). */
|
||||
limitPerTable?: number
|
||||
/** Records to skip per table (paging one table; ignored by older backends). */
|
||||
offset?: number
|
||||
/** Requested order (ignored by older backends, which order by relevance or primary key). */
|
||||
sort?: RecordSearchSort
|
||||
/** Aborts the request (a newer search superseded it). */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** The record search response did not have the expected shape. */
|
||||
export class RecordSearchResponseError extends Error {
|
||||
/** Creates the error. */
|
||||
constructor() {
|
||||
super('Invalid record search response')
|
||||
this.name = 'RecordSearchResponseError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops match ranges that are out of bounds, empty, unsorted or overlapping.
|
||||
*
|
||||
* @param text - Highlight text.
|
||||
* @param matches - Ranges from the backend.
|
||||
* @returns The valid ranges, in order.
|
||||
*/
|
||||
export function validMatches(text: string, matches: RecordSearchMatch[] | null | undefined): RecordSearchMatch[] {
|
||||
const valid: RecordSearchMatch[] = []
|
||||
let floor = 0
|
||||
for (const match of matches ?? []) {
|
||||
if (match.start < floor || match.end <= match.start || match.end > text.length) continue
|
||||
valid.push({ start: match.start, end: match.end })
|
||||
floor = match.end
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Record search across tables: `POST /search` (QQQ v1). Call it only when the
|
||||
* metadata advertises searchable tables (`searchFields`); a backend without the
|
||||
* capability has no such endpoint, so every error is re-thrown.
|
||||
*
|
||||
* The backend matches the term against each table's declared search fields,
|
||||
* searches only the tables the session may read, and applies record security locks.
|
||||
* `offset` and `sort` are sent only when they differ from the defaults, so the
|
||||
* request body stays exactly what older backends expect.
|
||||
*
|
||||
* @param searchTerm - Free-text search term (the backend trims it).
|
||||
* @param options - Table scope, per-table limit, paging, order and abort signal.
|
||||
* A result without a record label is labeled "{table label} {record id}"; a result that
|
||||
* cannot link to a record (no table name or record id) is skipped with a console warning.
|
||||
*
|
||||
* @returns Matching records (grouped by table in backend order), plus per-table summaries and provider status when the backend sends them.
|
||||
* @throws When the request fails (an `AxiosError`, or a cancel when aborted) or the response is not a record search response ({@link RecordSearchResponseError}).
|
||||
*/
|
||||
export async function searchRecords(searchTerm: string, options: RecordSearchOptions = {}): Promise<RecordSearchResponse> {
|
||||
const body = {
|
||||
searchTerm,
|
||||
...(options.tableNames ? { tableNames: options.tableNames } : {}),
|
||||
...(options.limitPerTable ? { limitPerTable: options.limitPerTable } : {}),
|
||||
...(options.offset ? { offset: options.offset } : {}),
|
||||
...(options.sort === 'recent' ? { sort: 'RECENT' } : {}),
|
||||
}
|
||||
const raw = options.signal
|
||||
? await apiClient.post<unknown>('/search', body, { signal: options.signal })
|
||||
: await apiClient.post<unknown>('/search', body)
|
||||
const parsed = RecordSearchResponseSchema.safeParse(raw)
|
||||
if (!parsed.success) throw new RecordSearchResponseError()
|
||||
const { tables, provider } = parsed.data
|
||||
const results = parsed.data.results.flatMap((raw) => {
|
||||
const result = RecordSearchResultSchema.safeParse(raw)
|
||||
return result.success ? [result.data] : []
|
||||
})
|
||||
const skipped = parsed.data.results.length - results.length
|
||||
if (skipped > 0) console.warn(`Record search: skipped ${skipped} malformed result(s) without a table name or record id`)
|
||||
return {
|
||||
results: results.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
tableLabel: item.tableLabel ?? undefined,
|
||||
recordId: item.recordId,
|
||||
recordLabel: item.recordLabel?.trim() || `${item.tableLabel ?? item.tableName} ${item.recordId}`,
|
||||
score: item.score ?? undefined,
|
||||
updatedAt: item.updatedAt ?? undefined,
|
||||
highlights: item.highlights?.map((highlight) => ({
|
||||
fieldName: highlight.fieldName,
|
||||
fieldLabel: highlight.fieldLabel ?? undefined,
|
||||
text: highlight.text,
|
||||
matches: validMatches(highlight.text, highlight.matches),
|
||||
})),
|
||||
details: item.details?.map((detail) => ({ fieldName: detail.fieldName, fieldLabel: detail.fieldLabel ?? undefined, displayValue: detail.displayValue })),
|
||||
})),
|
||||
tables: tables?.map((table) => ({
|
||||
tableName: table.tableName,
|
||||
tableLabel: table.tableLabel ?? undefined,
|
||||
resultCount: table.resultCount ?? undefined,
|
||||
hasMore: table.hasMore ?? undefined,
|
||||
totalCount: table.totalCount ?? undefined,
|
||||
source: table.source ?? undefined,
|
||||
})),
|
||||
provider: provider ? { status: provider.status, failureClass: provider.failureClass ?? undefined, message: provider.message ?? undefined } : undefined,
|
||||
}
|
||||
}
|
||||
+22
-15
@@ -27,10 +27,9 @@ import {
|
||||
QRecordSchema,
|
||||
QueryRecordsResponseSchema,
|
||||
CountRecordsResponseSchema,
|
||||
RecordSearchResultSchema,
|
||||
RecordSearchResponseSchema,
|
||||
QInstanceMinimalSchema,
|
||||
} from './schemas'
|
||||
import { RecordSearchResultSchema, RecordSearchResponseSchema } from './record-search'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QRecordSchema
|
||||
@@ -197,27 +196,35 @@ describe('RecordSearchResultSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a missing, null or empty recordLabel or tableLabel (QQQ omits empty values)', () => {
|
||||
for (const entry of [
|
||||
{ tableName: 'page_section', recordId: '1' },
|
||||
{ tableName: 'page_section', tableLabel: null, recordId: '1', recordLabel: null },
|
||||
{ tableName: 'page_section', tableLabel: '', recordId: '1', recordLabel: '' },
|
||||
]) {
|
||||
const result = RecordSearchResultSchema.safeParse(entry)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toEqual({ tableName: 'page_section', tableLabel: undefined, recordId: '1', recordLabel: undefined })
|
||||
}
|
||||
it('accepts a missing or null recordLabel (the client falls back to the table and id)', () => {
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: '123' }).success).toBe(true)
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: '123', recordLabel: null, tableLabel: null }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a numeric recordId as a string', () => {
|
||||
const result = RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: 7, recordLabel: 'Zed' })
|
||||
expect(result.data?.recordId).toBe('7')
|
||||
const result = RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: 7, recordLabel: 'x' })
|
||||
expect(result.success && result.data.recordId).toBe('7')
|
||||
})
|
||||
|
||||
it('fails when tableName or recordId is empty', () => {
|
||||
it('fails without a table name or record id', () => {
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: 'person', recordLabel: 'x' }).success).toBe(false)
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: '', recordId: '1' }).success).toBe(false)
|
||||
expect(RecordSearchResultSchema.safeParse({ tableName: 'person', recordId: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('parses the extended fields and drops a malformed one instead of failing', () => {
|
||||
const result = RecordSearchResultSchema.safeParse({
|
||||
tableName: 'person', recordId: '1', recordLabel: 'Alice',
|
||||
score: 3.2, updatedAt: '2026-10-01T00:00:00Z',
|
||||
highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'alice@x.io', matches: [{ start: 0, end: 5 }] }],
|
||||
details: 'not-a-list',
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(result.data.score).toBe(3.2)
|
||||
expect(result.data.highlights?.[0].matches).toEqual([{ start: 0, end: 5 }])
|
||||
expect(result.data.details).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -93,31 +93,6 @@ export const CountRecordsResponseSchema = z.object({
|
||||
distinctCount: z.number().optional(),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record search — POST /search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod schema for one record search result. `tableName` and `recordId` are
|
||||
* required (a result cannot link anywhere without them); `recordId` may arrive
|
||||
* as a number. `tableLabel` and `recordLabel` may be missing or null: QQQ leaves
|
||||
* empty and null values out of its JSON, so a record whose label fields are empty
|
||||
* arrives without `recordLabel`, and callers label it themselves.
|
||||
*/
|
||||
export const RecordSearchResultSchema = z.object({
|
||||
tableName: z.string().min(1),
|
||||
tableLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
recordId: z.union([z.string().min(1), z.number()]).transform(String),
|
||||
recordLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for the record search response envelope: `{ results: [...] }`. Each
|
||||
* result is validated separately ({@link RecordSearchResultSchema}), so one
|
||||
* malformed result is skipped rather than failing the whole search.
|
||||
*/
|
||||
export const RecordSearchResponseSchema = z.object({ results: z.array(z.unknown()) })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QInstance — GET /metaData (minimal required-field validation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -266,72 +266,6 @@ describe('Tables API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchRecords', () => {
|
||||
it('posts the term, table scope and limit to /search and returns the results', async () => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
const results = [{ tableName: 'person', tableLabel: 'People', recordId: '1', recordLabel: 'Alice' }]
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results })
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('Alice', { tableNames: ['person'], limitPerTable: 5 })).resolves.toEqual(results)
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search', { searchTerm: 'Alice', tableNames: ['person'], limitPerTable: 5 })
|
||||
})
|
||||
|
||||
it('sends only the term when no options are given', async () => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [] })
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('x')).resolves.toEqual([])
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/search', { searchTerm: 'x' })
|
||||
})
|
||||
|
||||
it('labels a result whose recordLabel was omitted (empty label fields) instead of failing the search', async () => {
|
||||
// the prod admin.kof22.com response for "maes": QQQ left recordLabel out of results whose label was ""
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [
|
||||
{ tableName: 'author', tableLabel: 'Authors', recordId: '2', recordLabel: 'jm' },
|
||||
{ tableName: 'page_section', tableLabel: 'Page Sections', recordId: '1' },
|
||||
{ tableName: 'page_section', recordId: 11, recordLabel: null },
|
||||
] })
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('maes')).resolves.toEqual([
|
||||
{ tableName: 'author', tableLabel: 'Authors', recordId: '2', recordLabel: 'jm' },
|
||||
{ tableName: 'page_section', tableLabel: 'Page Sections', recordId: '1', recordLabel: 'Page Sections 1' },
|
||||
{ tableName: 'page_section', recordId: '11', recordLabel: 'page_section 11' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips (and warns about) a result that cannot link to a record, keeping the others', async () => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ results: [{ tableName: 'person' }, { tableName: 'person', recordId: '1', recordLabel: 'Alice' }] })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('x')).resolves.toEqual([{ tableName: 'person', recordId: '1', recordLabel: 'Alice' }])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped 1 malformed result'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it.each([[[]], [{}], ['<html>Dashboard</html>'], [{ results: 'nope' }]])('rejects an invalid response: %j', async (response) => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response)
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('x')).rejects.toThrow('Invalid record search response')
|
||||
})
|
||||
|
||||
it('re-throws request errors, including 404 (callers only search when the capability is advertised)', async () => {
|
||||
const { default: apiClient } = await import('./client')
|
||||
const notFound = Object.assign(new Error('Not Found'), { isAxiosError: true, response: { status: 404 } })
|
||||
vi.mocked(apiClient.post).mockRejectedValue(notFound)
|
||||
|
||||
const { searchRecords } = await import('./tables')
|
||||
await expect(searchRecords('x')).rejects.toThrow('Not Found')
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
QRecordSchema,
|
||||
QueryRecordsResponseSchema,
|
||||
CountRecordsResponseSchema,
|
||||
RecordSearchResponseSchema,
|
||||
RecordSearchResultSchema,
|
||||
} from './schemas'
|
||||
|
||||
/**
|
||||
@@ -364,75 +362,6 @@ export async function deleteRecord(
|
||||
return { deletedCount: parsed.data.deletedRecordCount }
|
||||
}
|
||||
|
||||
/**
|
||||
* One record matched by record search (`POST /search`).
|
||||
*/
|
||||
export interface RecordSearchResult {
|
||||
/** Backend-registered name of the table containing this record. */
|
||||
tableName: string
|
||||
/** User-facing label of the table. */
|
||||
tableLabel?: string
|
||||
/** Primary key of the matching record, serialised as a string. */
|
||||
recordId: string
|
||||
/** User-facing label of the matching record. */
|
||||
recordLabel: string
|
||||
}
|
||||
|
||||
/** Options for {@link searchRecords}. */
|
||||
export interface RecordSearchOptions {
|
||||
/** Tables to search; when omitted the backend searches every searchable table the user may read. */
|
||||
tableNames?: string[]
|
||||
/** Maximum records per table (the backend caps it). */
|
||||
limitPerTable?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Record search across tables: `POST /search` (QQQ v1). Call it only when the
|
||||
* metadata advertises searchable tables (`searchFields`); a backend without the
|
||||
* capability has no such endpoint, so every error is re-thrown.
|
||||
*
|
||||
* The backend matches the term against each table's declared search fields,
|
||||
* searches only the tables the session may read, and applies record security locks.
|
||||
*
|
||||
* @param searchTerm - Free-text search term (the backend trims it).
|
||||
* @param options - Table scope and per-table limit.
|
||||
* A result without a record label (its label fields are empty) is labeled
|
||||
* "{table label} {record id}"; a result that cannot link to a record (no table
|
||||
* name or record id) is skipped with a console warning, so the other results
|
||||
* still show.
|
||||
*
|
||||
* @returns Matching records, grouped by table in backend order.
|
||||
* @throws When the request fails or the response is not a record search response.
|
||||
*/
|
||||
export async function searchRecords(searchTerm: string, options: RecordSearchOptions = {}): Promise<RecordSearchResult[]> {
|
||||
const result = await apiClient.post<unknown>('/search', {
|
||||
searchTerm,
|
||||
...(options.tableNames ? { tableNames: options.tableNames } : {}),
|
||||
...(options.limitPerTable ? { limitPerTable: options.limitPerTable } : {}),
|
||||
})
|
||||
const parsed = RecordSearchResponseSchema.safeParse(result)
|
||||
if (!parsed.success) throw new Error('Invalid record search response')
|
||||
|
||||
const results: RecordSearchResult[] = []
|
||||
let skipped = 0
|
||||
for (const entry of parsed.data.results) {
|
||||
const item = RecordSearchResultSchema.safeParse(entry)
|
||||
if (!item.success) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const { tableName, tableLabel, recordId, recordLabel } = item.data
|
||||
results.push({
|
||||
tableName,
|
||||
...(tableLabel ? { tableLabel } : {}),
|
||||
recordId,
|
||||
recordLabel: recordLabel?.trim() ? recordLabel : `${tableLabel ?? tableName} ${recordId}`,
|
||||
})
|
||||
}
|
||||
if (skipped > 0) console.warn(`Record search skipped ${skipped} malformed result(s) without a table name or record id.`)
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve binary values without guessing field types in the recursive wire tree.
|
||||
* @param groups - Exact named child records prepared by the form.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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 useQuickSearch — state and keyboard behaviour of a search-as-you-type combobox
|
||||
* (the header search and the `/` dialog): the term, record search, grouped options, the
|
||||
* active option, recent searches and recently viewed records.
|
||||
*
|
||||
* Keyboard: ↓/↑ move the active option, Enter opens it (Ctrl/⌘+Enter in a new tab), and
|
||||
* with a term the first option is active until the user moves, so Enter opens the top hit.
|
||||
* Without a term nothing is active, so Enter does nothing until the user picks.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import { useRecordSearch, type RecordSearchState } from '@/lib/hooks/use-record-search'
|
||||
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { addRecentSearch, clearRecentSearches, getRecentSearches } from '@/lib/search/recent-searches'
|
||||
import { buildQuickSearchGroups, flattenOptions, type QuickSearchGroup, type QuickSearchOption } from '@/lib/search/search-model'
|
||||
import { openInNewTab, searchPagePath } from '@/lib/search/search-url'
|
||||
|
||||
/** Records requested per table by the quick search. */
|
||||
export const QUICK_SEARCH_RECORDS_PER_TABLE = 6
|
||||
|
||||
/** Options of {@link useQuickSearch}. */
|
||||
export interface UseQuickSearchOptions {
|
||||
/** Whether the search is open (record search and recents run only while open). */
|
||||
open: boolean
|
||||
/** Search text (owned by the caller's input). */
|
||||
term: string
|
||||
/** Sets the search text. */
|
||||
setTerm: (term: string) => void
|
||||
/** Navigable pages. */
|
||||
navTargets: NavTarget[]
|
||||
/** Searchable tables (empty: record search unavailable, never called). */
|
||||
searchTables: SearchableTable[]
|
||||
/** Navigates in this tab (the caller also closes the search; the term is cleared after). */
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
/** State and handlers returned by {@link useQuickSearch}. */
|
||||
export interface QuickSearchController {
|
||||
/** Search text. */
|
||||
term: string
|
||||
/** Sets the search text. */
|
||||
setTerm: (term: string) => void
|
||||
/** Record search status. */
|
||||
recordSearch: RecordSearchState
|
||||
/** Groups to render. */
|
||||
groups: QuickSearchGroup[]
|
||||
/** Every option, in keyboard order. */
|
||||
options: QuickSearchOption[]
|
||||
/** Id of the active option, or `null`. */
|
||||
activeId: string | null
|
||||
/** Makes an option active (pointer hover). */
|
||||
setActiveId: (id: string | null) => void
|
||||
/** Opens or applies an option. */
|
||||
choose: (option: QuickSearchOption, newTab?: boolean) => void
|
||||
/** Opens the search page for the current term. */
|
||||
openSearchPage: () => void
|
||||
/** Clears the recent search terms. */
|
||||
clearRecent: () => void
|
||||
/** Keyboard handler for the combobox input; returns nothing, handles ↑↓ and Enter. */
|
||||
onInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-as-you-type state for a combobox over pages, records and recents.
|
||||
*
|
||||
* @param options - Open state, candidates and navigation.
|
||||
* @returns The controller.
|
||||
*/
|
||||
export function useQuickSearch({ open, term, setTerm: setTermProp, navTargets, searchTables, onNavigate }: UseQuickSearchOptions): QuickSearchController {
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
const [activeId, setActiveIdState] = useState<string | null>(null)
|
||||
// whether the user moved the selection since the options last changed
|
||||
const userMovedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setRecentRecords(getRecentRecords().slice(0, 10))
|
||||
setRecentSearches(getRecentSearches())
|
||||
}, [open])
|
||||
|
||||
const recordSearch = useRecordSearch(open ? term : '', searchTables, QUICK_SEARCH_RECORDS_PER_TABLE)
|
||||
const groups = useMemo(() => buildQuickSearchGroups({
|
||||
term,
|
||||
navTargets,
|
||||
recentRecords,
|
||||
recentSearches,
|
||||
results: recordSearch.results,
|
||||
summaries: recordSearch.summaries,
|
||||
tables: searchTables,
|
||||
limitPerTable: QUICK_SEARCH_RECORDS_PER_TABLE,
|
||||
}), [term, navTargets, recentRecords, recentSearches, recordSearch.results, recordSearch.summaries, searchTables])
|
||||
const options = useMemo(() => flattenOptions(groups), [groups])
|
||||
const hasTerm = term.trim().length > 0
|
||||
|
||||
// a new term discards a choice made for the previous one (runs before the effect below)
|
||||
const previousTermRef = useRef(term)
|
||||
useEffect(() => {
|
||||
if (previousTermRef.current === term) return
|
||||
previousTermRef.current = term
|
||||
userMovedRef.current = false
|
||||
}, [term])
|
||||
|
||||
// keep the user's choice while it is listed, including "nothing selected" (ArrowUp above the
|
||||
// first option, so Enter opens the results page even when records arrive afterwards); else
|
||||
// the first (with a term). While the records belong to the previous term, preselect only a
|
||||
// current entry (a page or "search everything"), so Enter never opens a stale record.
|
||||
const stale = recordSearch.isStale
|
||||
useEffect(() => {
|
||||
setActiveIdState((current) => {
|
||||
if (userMovedRef.current && (current === null || options.some((option) => option.id === current))) return current
|
||||
userMovedRef.current = false
|
||||
if (!hasTerm) return null
|
||||
// never preselect a completion (Enter opens a result), nor a stale record
|
||||
const first = options.find((option) => option.kind !== 'query' && (!stale || (option.kind !== 'record' && option.kind !== 'more')))
|
||||
return first?.id ?? null
|
||||
})
|
||||
}, [options, hasTerm, stale])
|
||||
|
||||
const setTerm = useCallback((next: string) => {
|
||||
userMovedRef.current = false
|
||||
setTermProp(next)
|
||||
}, [setTermProp])
|
||||
|
||||
const setActiveId = useCallback((id: string | null) => {
|
||||
userMovedRef.current = true
|
||||
setActiveIdState(id)
|
||||
}, [])
|
||||
|
||||
const choose = useCallback((option: QuickSearchOption, newTab = false) => {
|
||||
if (option.kind === 'query') {
|
||||
setTerm(option.term)
|
||||
return
|
||||
}
|
||||
if (hasTerm) setRecentSearches(addRecentSearch(term))
|
||||
if (newTab) {
|
||||
openInNewTab(option.path)
|
||||
return
|
||||
}
|
||||
onNavigate(option.path)
|
||||
setTerm('')
|
||||
}, [hasTerm, term, onNavigate, setTerm])
|
||||
|
||||
const openSearchPage = useCallback(() => {
|
||||
if (!hasTerm) return
|
||||
setRecentSearches(addRecentSearch(term))
|
||||
onNavigate(searchPagePath({ q: term }))
|
||||
setTerm('')
|
||||
}, [hasTerm, term, onNavigate, setTerm])
|
||||
|
||||
const clearRecent = useCallback(() => {
|
||||
clearRecentSearches()
|
||||
setRecentSearches([])
|
||||
}, [])
|
||||
|
||||
const onInputKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.nativeEvent.isComposing) return
|
||||
const index = activeId ? options.findIndex((option) => option.id === activeId) : -1
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (options.length > 0) setActiveId(options[Math.min(index + 1, options.length - 1)].id)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
setActiveId(index > 0 ? options[index - 1].id : null)
|
||||
break
|
||||
case 'Enter': {
|
||||
event.preventDefault()
|
||||
// Shift+Enter always opens the results page
|
||||
if (event.shiftKey) {
|
||||
openSearchPage()
|
||||
break
|
||||
}
|
||||
const option = index >= 0 ? options[index] : undefined
|
||||
if (option) choose(option, event.metaKey || event.ctrlKey)
|
||||
else openSearchPage()
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [activeId, options, setActiveId, choose, openSearchPage])
|
||||
|
||||
return { term, setTerm, recordSearch, groups, options, activeId, setActiveId, choose, openSearchPage, clearRecent, onInputKeyDown }
|
||||
}
|
||||
@@ -15,75 +15,115 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file useRecordSearch — debounced record search (`POST /search`) over the
|
||||
* tables the metadata marks as searchable. Makes no request when no table is
|
||||
* searchable (the backend lacks the capability) or the term is too short.
|
||||
* @file useRecordSearch — debounced, cancellable record search (`POST /search`) over the
|
||||
* tables the metadata marks as searchable. Makes no request when no table is searchable
|
||||
* (the backend lacks the capability) or the term is too short. A superseded request is
|
||||
* aborted; the previous term's results stay on screen (flagged stale) until the new ones
|
||||
* arrive, so the list does not flash empty while typing.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { searchRecords } from '@/lib/api/tables'
|
||||
import type { RecordSearchResult } from '@/lib/api/tables'
|
||||
import { searchRecords } from '@/lib/api/record-search'
|
||||
import type { RecordSearchResult, RecordSearchSort, RecordSearchTableSummary } from '@/lib/api/record-search'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { isRecordSearchTerm } from '@/lib/utils/record-search'
|
||||
import type { SearchableTable } from '@/lib/utils/record-search'
|
||||
import { describeProviderFallback, describeSearchFailure, type SearchFailure, type SearchFallback } from '@/lib/search/search-failure'
|
||||
|
||||
/** Delay between the last keystroke and the search request. */
|
||||
export const RECORD_SEARCH_DEBOUNCE_MS = 250
|
||||
export const RECORD_SEARCH_DEBOUNCE_MS = 200
|
||||
|
||||
/** Stable empty result list (consumers memoize on it). */
|
||||
const NO_RESULTS: RecordSearchResult[] = []
|
||||
|
||||
/** Optional request settings of {@link useRecordSearch}. */
|
||||
export interface UseRecordSearchOptions {
|
||||
/** Records to skip per table (paging one table). */
|
||||
offset?: number
|
||||
/** Requested order. */
|
||||
sort?: RecordSearchSort
|
||||
/** Delay after the last change before searching (default {@link RECORD_SEARCH_DEBOUNCE_MS}). */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/** State of a record search. */
|
||||
export interface RecordSearchState {
|
||||
/** Whether record search is available (at least one searchable table). */
|
||||
available: boolean
|
||||
/** Results for the current (debounced) term; empty while not searching. */
|
||||
/** Results for the current term (or, while `isStale`, for the previous one). */
|
||||
results: RecordSearchResult[]
|
||||
/** Per-table summaries, when the backend sends them. */
|
||||
summaries?: RecordSearchTableSummary[]
|
||||
/** Set when the search provider (e.g. an index) failed and the results are the default search's. */
|
||||
fallback?: SearchFallback
|
||||
/** Whether a search for the current input is pending (debouncing or in flight). */
|
||||
isSearching: boolean
|
||||
/** Whether `results` belong to an earlier term (a newer search is pending). */
|
||||
isStale: boolean
|
||||
/** Whether the latest search failed. */
|
||||
isError: boolean
|
||||
/** Why it failed, when it did. */
|
||||
failure?: SearchFailure
|
||||
/** Runs the search again. */
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches records across the given tables as the user types.
|
||||
*
|
||||
* @param term - Current search text.
|
||||
* @param tables - Searchable tables (from {@link searchableTables}); empty disables search.
|
||||
* @param tables - Searchable tables (from `searchableTables`); empty disables search.
|
||||
* @param limitPerTable - Maximum results per table.
|
||||
* @param options - Offset, order and debounce.
|
||||
* @returns Search availability, results and status.
|
||||
*/
|
||||
export function useRecordSearch(term: string, tables: SearchableTable[], limitPerTable: number): RecordSearchState {
|
||||
export function useRecordSearch(term: string, tables: SearchableTable[], limitPerTable: number, options: UseRecordSearchOptions = {}): RecordSearchState {
|
||||
const { offset = 0, sort = 'relevance', debounceMs = RECORD_SEARCH_DEBOUNCE_MS } = options
|
||||
const trimmed = term.trim()
|
||||
const [debounced, setDebounced] = useState(trimmed)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(trimmed), RECORD_SEARCH_DEBOUNCE_MS)
|
||||
if (debounced === trimmed) return
|
||||
const timer = setTimeout(() => setDebounced(trimmed), debounceMs)
|
||||
return () => clearTimeout(timer)
|
||||
}, [trimmed])
|
||||
}, [trimmed, debounced, debounceMs])
|
||||
|
||||
const tableNames = useMemo(() => tables.map((table) => table.name), [tables])
|
||||
const available = tableNames.length > 0
|
||||
const searchable = available && isRecordSearchTerm(trimmed)
|
||||
const enabled = available && isRecordSearchTerm(debounced)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.recordSearch(debounced, tableNames, limitPerTable),
|
||||
queryFn: () => searchRecords(debounced, { tableNames, limitPerTable }),
|
||||
queryKey: queryKeys.recordSearch(debounced, tableNames, limitPerTable, offset, sort),
|
||||
// the signal aborts a request whose term was superseded
|
||||
queryFn: ({ signal }) => searchRecords(debounced, { tableNames, limitPerTable, offset, sort, signal }),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const current = enabled && debounced === trimmed
|
||||
const current = searchable && debounced === trimmed
|
||||
const data = searchable ? query.data : undefined
|
||||
const isError = current && query.isError && !query.isFetching
|
||||
const failure = useMemo(() => (isError ? describeSearchFailure(query.error) : undefined), [isError, query.error])
|
||||
const fallback = useMemo(() => describeProviderFallback(data?.provider), [data?.provider])
|
||||
const { refetch } = query
|
||||
|
||||
return {
|
||||
available,
|
||||
// an empty answer keeps the same identity, so a keyboard selection made meanwhile survives it
|
||||
results: current && query.data && query.data.length > 0 ? query.data : NO_RESULTS,
|
||||
isSearching: available && isRecordSearchTerm(trimmed) && (debounced !== trimmed || query.isFetching),
|
||||
isError: current && query.isError,
|
||||
// an unchanged answer keeps the same identity, so a keyboard selection made meanwhile survives it
|
||||
results: data && data.results.length > 0 && !isError ? data.results : NO_RESULTS,
|
||||
summaries: isError ? undefined : data?.tables,
|
||||
fallback: isError ? undefined : fallback,
|
||||
isSearching: searchable && (debounced !== trimmed || query.isFetching),
|
||||
isStale: searchable && (debounced !== trimmed || query.isPlaceholderData),
|
||||
isError,
|
||||
failure,
|
||||
retry: () => void refetch(),
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,9 @@ describe('queryKeys factory', () => {
|
||||
expect(queryKeys.audits('person', 1)).toEqual(['qqq', 'records', 'person', '1', 'audits'])
|
||||
})
|
||||
|
||||
it('recordSearch includes the term, tables and limit', () => {
|
||||
expect(queryKeys.recordSearch('Alice', ['person'], 5)).toEqual(['qqq', 'search', 'records', 'Alice', ['person'], 5])
|
||||
it('recordSearch includes the term, tables, limit, offset and order', () => {
|
||||
expect(queryKeys.recordSearch('Alice', ['person'], 5)).toEqual(['qqq', 'search', 'records', 'Alice', ['person'], 5, 0, 'relevance'])
|
||||
expect(queryKeys.recordSearch('Alice', ['person'], 25, 50, 'recent')).toEqual(['qqq', 'search', 'records', 'Alice', ['person'], 25, 50, 'recent'])
|
||||
})
|
||||
|
||||
it('keys are distinct across different entities', () => {
|
||||
|
||||
@@ -548,8 +548,10 @@ export const queryKeys = {
|
||||
* @param searchTerm - The trimmed search term.
|
||||
* @param tableNames - The searched tables.
|
||||
* @param limitPerTable - Maximum records per table.
|
||||
* @param offset - Records skipped per table.
|
||||
* @param sort - Requested order.
|
||||
* @returns The record search query key tuple.
|
||||
*/
|
||||
recordSearch: (searchTerm: string, tableNames: string[], limitPerTable: number) =>
|
||||
[...queryKeys.search(), 'records', searchTerm, tableNames, limitPerTable] as const,
|
||||
recordSearch: (searchTerm: string, tableNames: string[], limitPerTable: number, offset = 0, sort = 'relevance') =>
|
||||
[...queryKeys.search(), 'records', searchTerm, tableNames, limitPerTable, offset, sort] as const,
|
||||
}
|
||||
@@ -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 highlight — splits text into matched and unmatched segments, from backend match
|
||||
* ranges or by finding the search term's words locally. Pure string work: the result is
|
||||
* rendered as text nodes and `<mark>` elements, never as HTML.
|
||||
*/
|
||||
|
||||
import type { RecordSearchMatch } from '@/lib/api/record-search'
|
||||
|
||||
/** A run of text that did or did not match the search. */
|
||||
export interface TextSegment {
|
||||
/** The text. */
|
||||
text: string
|
||||
/** Whether it matched. */
|
||||
match: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct, lowercased words of a search term, longest first (so a longer word wins
|
||||
* where two overlap).
|
||||
*
|
||||
* @param term - Search text.
|
||||
* @returns Words to highlight.
|
||||
*/
|
||||
export function termTokens(term: string): string[] {
|
||||
const words = term.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
return [...new Set(words)].sort((a, b) => b.length - a.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every occurrence of the term's words in a text (case-insensitive), merging
|
||||
* overlapping and adjacent occurrences.
|
||||
*
|
||||
* @param text - Text to search.
|
||||
* @param term - Search text.
|
||||
* @returns Sorted, non-overlapping match ranges.
|
||||
*/
|
||||
export function findMatches(text: string, term: string): RecordSearchMatch[] {
|
||||
const haystack = text.toLowerCase()
|
||||
const found: RecordSearchMatch[] = []
|
||||
for (const token of termTokens(term)) {
|
||||
for (let at = haystack.indexOf(token); at >= 0; at = haystack.indexOf(token, at + token.length)) {
|
||||
found.push({ start: at, end: at + token.length })
|
||||
}
|
||||
}
|
||||
found.sort((a, b) => a.start - b.start)
|
||||
const merged: RecordSearchMatch[] = []
|
||||
for (const match of found) {
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && match.start <= last.end) last.end = Math.max(last.end, match.end)
|
||||
else merged.push({ ...match })
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into segments at the given match ranges. Ranges are assumed valid
|
||||
* (in bounds, sorted, non-overlapping; see `validMatches`).
|
||||
*
|
||||
* @param text - Text to split.
|
||||
* @param matches - Match ranges.
|
||||
* @returns Alternating unmatched and matched segments (empty segments omitted).
|
||||
*/
|
||||
export function segmentText(text: string, matches: RecordSearchMatch[]): TextSegment[] {
|
||||
const segments: TextSegment[] = []
|
||||
let at = 0
|
||||
for (const match of matches) {
|
||||
if (match.start > at) segments.push({ text: text.slice(at, match.start), match: false })
|
||||
segments.push({ text: text.slice(match.start, match.end), match: true })
|
||||
at = match.end
|
||||
}
|
||||
if (at < text.length) segments.push({ text: text.slice(at), match: false })
|
||||
return segments
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 recent-searches — the user's recent search terms, newest first, in localStorage.
|
||||
* Storage may be unavailable (private windows, blocked site data): every access is guarded
|
||||
* and search works the same without it.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'qqq-recent-searches'
|
||||
|
||||
/** Most terms kept. */
|
||||
export const MAX_RECENT_SEARCHES = 8
|
||||
|
||||
/**
|
||||
* Reads the recent search terms.
|
||||
*
|
||||
* @returns Terms, newest first (empty when storage is unavailable or corrupt).
|
||||
*/
|
||||
export function getRecentSearches(): string[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
|
||||
return Array.isArray(parsed) ? parsed.filter((term): term is string => typeof term === 'string' && term.trim() !== '').slice(0, MAX_RECENT_SEARCHES) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the recent search terms.
|
||||
*
|
||||
* @param terms - Terms, newest first.
|
||||
*/
|
||||
function write(terms: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(terms.slice(0, MAX_RECENT_SEARCHES)))
|
||||
} catch {
|
||||
// storage unavailable: recent searches are a convenience
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a search term as the most recent (case-insensitive duplicates move to the top).
|
||||
*
|
||||
* @param term - Search text; blank terms are ignored.
|
||||
* @returns The updated terms.
|
||||
*/
|
||||
export function addRecentSearch(term: string): string[] {
|
||||
const trimmed = term.trim()
|
||||
if (!trimmed) return getRecentSearches()
|
||||
const terms = [trimmed, ...getRecentSearches().filter((existing) => existing.toLowerCase() !== trimmed.toLowerCase())]
|
||||
write(terms)
|
||||
return terms.slice(0, MAX_RECENT_SEARCHES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one recent search term.
|
||||
*
|
||||
* @param term - Term to remove.
|
||||
* @returns The updated terms.
|
||||
*/
|
||||
export function removeRecentSearch(term: string): string[] {
|
||||
const terms = getRecentSearches().filter((existing) => existing !== term)
|
||||
write(terms)
|
||||
return terms
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every recent search term.
|
||||
*/
|
||||
export function clearRecentSearches(): void {
|
||||
write([])
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 search-failure — turns a failed record search, or a provider that fell back to the
|
||||
* default search, into an honest, specific message: what failed (the failure class), the
|
||||
* backend's own explanation, and whether retrying can help.
|
||||
*/
|
||||
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { RecordSearchResponseError, type RecordSearchProviderStatus } from '@/lib/api/record-search'
|
||||
|
||||
/** Class of a record search failure. */
|
||||
export type SearchFailureKind =
|
||||
| 'offline'
|
||||
| 'timeout'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'unsupported'
|
||||
| 'bad-request'
|
||||
| 'server'
|
||||
| 'invalid-response'
|
||||
| 'unknown'
|
||||
|
||||
/** A record search failure, described for the user. */
|
||||
export interface SearchFailure {
|
||||
/** Failure class. */
|
||||
kind: SearchFailureKind
|
||||
/** Short headline, e.g. "Record search failed: server error (HTTP 500)". */
|
||||
title: string
|
||||
/** The backend's or browser's own explanation, when there is one. */
|
||||
detail?: string
|
||||
/** HTTP status, when the server answered. */
|
||||
status?: number
|
||||
/** Whether trying again may help. */
|
||||
retryable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortens a message to one line of at most 200 characters.
|
||||
*
|
||||
* @param message - Raw message.
|
||||
* @returns The first line, truncated, or `undefined` when blank.
|
||||
*/
|
||||
function oneLine(message: string | undefined): string | undefined {
|
||||
const line = message?.split('\n')[0]?.trim()
|
||||
if (!line) return undefined
|
||||
return line.length > 200 ? `${line.slice(0, 199)}…` : line
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes why a record search request failed.
|
||||
*
|
||||
* @param error - The rejected value of the search request.
|
||||
* @returns The failure class and a message for the user.
|
||||
*/
|
||||
export function describeSearchFailure(error: unknown): SearchFailure {
|
||||
if (error instanceof RecordSearchResponseError) {
|
||||
return { kind: 'invalid-response', title: 'Record search failed: the server sent an unexpected response', retryable: false }
|
||||
}
|
||||
if (isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
// the client interceptor puts the backend's own `error` text in the message
|
||||
const backend = status ? oneLine(error.message) : undefined
|
||||
const detail = backend && !/^Request failed with status code/.test(backend) ? backend : undefined
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
return { kind: 'timeout', title: 'Record search failed: the server took too long to answer', retryable: true }
|
||||
}
|
||||
return { kind: 'offline', title: 'Record search failed: the server could not be reached', detail: oneLine(error.message), retryable: true }
|
||||
}
|
||||
if (status === 401) return { kind: 'unauthorized', title: 'Record search failed: your session has ended', detail, status, retryable: false }
|
||||
if (status === 403) return { kind: 'forbidden', title: 'Record search failed: permission denied', detail, status, retryable: false }
|
||||
if (status === 404 || status === 405 || status === 501) {
|
||||
return { kind: 'unsupported', title: 'Record search is not available on this server', detail, status, retryable: false }
|
||||
}
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return { kind: 'bad-request', title: `Record search failed: the search was rejected (HTTP ${status})`, detail, status, retryable: false }
|
||||
}
|
||||
if (status === 504) return { kind: 'timeout', title: 'Record search failed: the server took too long to answer (HTTP 504)', detail, status, retryable: true }
|
||||
return { kind: 'server', title: `Record search failed: server error (HTTP ${status})`, detail, status, retryable: true }
|
||||
}
|
||||
return { kind: 'unknown', title: 'Record search failed', detail: error instanceof Error ? oneLine(error.message) : undefined, retryable: true }
|
||||
}
|
||||
|
||||
/** A provider that failed while its tables still answered from the default search. */
|
||||
export interface SearchFallback {
|
||||
/** Headline, e.g. "Search index unavailable". */
|
||||
title: string
|
||||
/** Failure class and message, e.g. "ConnectException: Connection refused". */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a provider fallback, when the response reports one.
|
||||
*
|
||||
* @param provider - Provider status from the response.
|
||||
* @returns The fallback notice, or `undefined` when the provider was fine or absent.
|
||||
*/
|
||||
export function describeProviderFallback(provider: RecordSearchProviderStatus | undefined): SearchFallback | undefined {
|
||||
if (provider?.status !== 'FAILED') return undefined
|
||||
const message = oneLine(provider.message)
|
||||
const detail = [provider.failureClass, message].filter(Boolean).join(': ') || undefined
|
||||
return { title: 'Search index unavailable — showing basic matches instead', detail }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Tests for the search library: highlighting, failure descriptions, recent searches
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { AxiosError, type AxiosResponse } from 'axios'
|
||||
|
||||
import { RecordSearchResponseError } from '@/lib/api/record-search'
|
||||
import { findMatches, segmentText, termTokens } from './highlight'
|
||||
import { describeProviderFallback, describeSearchFailure } from './search-failure'
|
||||
import { addRecentSearch, clearRecentSearches, getRecentSearches, MAX_RECENT_SEARCHES, removeRecentSearch } from './recent-searches'
|
||||
|
||||
/**
|
||||
* An axios error carrying an HTTP response.
|
||||
*
|
||||
* @param status - HTTP status.
|
||||
* @param message - Error message (the client interceptor puts the backend's text here).
|
||||
* @returns The error.
|
||||
*/
|
||||
function httpError(status: number, message = `Request failed with status code ${status}`): AxiosError {
|
||||
return new AxiosError(message, 'ERR_BAD_RESPONSE', undefined, null, { status, data: {}, statusText: '', headers: {}, config: {} } as unknown as AxiosResponse)
|
||||
}
|
||||
|
||||
describe('highlight', () => {
|
||||
it('splits a term into distinct lowercased words, longest first', () => {
|
||||
expect(termTokens(' Acme co ACME ')).toEqual(['acme', 'co'])
|
||||
})
|
||||
|
||||
it('finds every occurrence of every word, merging overlaps', () => {
|
||||
expect(findMatches('Acme Corp — acme.com', 'acme')).toEqual([{ start: 0, end: 4 }, { start: 12, end: 16 }])
|
||||
expect(findMatches('abcd', 'abc bcd')).toEqual([{ start: 0, end: 4 }])
|
||||
expect(findMatches('nothing', 'zzz')).toEqual([])
|
||||
})
|
||||
|
||||
it('segments text at the ranges', () => {
|
||||
expect(segmentText('alice@acme.io', [{ start: 6, end: 10 }])).toEqual([
|
||||
{ text: 'alice@', match: false }, { text: 'acme', match: true }, { text: '.io', match: false },
|
||||
])
|
||||
expect(segmentText('abc', [])).toEqual([{ text: 'abc', match: false }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeSearchFailure', () => {
|
||||
it('names server errors with their status and the backend message', () => {
|
||||
expect(describeSearchFailure(httpError(500, 'Error in record search provider'))).toEqual({
|
||||
kind: 'server', title: 'Record search failed: server error (HTTP 500)', detail: 'Error in record search provider', status: 500, retryable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits axios generic messages', () => {
|
||||
expect(describeSearchFailure(httpError(502)).detail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies unreachable, timeout, permission, unsupported, rejected and invalid responses', () => {
|
||||
expect(describeSearchFailure(new AxiosError('Network Error', 'ERR_NETWORK')).kind).toBe('offline')
|
||||
expect(describeSearchFailure(new AxiosError('timeout of 30000ms exceeded', 'ECONNABORTED')).kind).toBe('timeout')
|
||||
expect(describeSearchFailure(httpError(504)).kind).toBe('timeout')
|
||||
expect(describeSearchFailure(httpError(401)).kind).toBe('unauthorized')
|
||||
expect(describeSearchFailure(httpError(403, 'Permission denied.'))).toMatchObject({ kind: 'forbidden', detail: 'Permission denied.', retryable: false })
|
||||
expect(describeSearchFailure(httpError(404)).kind).toBe('unsupported')
|
||||
expect(describeSearchFailure(httpError(400, 'The search term may not be longer than 100 characters.'))).toMatchObject({ kind: 'bad-request', title: 'Record search failed: the search was rejected (HTTP 400)' })
|
||||
expect(describeSearchFailure(new RecordSearchResponseError()).kind).toBe('invalid-response')
|
||||
expect(describeSearchFailure(new Error('boom'))).toMatchObject({ kind: 'unknown', detail: 'boom' })
|
||||
})
|
||||
|
||||
it('reports a failed provider with its failure class, and nothing otherwise', () => {
|
||||
expect(describeProviderFallback({ status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused\n at …' })).toEqual({
|
||||
title: 'Search index unavailable — showing basic matches instead', detail: 'ConnectException: Connection refused',
|
||||
})
|
||||
expect(describeProviderFallback({ status: 'OK' })).toBeUndefined()
|
||||
expect(describeProviderFallback(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recent searches', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('keeps distinct terms newest first, moving a repeated term (any case) to the top', () => {
|
||||
addRecentSearch('acme')
|
||||
addRecentSearch('bolt')
|
||||
expect(addRecentSearch(' ACME ')).toEqual(['ACME', 'bolt'])
|
||||
expect(getRecentSearches()).toEqual(['ACME', 'bolt'])
|
||||
})
|
||||
|
||||
it('ignores blank terms, caps the list, removes and clears', () => {
|
||||
addRecentSearch(' ')
|
||||
for (let index = 0; index < MAX_RECENT_SEARCHES + 3; index++) addRecentSearch(`term ${index}`)
|
||||
expect(getRecentSearches()).toHaveLength(MAX_RECENT_SEARCHES)
|
||||
expect(removeRecentSearch(`term ${MAX_RECENT_SEARCHES + 2}`)).not.toContain(`term ${MAX_RECENT_SEARCHES + 2}`)
|
||||
clearRecentSearches()
|
||||
expect(getRecentSearches()).toEqual([])
|
||||
})
|
||||
|
||||
it('survives corrupt storage', () => {
|
||||
localStorage.setItem('qqq-recent-searches', '{not json')
|
||||
expect(getRecentSearches()).toEqual([])
|
||||
localStorage.setItem('qqq-recent-searches', JSON.stringify(['ok', 3, '']))
|
||||
expect(getRecentSearches()).toEqual(['ok'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Tests for the search model: match quality, per-table ordering and grouping, counts,
|
||||
// URL state, and the quick search's groups (top hits, pages, tables, recents, actions)
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import {
|
||||
buildQuickSearchGroups,
|
||||
flattenOptions,
|
||||
groupResultsByTable,
|
||||
labelQuality,
|
||||
orderTableResults,
|
||||
parseSearchPageState,
|
||||
searchPagePath,
|
||||
tableCountLabel,
|
||||
tableHasMore,
|
||||
termCompletions,
|
||||
} from './search-model'
|
||||
|
||||
const crm = { label: 'CRM', path: '/app/crm' }
|
||||
const navTargets: NavTarget[] = [
|
||||
{ key: 'crm', label: 'CRM', path: '/app/crm', nodeType: 'APP', ancestors: [] },
|
||||
{ key: 'account', label: 'Accounts', path: '/app/account', nodeType: 'TABLE', ancestors: [crm] },
|
||||
{ key: 'acmeSync', label: 'Sync Acme Orders', path: '/app/acmeSync', nodeType: 'PROCESS', ancestors: [crm] },
|
||||
]
|
||||
const tables = [{ name: 'account', label: 'Accounts', icon: { name: 'business' } }, { name: 'contact', label: 'Contacts' }]
|
||||
|
||||
/**
|
||||
* A record search result.
|
||||
*
|
||||
* @param tableName - Table.
|
||||
* @param recordId - Id.
|
||||
* @param recordLabel - Label.
|
||||
* @param extra - Extended fields.
|
||||
* @returns The result.
|
||||
*/
|
||||
function hit(tableName: string, recordId: string, recordLabel: string, extra: Partial<RecordSearchResult> = {}): RecordSearchResult {
|
||||
return { tableName, recordId, recordLabel, ...extra }
|
||||
}
|
||||
|
||||
describe('labelQuality', () => {
|
||||
it('grades exact, prefix, word-start, contained and no match', () => {
|
||||
expect(labelQuality('Acme', 'acme')).toBe(4)
|
||||
expect(labelQuality('Acme Corp', 'acme')).toBe(3)
|
||||
expect(labelQuality('The Acme Corp', 'corp acme')).toBe(2)
|
||||
expect(labelQuality('Bigacme', 'acme')).toBe(1)
|
||||
expect(labelQuality('Other', 'acme')).toBe(0)
|
||||
expect(labelQuality('Other', ' ')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderTableResults', () => {
|
||||
const results = [hit('a', '1', 'Big Acme'), hit('a', '2', 'Acme', { updatedAt: '2026-01-01T00:00:00Z' }), hit('a', '3', 'Acme Two', { updatedAt: '2026-06-01T00:00:00Z' })]
|
||||
|
||||
it('ranks better label matches first for the default search (primary key order)', () => {
|
||||
expect(orderTableResults(results, 'acme').map((result) => result.recordId)).toEqual(['2', '3', '1'])
|
||||
})
|
||||
|
||||
it("keeps a provider's order (it sent scores)", () => {
|
||||
const scored = results.map((result, index) => ({ ...result, score: 10 - index }))
|
||||
expect(orderTableResults(scored, 'acme').map((result) => result.recordId)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('orders by most recent update, undated last, for "recent"', () => {
|
||||
expect(orderTableResults(results, 'acme', 'recent').map((result) => result.recordId)).toEqual(['3', '2', '1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupResultsByTable / counts', () => {
|
||||
it('groups in backend table order with metadata labels, icons and summaries', () => {
|
||||
const groups = groupResultsByTable(
|
||||
[hit('contact', '9', 'Ann'), hit('account', '1', 'Acme', { tableLabel: 'Ignored' }), hit('contact', '8', 'Bob')],
|
||||
[{ tableName: 'account', hasMore: true }],
|
||||
tables,
|
||||
'a'
|
||||
)
|
||||
expect(groups.map((group) => [group.tableName, group.tableLabel, group.results.length, group.summary?.hasMore])).toEqual([
|
||||
['contact', 'Contacts', 2, undefined],
|
||||
['account', 'Accounts', 1, true],
|
||||
])
|
||||
expect(groups[1].icon).toEqual({ name: 'business' })
|
||||
})
|
||||
|
||||
it('labels counts with totals, "+" when more exist, else the returned count', () => {
|
||||
expect(tableCountLabel(5, { tableName: 'a', totalCount: 128 })).toBe('128')
|
||||
expect(tableCountLabel(5, { tableName: 'a', hasMore: true })).toBe('5+')
|
||||
expect(tableCountLabel(3)).toBe('3')
|
||||
})
|
||||
|
||||
it('tells when a table has more: summary first, then a full page from older backends', () => {
|
||||
expect(tableHasMore(5, { tableName: 'a', hasMore: false }, 5)).toBe(false)
|
||||
expect(tableHasMore(5, { tableName: 'a', hasMore: true }, 5)).toBe(true)
|
||||
expect(tableHasMore(5, { tableName: 'a', totalCount: 9 }, 5)).toBe(true)
|
||||
expect(tableHasMore(5, undefined, 5)).toBe(true)
|
||||
expect(tableHasMore(4, undefined, 5)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('termCompletions', () => {
|
||||
it('completes the last word from label words, most frequent first, keeping the earlier words', () => {
|
||||
expect(termCompletions('acme co', ['Acme Corp', 'Acme Corporation', 'Corp of Acme', 'Cobalt'])).toEqual(['acme corp', 'acme cobalt', 'acme corporation'])
|
||||
expect(termCompletions('pe', ['Person', 'Pet', 'Pet Species'])).toEqual(['pet', 'person'])
|
||||
})
|
||||
|
||||
it('needs two characters, and never offers the term itself', () => {
|
||||
expect(termCompletions('a', ['Acme'])).toEqual([])
|
||||
expect(termCompletions('acme', ['Acme'])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('search page URL state', () => {
|
||||
it('round-trips q, tables, view and sort, omitting defaults', () => {
|
||||
expect(searchPagePath({ q: ' acme ' })).toBe('/app/search?q=acme')
|
||||
expect(searchPagePath({ q: 'acme', tables: ['account', 'contact'], sort: 'recent' })).toBe('/app/search?q=acme&tables=account%2Ccontact&sort=recent')
|
||||
expect(searchPagePath({ q: 'acme', tables: ['account'], view: 'pages' })).toBe('/app/search?q=acme&view=pages')
|
||||
expect(searchPagePath({ q: '' })).toBe('/app/search')
|
||||
expect(parseSearchPageState(new URLSearchParams('q=acme&tables=account,,contact,account&sort=recent'))).toEqual({ q: 'acme', tables: ['account', 'contact'], sort: 'recent', view: 'all' })
|
||||
expect(parseSearchPageState(new URLSearchParams('q=x&sort=bogus&view=pages&tables=a'))).toEqual({ q: 'x', tables: [], sort: 'relevance', view: 'pages' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildQuickSearchGroups', () => {
|
||||
const recent: RecentRecord[] = [
|
||||
{ tableName: 'contact', tableLabel: 'Contacts', recordId: '5', recordLabel: 'Acme buyer', path: '/app/contact/5', viewedAt: 2 },
|
||||
{ tableName: 'account', tableLabel: 'Accounts', recordId: '1', recordLabel: 'Acme', path: '/app/account/1', viewedAt: 1 },
|
||||
]
|
||||
|
||||
it('without a term lists recent searches, then recently viewed records', () => {
|
||||
const groups = buildQuickSearchGroups({ term: ' ', navTargets, recentRecords: recent, recentSearches: ['acme', 'bolt'], results: [], tables, limitPerTable: 6 })
|
||||
expect(groups.map((group) => [group.kind, group.options.length])).toEqual([['recent-searches', 2], ['recent', 2]])
|
||||
expect(groups[0].options[0]).toEqual({ id: 'query:acme', kind: 'query', term: 'acme' })
|
||||
})
|
||||
|
||||
it('promotes exact and prefix matches to top hits, then pages, tables with counts and more links, recents and search-all', () => {
|
||||
const results = [
|
||||
hit('account', '1', 'Acme', { tableLabel: 'Accounts' }),
|
||||
hit('account', '2', 'Big Acme Holdings'),
|
||||
hit('contact', '7', 'Wile E. Coyote', { highlights: [{ fieldName: 'company', text: 'Acme', matches: [{ start: 0, end: 4 }] }] }),
|
||||
]
|
||||
const groups = buildQuickSearchGroups({
|
||||
term: 'acme', navTargets, recentRecords: recent, recentSearches: [], results,
|
||||
summaries: [{ tableName: 'account', hasMore: true, totalCount: 14 }], tables, limitPerTable: 6,
|
||||
})
|
||||
expect(groups.map((group) => [group.kind, group.heading, group.count ?? null])).toEqual([
|
||||
['top', 'Top hits', null],
|
||||
['pages', 'Pages', null],
|
||||
['table', 'Accounts', '14'],
|
||||
['table', 'Contacts', '1'],
|
||||
['recent', 'Recently viewed', null],
|
||||
['actions', 'Search', null],
|
||||
])
|
||||
expect(groups[0].options.map((option) => option.id)).toEqual(['record:account:1'])
|
||||
expect(groups[1].options.map((option) => option.id)).toEqual(['page:/app/acmeSync'])
|
||||
// the top hit is not repeated; the table still links to all its matches
|
||||
expect(groups[2].options.map((option) => option.id)).toEqual(['record:account:2', 'more:account'])
|
||||
expect(groups[2].options[1]).toMatchObject({ kind: 'more', path: '/app/search?q=acme&tables=account', count: '14' })
|
||||
// a recently viewed record found by the search is not listed twice
|
||||
expect(groups[4].options.map((option) => option.id)).toEqual(['recent:/app/contact/5'])
|
||||
expect(flattenOptions(groups).at(-1)).toEqual({ id: 'all', kind: 'all', path: '/app/search?q=acme', term: 'acme' })
|
||||
})
|
||||
|
||||
it('always ends with search-all, even when nothing matches', () => {
|
||||
const groups = buildQuickSearchGroups({ term: 'zzz', navTargets, recentRecords: [], recentSearches: [], results: [], tables, limitPerTable: 6 })
|
||||
expect(groups.map((group) => group.kind)).toEqual(['actions'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* 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 search-model — ranks and groups everything the quick search shows: pages (apps,
|
||||
* tables, processes, reports matched locally), records found by record search (grouped by
|
||||
* table with counts, best matches promoted to "Top hits"), recently viewed records, recent
|
||||
* search terms, and the "more in table" / "all results" links. Pure functions over plain
|
||||
* data, shared by the header search, the `/` dialog and the search page.
|
||||
*/
|
||||
|
||||
import type { QIcon } from '@/types'
|
||||
import type { QAppNodeType } from '@/types/enums'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import type { RecordSearchResult, RecordSearchTableSummary } from '@/lib/api/record-search'
|
||||
import type { RecentRecord } from '@/lib/utils/recent-records'
|
||||
import { recordSearchResultPath, type SearchableTable } from '@/lib/utils/record-search'
|
||||
import { searchNavTargets, searchRecentRecords } from '@/lib/utils/navigation-search'
|
||||
import { termTokens } from './highlight'
|
||||
import { searchPagePath, type SearchSort } from './search-url'
|
||||
|
||||
export { parseSearchPageState, searchPagePath, type SearchPageState, type SearchSort } from './search-url'
|
||||
|
||||
/** One selectable entry of the quick search listbox. */
|
||||
export type QuickSearchOption =
|
||||
| { id: string; kind: 'page'; path: string; label: string; context: string; icon?: QIcon; nodeType?: QAppNodeType }
|
||||
| { id: string; kind: 'record'; path: string; label: string; tableLabel: string; icon?: QIcon; result: RecordSearchResult }
|
||||
| { id: string; kind: 'recent'; path: string; label: string; tableLabel: string; icon?: QIcon }
|
||||
| { id: string; kind: 'query'; term: string; completion?: boolean }
|
||||
| { id: string; kind: 'more'; path: string; tableLabel: string; count?: string }
|
||||
| { id: string; kind: 'all'; path: string; term: string }
|
||||
|
||||
/** A titled group of options. */
|
||||
export interface QuickSearchGroup {
|
||||
/** Stable id (DOM ids derive from it). */
|
||||
id: string
|
||||
/** Visible heading. */
|
||||
heading: string
|
||||
/** Count shown beside the heading, e.g. "12" or "25+". */
|
||||
count?: string
|
||||
/** Heading icon (a table's icon for its group). */
|
||||
icon?: QIcon
|
||||
/** What the group holds. */
|
||||
kind: 'top' | 'suggestions' | 'pages' | 'table' | 'recent' | 'recent-searches' | 'actions'
|
||||
/** The group's options, in order. */
|
||||
options: QuickSearchOption[]
|
||||
}
|
||||
|
||||
/** How well a label matches a term: 4 exact, 3 prefix, 2 every word starts a word, 1 every word contained, 0 otherwise. */
|
||||
export type MatchQuality = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
/**
|
||||
* Grades how well a label matches the search term.
|
||||
*
|
||||
* @param label - Label to grade.
|
||||
* @param term - Search text.
|
||||
* @returns The match quality.
|
||||
*/
|
||||
export function labelQuality(label: string, term: string): MatchQuality {
|
||||
const text = label.trim().toLowerCase()
|
||||
const whole = term.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
if (!whole) return 0
|
||||
if (text === whole) return 4
|
||||
if (text.startsWith(whole)) return 3
|
||||
const tokens = termTokens(whole)
|
||||
const words = text.split(/[\s\-_/.,:;()]+/).filter(Boolean)
|
||||
if (tokens.every((token) => words.some((word) => word.startsWith(token)))) return 2
|
||||
if (tokens.every((token) => text.includes(token))) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders one table's results. With `recent`, newest `updatedAt` first (records without one
|
||||
* last). With `relevance`, a provider's order (it sent scores) is kept; otherwise (the
|
||||
* default search orders by primary key) better label matches come first, stably.
|
||||
*
|
||||
* @param results - One table's results, in backend order.
|
||||
* @param term - Search text.
|
||||
* @param sort - Requested order.
|
||||
* @returns The ordered results (a new array).
|
||||
*/
|
||||
export function orderTableResults(results: RecordSearchResult[], term: string, sort: SearchSort = 'relevance'): RecordSearchResult[] {
|
||||
const indexed = results.map((result, index) => ({ result, index }))
|
||||
if (sort === 'recent') {
|
||||
const time = (result: RecordSearchResult) => (result.updatedAt ? Date.parse(result.updatedAt) : NaN)
|
||||
return indexed
|
||||
.sort((a, b) => {
|
||||
const ta = time(a.result)
|
||||
const tb = time(b.result)
|
||||
if (Number.isNaN(ta) !== Number.isNaN(tb)) return Number.isNaN(ta) ? 1 : -1
|
||||
return (Number.isNaN(ta) ? 0 : tb - ta) || a.index - b.index
|
||||
})
|
||||
.map(({ result }) => result)
|
||||
}
|
||||
if (results.some((result) => result.score !== undefined)) return [...results]
|
||||
return indexed
|
||||
.sort((a, b) => labelQuality(b.result.recordLabel, term) - labelQuality(a.result.recordLabel, term) || a.index - b.index)
|
||||
.map(({ result }) => result)
|
||||
}
|
||||
|
||||
/** Results of one table, with its summary. */
|
||||
export interface TableResults {
|
||||
/** Backend table name. */
|
||||
tableName: string
|
||||
/** Table label. */
|
||||
tableLabel: string
|
||||
/** Table icon from metadata. */
|
||||
icon?: QIcon
|
||||
/** Ordered results. */
|
||||
results: RecordSearchResult[]
|
||||
/** Summary from the backend (extended contract), when sent. */
|
||||
summary?: RecordSearchTableSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups results by table, in the backend's table order, with each table's summary and
|
||||
* metadata icon, ordered per {@link orderTableResults}.
|
||||
*
|
||||
* @param results - Results in backend order.
|
||||
* @param summaries - Per-table summaries (extended contract), if any.
|
||||
* @param tables - Searchable tables (labels and icons).
|
||||
* @param term - Search text.
|
||||
* @param sort - Requested order.
|
||||
* @returns One entry per table that has results.
|
||||
*/
|
||||
export function groupResultsByTable(
|
||||
results: RecordSearchResult[],
|
||||
summaries: RecordSearchTableSummary[] | undefined,
|
||||
tables: SearchableTable[],
|
||||
term: string,
|
||||
sort: SearchSort = 'relevance'
|
||||
): TableResults[] {
|
||||
const byName = new Map<string, RecordSearchResult[]>()
|
||||
for (const result of results) {
|
||||
const list = byName.get(result.tableName)
|
||||
if (list) list.push(result)
|
||||
else byName.set(result.tableName, [result])
|
||||
}
|
||||
return [...byName.entries()].map(([tableName, tableResults]) => {
|
||||
const table = tables.find((candidate) => candidate.name === tableName)
|
||||
return {
|
||||
tableName,
|
||||
tableLabel: table?.label ?? tableResults[0].tableLabel ?? tableName,
|
||||
icon: table?.icon,
|
||||
results: orderTableResults(tableResults, term, sort),
|
||||
summary: summaries?.find((summary) => summary.tableName === tableName),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The count shown for a table: its total when known, the returned count with "+" when
|
||||
* more are available, else the returned count.
|
||||
*
|
||||
* @param shown - Results returned for the table.
|
||||
* @param summary - The table's summary, if any.
|
||||
* @returns e.g. "128", "25+" or "3".
|
||||
*/
|
||||
export function tableCountLabel(shown: number, summary?: RecordSearchTableSummary): string {
|
||||
if (summary?.totalCount !== undefined && summary.totalCount >= shown) return String(summary.totalCount)
|
||||
if (summary?.hasMore) return `${shown}+`
|
||||
return String(shown)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a table has more matches than were shown.
|
||||
*
|
||||
* @param shown - Results shown.
|
||||
* @param summary - The table's summary, if any.
|
||||
* @param limit - The per-table limit that was requested (older backends: a full page suggests more).
|
||||
* @returns Whether to offer more.
|
||||
*/
|
||||
export function tableHasMore(shown: number, summary: RecordSearchTableSummary | undefined, limit: number): boolean {
|
||||
if (summary?.hasMore !== undefined) return summary.hasMore || (summary.totalCount ?? 0) > shown
|
||||
if (summary?.totalCount !== undefined) return summary.totalCount > shown
|
||||
return shown >= limit
|
||||
}
|
||||
|
||||
/** Inputs of {@link buildQuickSearchGroups}. */
|
||||
export interface QuickSearchInput {
|
||||
/** Search text. */
|
||||
term: string
|
||||
/** Navigable pages. */
|
||||
navTargets: NavTarget[]
|
||||
/** Recently viewed records, newest first. */
|
||||
recentRecords: RecentRecord[]
|
||||
/** Recent search terms, newest first. */
|
||||
recentSearches: string[]
|
||||
/** Record search results for `term` (empty while searching, failed or unavailable). */
|
||||
results: RecordSearchResult[]
|
||||
/** Per-table summaries from the response, if any. */
|
||||
summaries?: RecordSearchTableSummary[]
|
||||
/** Searchable tables (labels and icons). */
|
||||
tables: SearchableTable[]
|
||||
/** Records requested per table (to tell when more may exist). */
|
||||
limitPerTable: number
|
||||
/** Most pages listed. */
|
||||
maxPages?: number
|
||||
/** Most results listed per table. */
|
||||
maxPerTable?: number
|
||||
}
|
||||
|
||||
/** Most term completions offered. */
|
||||
const MAX_COMPLETIONS = 3
|
||||
|
||||
/**
|
||||
* Completes the last word of a search term from the words of matching labels, most
|
||||
* frequent first ("acme co" with "Acme Corp" and "Acme Corporation" offers "acme corp" and
|
||||
* "acme corporation").
|
||||
*
|
||||
* @param term - Search text (the last word needs two characters).
|
||||
* @param labels - Labels of matching pages and records.
|
||||
* @returns Up to three completed terms, lowercased, never the term itself.
|
||||
*/
|
||||
export function termCompletions(term: string, labels: string[]): string[] {
|
||||
const words = term.trim().split(/\s+/)
|
||||
const last = (words.pop() ?? '').toLowerCase()
|
||||
if (last.length < 2) return []
|
||||
const prefix = words.join(' ').toLowerCase()
|
||||
const counts = new Map<string, number>()
|
||||
for (const label of labels) {
|
||||
for (const word of new Set(label.toLowerCase().split(/[\s\-_/.,:;()"'@]+/))) {
|
||||
if (word.length > last.length && word.startsWith(last)) counts.set(word, (counts.get(word) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].length - b[0].length || a[0].localeCompare(b[0]))
|
||||
.slice(0, MAX_COMPLETIONS)
|
||||
.map(([word]) => (prefix ? `${prefix} ${word}` : word))
|
||||
}
|
||||
|
||||
/** Most entries in "Top hits". */
|
||||
const MAX_TOP_HITS = 3
|
||||
|
||||
/**
|
||||
* Builds the quick search's groups.
|
||||
*
|
||||
* Without a term: recent searches, then recently viewed records. With a term: "Top hits"
|
||||
* (exact and prefix matches across pages and records, best first), "Pages", one group per
|
||||
* table with its count and a "more in table" link when it has more, matching recently viewed
|
||||
* records not already listed, and finally a "search all results" entry, so Enter always has a
|
||||
* useful target.
|
||||
*
|
||||
* @param input - Term, candidates and limits.
|
||||
* @returns Non-empty groups, in display order.
|
||||
*/
|
||||
export function buildQuickSearchGroups(input: QuickSearchInput): QuickSearchGroup[] {
|
||||
const { term, navTargets, recentRecords, recentSearches, results, summaries, tables, limitPerTable, maxPages = 5, maxPerTable = 5 } = input
|
||||
const trimmed = term.trim()
|
||||
const groups: QuickSearchGroup[] = []
|
||||
|
||||
if (!trimmed) {
|
||||
if (recentSearches.length > 0) {
|
||||
groups.push({
|
||||
id: 'recent-searches',
|
||||
heading: 'Recent searches',
|
||||
kind: 'recent-searches',
|
||||
options: recentSearches.slice(0, 5).map((query) => ({ id: `query:${query}`, kind: 'query', term: query })),
|
||||
})
|
||||
}
|
||||
if (recentRecords.length > 0) {
|
||||
groups.push({
|
||||
id: 'recent',
|
||||
heading: 'Recently viewed',
|
||||
kind: 'recent',
|
||||
options: recentRecords.slice(0, 6).map((record) => ({ id: `recent:${record.path}`, kind: 'recent', path: record.path, label: record.recordLabel, tableLabel: record.tableLabel, icon: record.tableIcon })),
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
const pages = searchNavTargets(navTargets, trimmed).map((target) => ({
|
||||
option: {
|
||||
id: `page:${target.path}`,
|
||||
kind: 'page' as const,
|
||||
path: target.path,
|
||||
label: target.label,
|
||||
context: target.ancestors.map((ancestor) => ancestor.label).join(' / '),
|
||||
icon: target.icon,
|
||||
nodeType: target.nodeType,
|
||||
},
|
||||
quality: labelQuality(target.label, trimmed),
|
||||
}))
|
||||
// stable: better label matches first, tree order otherwise
|
||||
pages.sort((a, b) => b.quality - a.quality)
|
||||
|
||||
const byTable = groupResultsByTable(results, summaries, tables, trimmed)
|
||||
const records = byTable.flatMap((table) => table.results.map((result, rank) => ({
|
||||
option: {
|
||||
id: `record:${table.tableName}:${result.recordId}`,
|
||||
kind: 'record' as const,
|
||||
path: recordSearchResultPath(result),
|
||||
label: result.recordLabel,
|
||||
tableLabel: table.tableLabel,
|
||||
icon: table.icon,
|
||||
result,
|
||||
},
|
||||
quality: labelQuality(result.recordLabel, trimmed),
|
||||
rank,
|
||||
})))
|
||||
|
||||
const topCandidates = [
|
||||
...pages.filter((page) => page.quality >= 3).map((page) => ({ ...page, score: 0, record: false })),
|
||||
...records.filter((record) => record.quality >= 3 || (record.rank === 0 && record.quality >= 2 && record.option.result.score !== undefined))
|
||||
.map((record) => ({ ...record, score: record.option.result.score ?? 0, record: true })),
|
||||
]
|
||||
topCandidates.sort((a, b) => b.quality - a.quality || b.score - a.score || Number(a.record) - Number(b.record) || a.option.label.length - b.option.label.length)
|
||||
const top = topCandidates.slice(0, MAX_TOP_HITS).map((candidate) => candidate.option)
|
||||
const topIds = new Set(top.map((option) => option.id))
|
||||
if (top.length > 0) groups.push({ id: 'top', heading: 'Top hits', kind: 'top', options: top })
|
||||
|
||||
const completions = termCompletions(trimmed, [...pages.map((page) => page.option.label), ...records.map((record) => record.option.label)])
|
||||
if (completions.length > 0) {
|
||||
groups.push({
|
||||
id: 'suggestions',
|
||||
heading: 'Suggestions',
|
||||
kind: 'suggestions',
|
||||
options: completions.map((completion) => ({ id: `complete:${completion}`, kind: 'query', term: completion, completion: true })),
|
||||
})
|
||||
}
|
||||
|
||||
const remainingPages = pages.map((page) => page.option).filter((option) => !topIds.has(option.id)).slice(0, maxPages)
|
||||
if (remainingPages.length > 0) groups.push({ id: 'pages', heading: 'Pages', kind: 'pages', options: remainingPages })
|
||||
|
||||
for (const table of byTable) {
|
||||
const shown = table.results.length
|
||||
const options: QuickSearchOption[] = records
|
||||
.filter((record) => record.option.result.tableName === table.tableName && !topIds.has(record.option.id))
|
||||
.slice(0, maxPerTable)
|
||||
.map((record) => record.option)
|
||||
const listed = options.length + table.results.filter((result) => topIds.has(`record:${table.tableName}:${result.recordId}`)).length
|
||||
const count = tableCountLabel(shown, table.summary)
|
||||
if (listed < shown || tableHasMore(shown, table.summary, limitPerTable)) {
|
||||
options.push({ id: `more:${table.tableName}`, kind: 'more', path: searchPagePath({ q: trimmed, tables: [table.tableName] }), tableLabel: table.tableLabel, count })
|
||||
}
|
||||
if (options.length > 0) groups.push({ id: `table:${table.tableName}`, heading: table.tableLabel, count, icon: table.icon, kind: 'table', options })
|
||||
}
|
||||
|
||||
const foundPaths = new Set(records.map((record) => record.option.path))
|
||||
const recent = searchRecentRecords(recentRecords, trimmed)
|
||||
.filter((record) => !foundPaths.has(record.path))
|
||||
.slice(0, 3)
|
||||
.map((record): QuickSearchOption => ({ id: `recent:${record.path}`, kind: 'recent', path: record.path, label: record.recordLabel, tableLabel: record.tableLabel, icon: record.tableIcon }))
|
||||
if (recent.length > 0) groups.push({ id: 'recent', heading: 'Recently viewed', kind: 'recent', options: recent })
|
||||
|
||||
groups.push({ id: 'actions', heading: 'Search', kind: 'actions', options: [{ id: 'all', kind: 'all', path: searchPagePath({ q: trimmed }), term: trimmed }] })
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* The options of all groups, in display order (the keyboard order).
|
||||
*
|
||||
* @param groups - Groups from {@link buildQuickSearchGroups}.
|
||||
* @returns Every option.
|
||||
*/
|
||||
export function flattenOptions(groups: QuickSearchGroup[]): QuickSearchOption[] {
|
||||
return groups.flatMap((group) => group.options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an option leads, if anywhere (a recent search term fills the input instead).
|
||||
*
|
||||
* @param option - The option.
|
||||
* @returns Its path, or `undefined`.
|
||||
*/
|
||||
export function optionPath(option: QuickSearchOption): string | undefined {
|
||||
return option.kind === 'query' ? undefined : option.path
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 search-url — the search page's URL state (`q`, `tables`, `view=pages`,
|
||||
* `sort=recent`), so searches can be shared and bookmarked. Kept apart from the search
|
||||
* model so the always-loaded shell (header, palette) can link to the page cheaply.
|
||||
*/
|
||||
|
||||
/** Result order on the search page. */
|
||||
export type SearchSort = 'relevance' | 'recent'
|
||||
|
||||
/** The search page's URL state. */
|
||||
export interface SearchPageState {
|
||||
/** Search text. */
|
||||
q: string
|
||||
/** Tables to show (empty: all). */
|
||||
tables?: string[]
|
||||
/** Result order. */
|
||||
sort?: SearchSort
|
||||
/** `pages` shows only matching pages (apps, tables, processes, reports). */
|
||||
view?: 'all' | 'pages'
|
||||
}
|
||||
|
||||
/**
|
||||
* The search page URL for a state (default values are omitted, so URLs stay short).
|
||||
*
|
||||
* @param state - Search text, tables, order and view.
|
||||
* @returns e.g. `/app/search?q=acme&tables=person&sort=recent`.
|
||||
*/
|
||||
export function searchPagePath({ q, tables, sort, view }: SearchPageState): string {
|
||||
const params = new URLSearchParams()
|
||||
if (q.trim()) params.set('q', q.trim())
|
||||
if (view === 'pages') params.set('view', 'pages')
|
||||
else if (tables && tables.length > 0) params.set('tables', tables.join(','))
|
||||
if (sort === 'recent') params.set('sort', 'recent')
|
||||
const query = params.toString()
|
||||
return query ? `/app/search?${query}` : '/app/search'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the search page state from URL parameters (unknown values fall back to the defaults).
|
||||
*
|
||||
* @param params - The page's search params.
|
||||
* @returns The state.
|
||||
*/
|
||||
export function parseSearchPageState(params: Pick<URLSearchParams, 'get'>): Required<SearchPageState> {
|
||||
const view = params.get('view') === 'pages' ? 'pages' : 'all'
|
||||
const tables = view === 'pages' ? [] : [...new Set((params.get('tables') ?? '').split(',').map((name) => name.trim()).filter(Boolean))]
|
||||
return { q: params.get('q') ?? '', tables, sort: params.get('sort') === 'recent' ? 'recent' : 'relevance', view }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an app path in a new browser tab without giving it access to this window.
|
||||
*
|
||||
* @param path - App path.
|
||||
*/
|
||||
export function openInNewTab(path: string): void {
|
||||
window.open(path, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
@@ -15,37 +15,13 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file navigation-search — "jump to" search results for the header search, the `/`
|
||||
* search dialog and the search page: navigation targets and recently viewed records,
|
||||
* matched locally by label, plus the records a backend record search found (only
|
||||
* when the metadata advertises searchable tables; see `record-search.ts`).
|
||||
* @file navigation-search — local matching of navigation targets and recently viewed
|
||||
* records by label, for the quick search and the search page (ranking and grouping live
|
||||
* in `lib/search/search-model.ts`).
|
||||
*/
|
||||
|
||||
import type { QIcon } from '@/types'
|
||||
import type { QAppNodeType } from '@/types/enums'
|
||||
import type { NavTarget } from '@/lib/hooks/use-routes'
|
||||
import type { RecordSearchResult } from '@/lib/api/tables'
|
||||
import type { RecentRecord } from './recent-records'
|
||||
import { recordSearchResultPath } from './record-search'
|
||||
|
||||
/** One selectable search result. */
|
||||
export interface NavigationSearchItem {
|
||||
/**
|
||||
* `page` = app, table, process or report; `result` = record found by record search;
|
||||
* `record` = recently viewed record.
|
||||
*/
|
||||
kind: 'page' | 'result' | 'record'
|
||||
/** Destination path. */
|
||||
path: string
|
||||
/** Primary label. */
|
||||
label: string
|
||||
/** Secondary context: enclosing apps for pages, table label for records. */
|
||||
context: string
|
||||
/** Metadata icon: the node's for pages, the table's for recently viewed records. */
|
||||
icon?: QIcon
|
||||
/** App-tree node type (pages only). */
|
||||
nodeType?: QAppNodeType
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring test.
|
||||
@@ -87,49 +63,3 @@ export function searchRecentRecords(records: RecentRecord[], term: string): Rece
|
||||
if (!needle) return records
|
||||
return records.filter((record) => includes(record.recordLabel, needle) || includes(record.tableLabel, needle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ordered result list: matching pages, then records found by record
|
||||
* search (in backend order), then matching recent records not already found.
|
||||
*
|
||||
* @param targets - Navigable targets.
|
||||
* @param records - Recently viewed records, newest first.
|
||||
* @param term - Search text; blank lists only the recent records.
|
||||
* @param limit - Maximum pages and recent records (record search limits its own results).
|
||||
* @param found - Records found by record search for `term`.
|
||||
* @returns Selectable results.
|
||||
*/
|
||||
export function buildNavigationSearchItems(
|
||||
targets: NavTarget[],
|
||||
records: RecentRecord[],
|
||||
term: string,
|
||||
limit = 8,
|
||||
found: RecordSearchResult[] = []
|
||||
): NavigationSearchItem[] {
|
||||
const pages: NavigationSearchItem[] = searchNavTargets(targets, term).slice(0, limit).map((target) => ({
|
||||
kind: 'page',
|
||||
path: target.path,
|
||||
label: target.label,
|
||||
context: target.ancestors.map((ancestor) => ancestor.label).join(' / '),
|
||||
icon: target.icon,
|
||||
nodeType: target.nodeType,
|
||||
}))
|
||||
const results: NavigationSearchItem[] = found.map((result) => ({
|
||||
kind: 'result',
|
||||
path: recordSearchResultPath(result),
|
||||
label: result.recordLabel,
|
||||
context: result.tableLabel ?? '',
|
||||
}))
|
||||
const foundPaths = new Set(results.map((result) => result.path))
|
||||
const recent: NavigationSearchItem[] = searchRecentRecords(records, term)
|
||||
.filter((record) => !foundPaths.has(record.path))
|
||||
.slice(0, limit)
|
||||
.map((record) => ({
|
||||
kind: 'record',
|
||||
path: record.path,
|
||||
label: record.recordLabel,
|
||||
context: record.tableLabel,
|
||||
icon: record.tableIcon,
|
||||
}))
|
||||
return [...pages, ...results, ...recent]
|
||||
}
|
||||
@@ -14,13 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for record-search helpers and how found records join the navigation search results
|
||||
// Tests for record-search helpers: searchable tables, term length, result paths
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { QInstance, QTableMetaData } from '@/types'
|
||||
import { isRecordSearchTerm, recordSearchResultPath, searchableTables } from './record-search'
|
||||
import { buildNavigationSearchItems } from './navigation-search'
|
||||
|
||||
function table(name: string, extra: Partial<QTableMetaData>): QTableMetaData {
|
||||
return { name, label: name.toUpperCase(), isHidden: false, readPermission: true, ...extra } as QTableMetaData
|
||||
@@ -41,6 +40,11 @@ describe('searchableTables', () => {
|
||||
expect(searchableTables(metaData)).toEqual([{ name: 'person', label: 'PERSON' }, { name: 'order', label: 'ORDER' }])
|
||||
})
|
||||
|
||||
it('carries the table icon for result groups', () => {
|
||||
const metaData = { tables: { person: table('person', { searchFields: ['firstName'], icon: { name: 'person' } }) } } as unknown as QInstance
|
||||
expect(searchableTables(metaData)).toEqual([{ name: 'person', label: 'PERSON', icon: { name: 'person' } }])
|
||||
})
|
||||
|
||||
it('is empty without metadata or without any declaration (no record search capability)', () => {
|
||||
expect(searchableTables(undefined)).toEqual([])
|
||||
expect(searchableTables({ tables: { person: table('person', {}) } } as unknown as QInstance)).toEqual([])
|
||||
@@ -58,27 +62,3 @@ describe('isRecordSearchTerm / recordSearchResultPath', () => {
|
||||
expect(recordSearchResultPath({ tableName: 'file', recordId: 'a/b c', recordLabel: 'x' })).toBe('/app/file/a%2Fb%20c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNavigationSearchItems with found records', () => {
|
||||
it('orders pages, found records (backend order), then recent records not already found', () => {
|
||||
const items = buildNavigationSearchItems(
|
||||
[{ key: 'person', label: 'Person', path: '/app/person', nodeType: 'TABLE', ancestors: [] }],
|
||||
[
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Pat One', path: '/app/person/1', viewedAt: 2 },
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '9', recordLabel: 'Pat Nine', path: '/app/person/9', viewedAt: 1 },
|
||||
],
|
||||
'p',
|
||||
8,
|
||||
[
|
||||
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Pat One' },
|
||||
{ tableName: 'pet', tableLabel: 'Pet', recordId: '3', recordLabel: 'Pip' },
|
||||
]
|
||||
)
|
||||
expect(items.map((item) => [item.kind, item.label, item.context, item.path])).toEqual([
|
||||
['page', 'Person', '', '/app/person'],
|
||||
['result', 'Pat One', 'Person', '/app/person/1'],
|
||||
['result', 'Pip', 'Pet', '/app/pet/3'],
|
||||
['record', 'Pat Nine', 'Person', '/app/person/9'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -20,8 +20,8 @@
|
||||
* a table is searchable only when its metadata lists `searchFields`.
|
||||
*/
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/tables'
|
||||
import type { QIcon, QInstance } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
|
||||
/** Shortest term sent to record search (shorter terms still match pages locally). */
|
||||
export const MIN_RECORD_SEARCH_LENGTH = 2
|
||||
@@ -32,6 +32,8 @@ export interface SearchableTable {
|
||||
name: string
|
||||
/** Metadata label. */
|
||||
label: string
|
||||
/** Table icon from metadata. */
|
||||
icon?: QIcon
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +48,7 @@ export interface SearchableTable {
|
||||
export function searchableTables(metaData: QInstance | undefined): SearchableTable[] {
|
||||
return Object.values(metaData?.tables ?? {})
|
||||
.filter((table) => (table.searchFields?.length ?? 0) > 0 && table.readPermission !== false && table.isHidden !== true)
|
||||
.map((table) => ({ name: table.name, label: table.label }))
|
||||
.map((table) => ({ name: table.name, label: table.label, icon: table.icon }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -539,7 +539,7 @@
|
||||
"feature": "Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/search over each table's searchFields) finds, after the matching pages, by record label with the table label; results open by keyboard and the search page survives refresh",
|
||||
"source": [
|
||||
"src/lib/hooks/use-record-search.ts, src/lib/utils/record-search.ts, src/lib/utils/navigation-search.ts",
|
||||
"src/components/layout/GlobalSearch.tsx, src/components/feedback/SearchDialog.tsx, src/components/layout/NavigationSearchResults.tsx, src/app/(dashboard)/app/search/page.tsx",
|
||||
"src/components/layout/GlobalSearch.tsx, src/components/feedback/SearchDialog.tsx, src/components/search/QuickSearchListbox.tsx, src/components/search/SearchPage.tsx, src/app/(dashboard)/app/search/page.tsx",
|
||||
"qqq-backend-core RecordSearchAction, QTableMetaData.searchFields; qqq-middleware-javalin RecordSearchSpecV1"
|
||||
],
|
||||
"fixture": "stock sample + NavigationFixtures (search fields on person, pet, savedView)",
|
||||
|
||||
@@ -51,21 +51,25 @@ test('[NAV-032] without searchable tables the header box, "/" dialog and search
|
||||
await header.fill(person.first_name!)
|
||||
await expect(page.getByRole('listbox', { name: 'Search results' })).toContainText(`No pages or recent records match “${person.first_name}”`)
|
||||
await expect(page.getByRole('combobox', { name: 'Search pages and records' })).toHaveCount(0)
|
||||
// Escape closes the dropdown and keeps focus in the box (a combobox); leave it to use "/"
|
||||
await header.press('Escape')
|
||||
await expect(page.getByRole('listbox', { name: 'Search results' })).toHaveCount(0)
|
||||
await header.blur()
|
||||
await page.locator('body').press('/')
|
||||
}
|
||||
|
||||
// the dialog: pages still match, no Records group
|
||||
// the dialog: pages still match (the "Person" table is the top hit), and no record is listed
|
||||
await dialog.getByRole('combobox', { name: 'Search pages and recent records' }).fill('person')
|
||||
await expect(dialog.getByRole('group', { name: 'Pages' }).getByRole('option').first()).toContainText('Person')
|
||||
await expect(dialog.getByRole('group', { name: 'Records' })).toHaveCount(0)
|
||||
await expect(dialog.getByRole('group', { name: 'Top hits' }).getByRole('option').first()).toContainText('Person')
|
||||
await expect(dialog.locator('[data-qqq-id="search-option-record"]')).toHaveCount(0)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// search page: no Records region
|
||||
// search page: no record results, no sort (nothing to order)
|
||||
await open(page, `/app/search?q=${encodeURIComponent(person.first_name!)}`)
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(`Search results for “${person.first_name}”`)
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(`Results for “${person.first_name}”`)
|
||||
await expect(page.getByText(`No pages or recent records match “${person.first_name}”.`)).toBeVisible()
|
||||
await expect(page.getByRole('region', { name: 'Records' })).toHaveCount(0)
|
||||
await expect(page.locator('[data-qqq-id="search-result-card"]')).toHaveCount(0)
|
||||
await expect(page.getByRole('combobox', { name: 'Sort' })).toHaveCount(0)
|
||||
|
||||
// outlast the search debounce before asserting that nothing was requested
|
||||
await page.waitForTimeout(1_000)
|
||||
|
||||
@@ -46,9 +46,26 @@ async function openSearchDialog(page: Page): Promise<Locator> {
|
||||
return dialog
|
||||
}
|
||||
|
||||
/** The found-record options (the "Records" group) of a results listbox or dialog. */
|
||||
/** The found-record options of a results listbox or dialog ("Top hits" and the per-table groups). */
|
||||
function foundRecords(scope: Locator): Locator {
|
||||
return scope.getByRole('group', { name: 'Records' }).getByRole('option')
|
||||
return scope.locator('[data-qqq-id="search-option-record"]')
|
||||
}
|
||||
|
||||
/** The labels of the found records, sorted (top hits are promoted, so order is checked elsewhere). */
|
||||
async function foundLabels(scope: Locator): Promise<string[]> {
|
||||
return (await foundRecords(scope).locator('span.text-sm.font-medium').allTextContents()).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a found record's table: shown on the option in "Top hits", else its group is the table.
|
||||
*
|
||||
* @param option - A found-record option.
|
||||
* @param tableLabel - The table's label.
|
||||
*/
|
||||
async function expectFromTable(option: Locator, tableLabel: string) {
|
||||
const group = option.locator('xpath=ancestor::*[@role="group"][1]')
|
||||
if ((await group.getAttribute('data-qqq-id')) === 'search-group-top') await expect(option).toContainText(tableLabel)
|
||||
else await expect(group).toHaveAccessibleName(new RegExp(`^${tableLabel}\\b`))
|
||||
}
|
||||
|
||||
/** The label line of a result option (the table label is the second line). */
|
||||
@@ -77,9 +94,9 @@ test.describe('record search results', () => {
|
||||
const records = foundRecords(dialog)
|
||||
await expect(records).toHaveCount(1)
|
||||
await expect(optionLabel(records.first())).toHaveText(pets[0].name!)
|
||||
await expect(records.first()).toContainText(tables.pet.label)
|
||||
await expectFromTable(records.first(), tables.pet.label)
|
||||
|
||||
await page.keyboard.press('ArrowDown')
|
||||
// an exact label match is the top hit, preselected: Enter opens it
|
||||
await expect(records.first()).toHaveAttribute('aria-selected', 'true')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
@@ -91,7 +108,7 @@ test.describe('record search results', () => {
|
||||
for (const call of searches) {
|
||||
expect(call.status).toBe(200)
|
||||
expect([...(call.body.tableNames ?? [])].sort()).toEqual(['person', 'pet', 'savedView'])
|
||||
expect(call.body.limitPerTable).toBe(5)
|
||||
expect(call.body.limitPerTable).toBe(6)
|
||||
}
|
||||
expect(searches.map((call) => call.body.searchTerm)).toContain('COCO')
|
||||
})
|
||||
@@ -100,7 +117,8 @@ test.describe('record search results', () => {
|
||||
void diagnostics
|
||||
const searches = watchSearches(page)
|
||||
const personLabel = (await v1MetaData(backend)).tables.person.label
|
||||
const dropdownPeople = await expectedPeople(backend, 'sample', 5)
|
||||
const dropdownPeople = await expectedPeople(backend, 'sample', 6)
|
||||
const overviewPeople = await expectedPeople(backend, 'sample', 8)
|
||||
const pagePeople = await expectedPeople(backend, 'sample', 25)
|
||||
expect(dropdownPeople.length).toBeGreaterThan(1)
|
||||
expect(await backend.sql("select name from pet where lower(name) like '%sample%'")).toEqual([])
|
||||
@@ -112,26 +130,37 @@ test.describe('record search results', () => {
|
||||
await header.fill('Sample')
|
||||
const results = page.getByRole('listbox', { name: 'Search results' })
|
||||
const records = foundRecords(results)
|
||||
await expect(records.locator('span.text-sm.font-medium')).toHaveText(dropdownPeople)
|
||||
for (const option of await records.all()) await expect(option).toContainText(personLabel)
|
||||
// groups keep their order: pages (if any match), then found records
|
||||
const groups = await results.getByRole('group').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('aria-label')))
|
||||
expect(groups.filter((name) => name !== 'Pages')).toEqual(['Records'])
|
||||
// a full page of people (top hits plus the People group, which links to the rest)
|
||||
await expect(records.first()).toBeVisible()
|
||||
await expect(results).toHaveAttribute('aria-busy', 'false')
|
||||
const shown = await foundLabels(results)
|
||||
for (const label of shown) expect(dropdownPeople).toContain(label)
|
||||
// with more than a group's worth, the People group links to the rest instead of listing them
|
||||
if (dropdownPeople.length <= 5) expect(shown).toEqual([...dropdownPeople].sort())
|
||||
else expect(shown.length).toBeGreaterThanOrEqual(5)
|
||||
for (const option of await records.all()) await expectFromTable(option, personLabel)
|
||||
await expect(results.getByRole('group', { name: new RegExp(`^${personLabel}\\b`) })).toBeVisible()
|
||||
|
||||
// Enter with nothing selected opens the search page, with every match up to its limit
|
||||
// ArrowUp above the preselected top hit leaves nothing selected; Enter opens the search page
|
||||
await page.mouse.move(1, 1)
|
||||
await expect(results.getByRole('option', { selected: true })).toHaveCount(1)
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect(results.getByRole('option', { selected: true })).toHaveCount(0)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/app\/search\/?\?q=Sample$/)
|
||||
const cardLabels = async () => (await page.locator('[data-qqq-id="search-result-link"]').allTextContents()).sort()
|
||||
for (const attempt of ['navigate', 'reload']) {
|
||||
if (attempt === 'reload') await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Search results for “Sample”')
|
||||
const links = page.getByRole('region', { name: 'Records' }).getByRole('link')
|
||||
await expect(links.locator('span.font-medium')).toHaveText(pagePeople)
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Results for “Sample”')
|
||||
await expect(page.getByRole('region', { name: personLabel })).toBeVisible()
|
||||
await expect.poll(cardLabels).toEqual([...overviewPeople].sort())
|
||||
}
|
||||
await page.getByRole('region', { name: 'Records' }).getByRole('link').first().click()
|
||||
const [first] = await backend.sql("select id from person where lower(first_name) like '%sample%' or lower(last_name) like '%sample%' or lower(email) like '%sample%' order by id")
|
||||
await expect(page).toHaveURL(new RegExp(`/app/person/${first.id}/?$`))
|
||||
// the table's filter lists every match up to the per-table page size, by URL
|
||||
await page.getByRole('navigation', { name: 'Filter results' }).getByRole('link', { name: new RegExp(`^${personLabel}\\b`) }).click()
|
||||
await expect(page).toHaveURL(/\/app\/search\/?\?q=Sample&tables=person$/)
|
||||
await expect.poll(cardLabels).toEqual([...pagePeople].sort())
|
||||
await page.locator('[data-qqq-id="search-result-link"]').first().click()
|
||||
await expect(page).toHaveURL(/\/app\/person\/\d+\/?$/)
|
||||
expect(searches.filter((call) => call.status !== 200)).toEqual([])
|
||||
expect(searches.some((call) => call.body.limitPerTable === 25)).toBe(true)
|
||||
})
|
||||
@@ -148,14 +177,15 @@ test.describe('record search permissions', () => {
|
||||
const pets = await backend.sql("select name from pet where lower(name) like '%ey%' order by id")
|
||||
expect(pets.length).toBeGreaterThan(0)
|
||||
const people = await expectedPeople(backend, 'ey', 5)
|
||||
const shownPeople = await expectedPeople(backend, 'ey', 6)
|
||||
expect(people.length).toBeGreaterThan(0)
|
||||
|
||||
await open(page, '/app')
|
||||
await waitForShell(page)
|
||||
const dialog = await openSearchDialog(page)
|
||||
await dialog.getByRole('combobox', { name: 'Search pages and records' }).fill('ey')
|
||||
const records = foundRecords(dialog)
|
||||
await expect(records.locator('span.text-sm.font-medium')).toHaveText(people)
|
||||
await expect(foundRecords(dialog).first()).toBeVisible()
|
||||
for (const label of await foundLabels(dialog)) expect(shownPeople).toContain(label)
|
||||
for (const pet of pets) await expect(dialog.getByRole('option', { name: new RegExp(pet.name!) })).toHaveCount(0)
|
||||
for (const call of searches) expect(call.body.tableNames).not.toContain('pet')
|
||||
|
||||
@@ -188,9 +218,9 @@ test.describe('record search permissions', () => {
|
||||
const records = foundRecords(dialog)
|
||||
await expect(records).toHaveCount(1)
|
||||
await expect(optionLabel(records.first())).toHaveText(view.label!)
|
||||
await expect(records.first()).toContainText(savedViewLabel)
|
||||
await expectFromTable(records.first(), savedViewLabel)
|
||||
expect(await apiSearch(backend, { searchTerm: 'people view', tableNames: ['savedView'] })).toEqual([
|
||||
{ tableName: 'savedView', tableLabel: savedViewLabel, recordId: view.id, recordLabel: view.label },
|
||||
expect.objectContaining({ tableName: 'savedView', tableLabel: savedViewLabel, recordId: view.id, recordLabel: view.label }),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -19,26 +19,33 @@ for (const theme of ['light', 'dark']) {
|
||||
await trigger.press('/')
|
||||
const dialog = page.getByRole('dialog', { name: 'Search', exact: true })
|
||||
const input = dialog.getByRole('combobox')
|
||||
const close = dialog.getByRole('button', { name: 'Close search' })
|
||||
await expect(input).toBeFocused()
|
||||
// empty: the input and the close button (results are options reached with the arrow keys)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(close).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(close).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill('deep')
|
||||
await expect(dialog.getByRole('group', { name: 'Pages' }).getByRole('option')).toHaveCount(2)
|
||||
// populated: input, clear, close, wrapping both ways
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(dialog.getByRole('button', { name: 'Show all matches' })).toBeFocused()
|
||||
await expect(close).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(dialog.getByRole('button', { name: 'Clear search' })).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(dialog.getByRole('group', { name: 'Pages' }).getByRole('option').first()).toBeFocused()
|
||||
await expect(close).toBeFocused()
|
||||
await dialog.getByRole('button', { name: 'Clear search' }).click()
|
||||
await expect(input).toHaveValue('')
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(input).toBeFocused()
|
||||
await expect(close).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(input).toBeFocused()
|
||||
|
||||
|
||||
@@ -106,20 +106,27 @@ test.describe('command palette and search', () => {
|
||||
await recent.click()
|
||||
await expect(page).toHaveURL(/\/app\/person\/1\/?$/)
|
||||
|
||||
// Enter without a selection opens the search page, which survives a refresh
|
||||
// The first result is preselected while typing; ArrowUp above it leaves nothing selected,
|
||||
// and Enter then opens the search page, which survives a refresh
|
||||
await page.locator('body').press('/')
|
||||
await dialog.getByRole('combobox').fill('pet')
|
||||
// Keep the pointer off the results so no option is hovered (hover selects, as in the palette)
|
||||
await page.mouse.move(1, 1)
|
||||
await expect(dialog.getByRole('option', { selected: true })).toHaveCount(1)
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect(dialog.getByRole('option', { selected: true })).toHaveCount(0)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/\/app\/search\/?\?q=pet$/)
|
||||
for (const attempt of ['navigate', 'reload']) {
|
||||
if (attempt === 'reload') await page.reload()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Search results for “pet”')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Results for “pet”')
|
||||
// every matching page, on the pages view (the overview lists the best few)
|
||||
await page.getByRole('navigation', { name: 'Filter results' }).getByRole('link', { name: /^Pages/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/search\/?\?q=pet&view=pages$/)
|
||||
const pageLinks = page.getByRole('region', { name: 'Pages' }).getByRole('link')
|
||||
// Other areas' fixtures add matching pages (e.g. "Owned Pet … Report"); every result must match and the pet tables keep their order
|
||||
await expect(pageLinks.filter({ hasText: /^Pet/ })).toHaveText([/^Pet Species/, /^Pet(?! )/, /^Pet Note/])
|
||||
// Other areas' fixtures add matching pages (e.g. "Owned Pet … Report"); every result must match.
|
||||
// Exact and prefix label matches come first, in tree order: Pet, then Pet Species and Pet Note
|
||||
await expect(pageLinks.filter({ hasText: /^Pet/ })).toHaveText([/^Pet(?! )/, /^Pet Species/, /^Pet Note/])
|
||||
for (const text of await pageLinks.allTextContents()) expect(text).toMatch(/pet/i)
|
||||
}
|
||||
await page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /^Pet Note/ }).click()
|
||||
@@ -136,11 +143,15 @@ test.describe('command palette and search', () => {
|
||||
if (await header.isVisible()) {
|
||||
await header.fill('clone')
|
||||
const results = page.getByRole('listbox', { name: 'Search results' })
|
||||
await expect(results.getByRole('option')).toHaveCount(1)
|
||||
await expect(results.getByRole('option')).toContainText('Clone People')
|
||||
await expect(results.getByRole('option')).toContainText('People App')
|
||||
const clone = results.getByRole('option').filter({ hasText: 'Clone People' })
|
||||
await expect(clone).toHaveCount(1)
|
||||
await expect(clone).toContainText('People App')
|
||||
// the only other entry searches everything
|
||||
await expect(results.getByRole('option')).toHaveCount(2)
|
||||
await expect(results.getByRole('option').last()).toContainText('Search everything for “clone”')
|
||||
await expectTouchTargets(results)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
// the top hit is preselected: Enter opens it
|
||||
await expect(clone).toHaveAttribute('aria-selected', 'true')
|
||||
await page.keyboard.press('Enter')
|
||||
} else {
|
||||
// Narrow viewports replace the header box with a button that opens the same search
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// E2E: GlobalSearch (header "jump to" search) — local matches over pages and recent records, keyboard, Escape
|
||||
// E2E: GlobalSearch (header search) without record search — local matches over pages and recent records, keyboard, Escape
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { setupApiMocks } from './api-mocks'
|
||||
@@ -37,9 +37,10 @@ test.describe('GlobalSearch (header search)', () => {
|
||||
|
||||
test('typing a label lists matching pages with their app', async ({ page }) => {
|
||||
await searchInput(page).fill('peo')
|
||||
const pages = searchDropdown(page).getByRole('group', { name: 'Pages' })
|
||||
await expect(pages.getByRole('option').first()).toContainText('People')
|
||||
await expect(pages.getByRole('option').first()).toContainText('CRM')
|
||||
// "People" starts with the term: it is the top hit
|
||||
const top = searchDropdown(page).getByRole('group', { name: 'Top hits' })
|
||||
await expect(top.getByRole('option').first()).toContainText('People')
|
||||
await expect(top.getByRole('option').first()).toContainText('CRM')
|
||||
})
|
||||
|
||||
test('clicking a page result navigates to it', async ({ page }) => {
|
||||
@@ -68,12 +69,13 @@ test.describe('GlobalSearch (header search)', () => {
|
||||
await expect(page).toHaveURL(/\/app\/search\/?\?q=zzznoresults/, { timeout: 10000 })
|
||||
})
|
||||
|
||||
test('arrow keys move the selection and Enter opens it', async ({ page }) => {
|
||||
test('the first result is selected while typing, arrow keys move it, and Enter opens it', async ({ page }) => {
|
||||
await searchInput(page).fill('crm')
|
||||
const first = searchDropdown(page).getByRole('option').first()
|
||||
await expect(first).toBeVisible({ timeout: 5000 })
|
||||
const options = searchDropdown(page).getByRole('option')
|
||||
await expect(options.first()).toHaveAttribute('aria-selected', 'true', { timeout: 5000 })
|
||||
await searchInput(page).press('ArrowDown')
|
||||
await expect(first).toHaveAttribute('aria-selected', 'true', { timeout: 3000 })
|
||||
await expect(options.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 3000 })
|
||||
await searchInput(page).press('ArrowUp')
|
||||
await searchInput(page).press('Enter')
|
||||
await expect(page).toHaveURL(/\/app\/crm\/?$/, { timeout: 10000 })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// E2E: record search UX — header search-as-you-type with a mocked record search API
|
||||
// (highlights, counts, top hits, failure class, provider fallback), keyboard navigation
|
||||
// (arrows, Enter, Ctrl+Enter new tab), the "/" dialog, and the URL-addressable search page.
|
||||
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
/** METADATA with record search advertised on the person table. */
|
||||
const SEARCHABLE_METADATA = {
|
||||
...METADATA,
|
||||
tables: {
|
||||
...METADATA.tables,
|
||||
person: { ...METADATA.tables.person, searchFields: ['firstName', 'lastName', 'email'] },
|
||||
},
|
||||
}
|
||||
|
||||
/** An extended-contract response: highlights, details, dates, per-table summary. */
|
||||
const ALICE_RESPONSE = {
|
||||
results: [
|
||||
{
|
||||
tableName: 'person', tableLabel: 'People', recordId: '1', recordLabel: 'Alice Johnson', score: 9.1,
|
||||
updatedAt: '2026-10-10T15:00:00Z',
|
||||
highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'alice@example.com', matches: [{ start: 0, end: 5 }] }],
|
||||
details: [{ fieldName: 'status', fieldLabel: 'Status', displayValue: 'Active' }],
|
||||
},
|
||||
{
|
||||
tableName: 'person', tableLabel: 'People', recordId: '4', recordLabel: 'Malice Aforethought', score: 3.2,
|
||||
highlights: [{ fieldName: 'lastName', fieldLabel: 'Last Name', text: 'Aforethought', matches: [] }],
|
||||
},
|
||||
],
|
||||
tables: [{ tableName: 'person', tableLabel: 'People', resultCount: 2, hasMore: true, totalCount: 17, source: 'PROVIDER' }],
|
||||
provider: { status: 'OK' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks metadata with searchable tables and the record search endpoint.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @param handler - Answers `POST /search` (the request body is passed).
|
||||
*/
|
||||
async function setupSearch(page: Page, handler: (route: Route, body: Record<string, unknown>) => Promise<void> | void) {
|
||||
await setupApiMocks(page)
|
||||
await page.route(/\/qqq\/v1\/metaData(\?.*)?$/, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(SEARCHABLE_METADATA) }))
|
||||
await page.route('**/qqq/v1/search', (route) => handler(route, JSON.parse(route.request().postData() ?? '{}')))
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the app shell.
|
||||
*
|
||||
* @param page - The page.
|
||||
*/
|
||||
async function waitForAppReady(page: Page) {
|
||||
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible({ timeout: 30000 })
|
||||
}
|
||||
|
||||
const headerInput = (page: Page) => page.locator('[data-qqq-id="header-search"]').getByRole('combobox', { name: 'Search pages and records' })
|
||||
const headerResults = (page: Page) => page.locator('#header-search-results')
|
||||
|
||||
test.describe('Record search (header)', () => {
|
||||
test('lists grouped, highlighted results with counts and opens the top hit with Enter', async ({ page }) => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
await setupSearch(page, (route, body) => {
|
||||
bodies.push(body)
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
|
||||
await headerInput(page).fill('alice')
|
||||
const people = headerResults(page).getByRole('group', { name: /^People/ })
|
||||
await expect(people).toBeVisible()
|
||||
await expect(people).toHaveAccessibleName('People 17 matches')
|
||||
const top = headerResults(page).getByRole('group', { name: 'Top hits' })
|
||||
await expect(top.getByRole('option').first()).toContainText('Alice Johnson')
|
||||
await expect(top.getByRole('option').first()).toContainText('Email: alice@example.com')
|
||||
await expect(top.locator('mark').first()).toHaveText('Alice')
|
||||
await expect(people.getByRole('option').filter({ hasText: 'See all 17 matches in People' })).toHaveAttribute('href', '/app/search?q=alice&tables=person')
|
||||
expect(bodies.at(-1)).toEqual({ searchTerm: 'alice', tableNames: ['person'], limitPerTable: 6 })
|
||||
|
||||
// the top hit is selected while typing
|
||||
await expect(top.getByRole('option').first()).toHaveAttribute('aria-selected', 'true')
|
||||
await headerInput(page).press('Enter')
|
||||
await expect(page).toHaveURL(/\/app\/person\/1\/?$/)
|
||||
})
|
||||
|
||||
test('arrow keys move through results; Ctrl+Enter opens a new tab', async ({ page, context }) => {
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
|
||||
await headerInput(page).fill('alice')
|
||||
const options = headerResults(page).getByRole('option')
|
||||
await expect(options.first()).toHaveAttribute('aria-selected', 'true')
|
||||
await headerInput(page).press('ArrowDown')
|
||||
await expect(options.nth(1)).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(headerInput(page)).toHaveAttribute('aria-activedescendant', (await options.nth(1).getAttribute('id'))!)
|
||||
await headerInput(page).press('ArrowUp')
|
||||
const [popup] = await Promise.all([context.waitForEvent('page'), headerInput(page).press('Control+Enter')])
|
||||
await expect(popup).toHaveURL(/\/app\/person\/1\/?$/)
|
||||
// this tab stays on the search
|
||||
await expect(headerResults(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test('names the failure class and keeps page results when record search fails', async ({ page }) => {
|
||||
await setupSearch(page, (route) => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Error in record search provider' }) }))
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
|
||||
await headerInput(page).fill('peo')
|
||||
const failure = page.locator('[data-qqq-id="header-search"] [data-qqq-id="search-failure"]')
|
||||
await expect(failure).toContainText('Record search failed: server error (HTTP 500)')
|
||||
await expect(failure).toContainText('Error in record search provider')
|
||||
await expect(failure).toHaveAttribute('data-failure-kind', 'server')
|
||||
await expect(headerResults(page).getByRole('option').filter({ hasText: 'People' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows fallback results under a provider failure notice', async ({ page }) => {
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({
|
||||
...ALICE_RESPONSE,
|
||||
tables: [{ ...ALICE_RESPONSE.tables[0], source: 'FALLBACK' }],
|
||||
provider: { status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused' },
|
||||
}) }))
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
|
||||
await headerInput(page).fill('alice')
|
||||
const fallback = page.locator('[data-qqq-id="header-search"] [data-qqq-id="search-fallback"]')
|
||||
await expect(fallback).toContainText('Search index unavailable — showing basic matches instead')
|
||||
await expect(fallback).toContainText('ConnectException: Connection refused')
|
||||
await expect(headerResults(page).getByRole('option').filter({ hasText: 'Alice Johnson' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('cancels superseded requests while typing', async ({ page }) => {
|
||||
const terms: string[] = []
|
||||
await setupSearch(page, async (route, body) => {
|
||||
terms.push(String(body.searchTerm))
|
||||
// slow answers: the next keystroke supersedes the request in flight
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }).catch(() => undefined)
|
||||
})
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
|
||||
await headerInput(page).pressSequentially('al', { delay: 50 })
|
||||
await page.waitForTimeout(350)
|
||||
await headerInput(page).pressSequentially('ice', { delay: 50 })
|
||||
// debounced: one request per pause, not per keystroke
|
||||
await expect.poll(() => terms).toEqual(['al', 'alice'])
|
||||
await expect(headerResults(page).getByRole('option').filter({ hasText: 'Alice Johnson' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('the open dropdown has no axe violations', async ({ page }) => {
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
await headerInput(page).fill('alice')
|
||||
await expect(headerResults(page).getByRole('group', { name: /^People/ })).toBeVisible()
|
||||
await checkA11y(page)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Record search ("/" dialog)', () => {
|
||||
test('searches records and navigates by keyboard', async ({ page }) => {
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/')
|
||||
await waitForAppReady(page)
|
||||
|
||||
await page.locator('body').press('/')
|
||||
const dialog = page.getByRole('dialog', { name: 'Search' })
|
||||
const input = dialog.getByRole('combobox')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill('alice')
|
||||
await expect(dialog.getByRole('group', { name: /^People/ })).toBeVisible()
|
||||
await input.press('ArrowDown')
|
||||
await input.press('Enter')
|
||||
await expect(page).toHaveURL(/\/app\/person\/4\/?$/)
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Search page', () => {
|
||||
test('is URL-addressable: filters, counts, sort and paging', async ({ page }) => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
await setupSearch(page, (route, body) => {
|
||||
bodies.push(body)
|
||||
if (body.offset === 25) {
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({
|
||||
results: [{ tableName: 'person', tableLabel: 'People', recordId: '99', recordLabel: 'Alice Page Two' }],
|
||||
tables: [{ tableName: 'person', resultCount: 1, hasMore: false, totalCount: 26 }],
|
||||
}) })
|
||||
}
|
||||
if (body.limitPerTable === 25) {
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({
|
||||
results: Array.from({ length: 25 }, (_, index) => ({ tableName: 'person', tableLabel: 'People', recordId: String(index + 1), recordLabel: `Alice ${index + 1}` })),
|
||||
tables: [{ tableName: 'person', resultCount: 25, hasMore: true, totalCount: 26 }],
|
||||
}) })
|
||||
}
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) })
|
||||
})
|
||||
await page.goto('/app/search?q=alice')
|
||||
await waitForAppReady(page)
|
||||
|
||||
const people = page.getByRole('region', { name: 'People' })
|
||||
await expect(people.getByRole('listitem').first()).toContainText('Alice Johnson')
|
||||
await expect(people.getByRole('listitem').first()).toContainText('Status: Active')
|
||||
const filters = page.getByRole('navigation', { name: 'Filter results' })
|
||||
await expect(filters.getByRole('link', { name: /People/ })).toContainText('17')
|
||||
|
||||
await filters.getByRole('link', { name: /People/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/search\/?\?q=alice&tables=person$/)
|
||||
await expect(people.getByRole('listitem')).toHaveCount(25)
|
||||
await expect(people).toContainText('25 of 26')
|
||||
await page.getByRole('button', { name: 'Show more' }).click()
|
||||
await expect(people.getByRole('listitem')).toHaveCount(26)
|
||||
expect(bodies.some((body) => body.offset === 25 && body.limitPerTable === 25)).toBe(true)
|
||||
|
||||
await page.getByRole('combobox', { name: 'Sort' }).selectOption('recent')
|
||||
await expect(page).toHaveURL(/sort=recent/)
|
||||
await expect.poll(() => bodies.some((body) => body.sort === 'RECENT')).toBe(true)
|
||||
})
|
||||
|
||||
test('opens a result in a new tab and has no axe violations', async ({ page, context }) => {
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/app/search?q=alice')
|
||||
await waitForAppReady(page)
|
||||
|
||||
const people = page.getByRole('region', { name: 'People' })
|
||||
await expect(people.getByRole('listitem').first()).toBeVisible()
|
||||
await checkA11y(page)
|
||||
const [popup] = await Promise.all([context.waitForEvent('page'), people.getByRole('link', { name: 'Open Alice Johnson in a new tab' }).click()])
|
||||
await expect(popup).toHaveURL(/\/app\/person\/1\/?$/)
|
||||
})
|
||||
|
||||
test('wide screens preview the selected result beside the list; arrow keys move the preview', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/app/search?q=alice')
|
||||
await waitForAppReady(page)
|
||||
|
||||
const pane = page.getByRole('complementary', { name: 'Record preview' })
|
||||
await expect(pane.getByRole('heading', { level: 2 })).toHaveText('Alice Johnson')
|
||||
await expect(pane.locator('[data-qqq-id="search-preview-fields"]')).toContainText('alice@example.com')
|
||||
await expect(pane.getByRole('link', { name: 'Open' })).toHaveAttribute('href', '/app/person/1')
|
||||
const links = page.locator('[data-qqq-id="search-result-link"]')
|
||||
await links.first().focus()
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(links.nth(1)).toBeFocused()
|
||||
await expect(pane.getByRole('heading', { level: 2 })).toHaveText('Malice Aforethought')
|
||||
// the page itself does not scroll sideways and the list is not a second scroll area
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('phones open the preview in a side sheet', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/app/search?q=alice')
|
||||
await waitForAppReady(page)
|
||||
|
||||
// the card keeps only Preview on phones; the sheet offers New tab and Copy link
|
||||
await expect(page.getByRole('link', { name: 'Open Alice Johnson in a new tab' })).toBeHidden()
|
||||
await page.getByRole('button', { name: 'Preview Alice Johnson' }).click()
|
||||
const sheet = page.getByRole('dialog', { name: 'Preview' })
|
||||
await expect(sheet.getByRole('button', { name: 'Copy link' })).toBeVisible()
|
||||
await expect(sheet.getByRole('heading', { name: 'Alice Johnson' })).toBeVisible()
|
||||
await expect(sheet.locator('[data-qqq-id="search-preview-fields"]')).toContainText('alice@example.com')
|
||||
await sheet.getByRole('button', { name: 'Close preview' }).click()
|
||||
await expect(sheet).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('has no horizontal overflow at phone width', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 760 })
|
||||
await setupSearch(page, (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(ALICE_RESPONSE) }))
|
||||
await page.goto('/app/search?q=alice')
|
||||
await waitForAppReady(page)
|
||||
await expect(page.getByRole('region', { name: 'People' }).getByRole('listitem').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user