From 5c5ec05003d149cebe1c68c701845b5c5fd918b8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 22:14:17 -0700 Subject: [PATCH] Rewrite global search, the command palette, the record page and the error boundary --- src/components/common/CommandPalette.tsx | 158 +++++++++---------- src/components/common/paletteRows.test.ts | 36 +++++ src/components/common/paletteRows.ts | 25 +++ src/components/layout/ErrorBoundary.test.tsx | 78 +++++++++ src/components/layout/ErrorBoundary.tsx | 53 +++---- src/components/views/DynamicViewPage.tsx | 85 ++++------ src/components/views/recordView.test.ts | 48 ++++++ src/components/views/recordView.ts | 33 ++++ src/hooks/useGlobalSearch.ts | 113 +++---------- src/lib/globalSearch.test.ts | 101 ++++++++++++ src/lib/globalSearch.ts | 78 +++++++++ src/pages/adminRoute.ts | 2 +- 12 files changed, 544 insertions(+), 266 deletions(-) create mode 100644 src/components/common/paletteRows.test.ts create mode 100644 src/components/common/paletteRows.ts create mode 100644 src/components/layout/ErrorBoundary.test.tsx create mode 100644 src/components/views/recordView.test.ts create mode 100644 src/components/views/recordView.ts create mode 100644 src/lib/globalSearch.test.ts create mode 100644 src/lib/globalSearch.ts diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index b6074ea..7cc3c32 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -1,27 +1,29 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { LogOut, Route, SunMoon, Wand2, Zap } from 'lucide-react'; -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; + import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; -import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch'; -import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore'; -import { useAccountStore } from '@/stores/accountStore'; -import { useUIStore } from '@/stores/uiStore'; -import { signOut } from '@/services/auth/signOut'; -import { buildCommands, matchCommands, type LaunchJob, type PaletteCommand } from '@/features/commands/commands'; -import { SendingLaunchChoice } from '@/features/sending/SendingSetupCard'; -import { LimitsLaunchChoice } from '@/features/limits/LimitsSummary'; -import { DirectoryLaunchChoice } from '@/features/directory/DirectorySetupCard'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { CertificateLaunchChoice } from '@/features/certificates/CertificateSetupCard'; +import { buildCommands, matchCommands, type LaunchJob, type PaletteCommand } from '@/features/commands/commands'; +import { DirectoryLaunchChoice } from '@/features/directory/DirectorySetupCard'; +import { LimitsLaunchChoice } from '@/features/limits/LimitsSummary'; +import { SendingLaunchChoice } from '@/features/sending/SendingSetupCard'; +import { useGlobalSearch } from '@/hooks/useGlobalSearch'; +import { friendlyName, getActionInfo, getObjectKind } from '@/lib/globalSearch'; +import { signOut } from '@/services/auth/signOut'; +import { useAccountStore } from '@/stores/accountStore'; +import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore'; +import { useUIStore } from '@/stores/uiStore'; + +import { commandRow, entryRow, paletteRows } from './paletteRows'; const COMMAND_ICONS: Record = { action: Zap, @@ -31,89 +33,84 @@ const COMMAND_ICONS: Record = { signOut: LogOut, }; +const LIST_CLASSES = + '[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'; + interface CommandPaletteProps { open: boolean; onOpenChange: (open: boolean) => void; } +/** + * The command bar: commands (server actions, guided setups, a delivery + * trace, the theme, signing out) above the pages, sections and fields that + * match what's typed. + */ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { const { t } = useTranslation(); - const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]); - const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette); const navigate = useNavigate(); + const close = useCallback(() => onOpenChange(false), [onOpenChange]); + const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(close); const searchIndex = useSchemaStore((s) => s.searchIndex); const viewToSection = useSchemaStore((s) => s.viewToSection); const hasPermission = useAccountStore((s) => s.hasPermission); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const theme = useUIStore((s) => s.theme); const toggleTheme = useUIStore((s) => s.toggleTheme); - // inbuxa: a guided setup chosen here still asks "Guided or manual?" first. + // A guided setup chosen here still asks "Guided or manual?" first. const [launch, setLaunch] = useState(null); - // inbuxa: commands (server actions, guided setups, a delivery trace, theme, sign out) beside the pages. - const commandContext = useMemo( + const context = useMemo( () => (schema ? { schema, searchIndex, viewToSection, hasPermission, hasObjectPermission, theme, t } : null), [schema, searchIndex, viewToSection, hasPermission, hasObjectPermission, theme, t], ); - const allCommands = useMemo(() => (commandContext ? buildCommands(commandContext) : []), [commandContext]); + const allCommands = useMemo(() => (context ? buildCommands(context) : []), [context]); const commands = useMemo( - () => (commandContext ? matchCommands(allCommands, debouncedQuery, commandContext) : []), - [allCommands, debouncedQuery, commandContext], + () => (context ? matchCommands(allCommands, debouncedQuery, context) : []), + [allCommands, debouncedQuery, context], ); - // inbuxa: the highlighted row, back on the first result whenever the results - // change. Left to itself, cmdk keeps the old row's value after a new query, - // and once that row is gone Enter picks nothing. - const rowValues = useMemo(() => { - const values = commands.map((c) => `command-${c.id}`); - for (const [type, entries] of groups) { - entries.forEach((entry, idx) => values.push(`${type}-${idx}-${entry.viewName}`)); - } - return values; - }, [commands, groups]); - const rowsKey = rowValues.join('\n'); - const [selected, setSelected] = useState(rowValues[0] ?? ''); + // The highlighted row goes back to the first whenever the rows change. Left + // to itself, cmdk keeps the old row's value after a new query, and once that + // row is gone Enter picks nothing. + const rows = useMemo(() => paletteRows(commands, groups), [commands, groups]); + const rowsKey = rows.join('\n'); + const [selected, setSelected] = useState(rows[0] ?? ''); const [selectedFor, setSelectedFor] = useState(rowsKey); if (selectedFor !== rowsKey) { setSelectedFor(rowsKey); - setSelected(rowValues[0] ?? ''); + setSelected(rows[0] ?? ''); } - const runCommand = useCallback( - (command: PaletteCommand) => { - closePalette(); - switch (command.kind.type) { - case 'navigate': - navigate(command.kind.to); - break; - case 'launch': - setLaunch(command.kind.job); - break; - case 'theme': - toggleTheme(); - break; - case 'signOut': - signOut(navigate); - break; - } - }, - [closePalette, navigate, toggleTheme], - ); + const run = (command: PaletteCommand) => { + close(); + switch (command.kind.type) { + case 'navigate': + navigate(command.kind.to); + break; + case 'launch': + setLaunch(command.kind.job); + break; + case 'theme': + toggleTheme(); + break; + case 'signOut': + signOut(navigate); + break; + } + }; const launchProps = (job: LaunchJob) => ({ open: launch === job, - onOpenChange: (open: boolean) => { - if (!open) setLaunch(null); + onOpenChange: (isOpen: boolean) => { + if (!isOpen) setLaunch(null); }, }); - const groupLabels: Record = useMemo( - () => ({ - link: t('globalSearch.pages', 'Pages'), - form: t('globalSearch.formSections', 'Form Sections'), - field: t('globalSearch.fields', 'Fields'), - }), - [t], - ); + const groupLabels: Record = { + link: t('globalSearch.pages', 'Pages'), + form: t('globalSearch.formSections', 'Form Sections'), + field: t('globalSearch.fields', 'Fields'), + }; useEffect(() => { if (!open) reset(); @@ -124,13 +121,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { {t('globalSearch.title', 'Search')} - + { const Icon = COMMAND_ICONS[command.icon]; return ( - runCommand(command)} - > + run(command)}>
{command.label} @@ -167,21 +154,20 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { })} )} - {Array.from(groups.entries()).map(([type, entries]) => ( + {[...groups].map(([type, entries]) => ( - {entries.map((entry, idx) => { - const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; - const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); - const itemValue = `${type}-${idx}-${entry.viewName}`; - + {entries.map((entry, i) => { + const kind = schema ? getObjectKind(schema, entry.viewName) : null; + const { label, Icon } = getActionInfo(entry.type, kind, t); + const value = entryRow(type, i, entry.viewName); return ( - selectEntry(entry)}> - + selectEntry(entry)}> +
{friendlyName(entry.text)} {entry.breadcrumb}
- {actionLabel} + {label}
); })} diff --git a/src/components/common/paletteRows.test.ts b/src/components/common/paletteRows.test.ts new file mode 100644 index 0000000..b3928bb --- /dev/null +++ b/src/components/common/paletteRows.test.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { commandRow, entryRow, paletteRows } from './paletteRows'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; + +const entry = (viewName: string) => ({ viewName }) as SearchIndexEntry; + +describe('paletteRows', () => { + it('lists commands first, then each group in order, numbering within the group', () => { + const groups = new Map([ + ['link', [entry('x:Domain'), entry('x:Account/User')]], + ['field', [entry('x:Domain')]], + ]); + expect(paletteRows([{ id: 'theme' }, { id: 'trace' }], groups)).toEqual([ + 'command-theme', + 'command-trace', + 'link-0-x:Domain', + 'link-1-x:Account/User', + 'field-0-x:Domain', + ]); + }); + + it('is empty with nothing to show', () => { + expect(paletteRows([], new Map())).toEqual([]); + }); + + it('names rows the way the list items are valued', () => { + expect(commandRow({ id: 'signOut' })).toBe('command-signOut'); + expect(entryRow('form', 3, 'x:Security')).toBe('form-3-x:Security'); + }); +}); diff --git a/src/components/common/paletteRows.ts b/src/components/common/paletteRows.ts new file mode 100644 index 0000000..c154ad3 --- /dev/null +++ b/src/components/common/paletteRows.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** The command bar's rows, each with the value the list highlights it by. */ +import type { PaletteCommand } from '@/features/commands/commands'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; + +export const commandRow = (command: Pick) => `command-${command.id}`; + +export const entryRow = (type: SearchIndexEntry['type'], position: number, viewName: string) => + `${type}-${position}-${viewName}`; + +/** Every row's value, top to bottom: commands, then each group of results. */ +export function paletteRows( + commands: Pick[], + groups: Map, +): string[] { + return [ + ...commands.map(commandRow), + ...[...groups].flatMap(([type, entries]) => entries.map((entry, i) => entryRow(type, i, entry.viewName))), + ]; +} diff --git a/src/components/layout/ErrorBoundary.test.tsx b/src/components/layout/ErrorBoundary.test.tsx new file mode 100644 index 0000000..4e803bb --- /dev/null +++ b/src/components/layout/ErrorBoundary.test.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { ErrorBoundary } from './ErrorBoundary'; + +let failing = true; +function Fragile() { + if (failing) throw new Error('the part broke'); + return

all good

; +} + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + vi.spyOn(console, 'error').mockImplementation(() => {}); + failing = true; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +describe('ErrorBoundary', () => { + it('shows what failed and lets it be tried again', () => { + act(() => + root.render( + + + , + ), + ); + const alert = host.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Something went wrong'); + expect(alert?.textContent).toContain('the part broke'); + expect(console.error).toHaveBeenCalledWith('[ErrorBoundary] Caught error:', expect.any(Error), expect.anything()); + + failing = false; + act(() => host.querySelector('button')!.click()); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(host.textContent).toBe('all good'); + }); + + it('shows its own fallback when given one', () => { + act(() => + root.render( + fallback

}> + +
, + ), + ); + expect(host.textContent).toBe('fallback'); + }); + + it('draws its children while nothing fails', () => { + failing = false; + act(() => + root.render( + + + , + ), + ); + expect(host.textContent).toBe('all good'); + }); +}); diff --git a/src/components/layout/ErrorBoundary.tsx b/src/components/layout/ErrorBoundary.tsx index 7fa4c3a..75948cb 100644 --- a/src/components/layout/ErrorBoundary.tsx +++ b/src/components/layout/ErrorBoundary.tsx @@ -1,7 +1,7 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ import { Component, type ErrorInfo, type ReactNode } from 'react'; @@ -10,6 +10,7 @@ import i18n from '@/i18n'; interface ErrorBoundaryProps { children: ReactNode; + /** Shown instead of the standard message when something below fails. */ fallback?: ReactNode; } @@ -18,45 +19,35 @@ interface ErrorBoundaryState { error: Error | null; } +/** Keeps a failing part of the page from taking the rest down, with a way to try it again. */ export class ErrorBoundary extends Component { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } + state: ErrorBoundaryState = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; } - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('[ErrorBoundary] Caught error:', error, errorInfo); + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[ErrorBoundary] Caught error:', error, info); } - handleReset = () => { - this.setState({ hasError: false, error: null }); - }; + private tryAgain = () => this.setState({ hasError: false, error: null }); render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback; - } - - return ( -
-
- {i18n.t('errorBoundary.title', 'Something went wrong')} -
-

- {this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')} -

- + if (!this.state.hasError) return this.props.children; + if (this.props.fallback) return this.props.fallback; + return ( +
+
+ {i18n.t('errorBoundary.title', 'Something went wrong')}
- ); - } - - return this.props.children; +

+ {this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')} +

+ +
+ ); } } diff --git a/src/components/views/DynamicViewPage.tsx b/src/components/views/DynamicViewPage.tsx index cb1673e..dfc77c1 100644 --- a/src/components/views/DynamicViewPage.tsx +++ b/src/components/views/DynamicViewPage.tsx @@ -1,70 +1,63 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, Loader2 } from 'lucide-react'; + import { Button } from '@/components/ui/button'; +import { resolveList, resolveObject } from '@/lib/schemaResolver'; +import { getAccountId, jmapGet } from '@/services/jmap/client'; import { useSchemaStore } from '@/stores/schemaStore'; -import { resolveObject, resolveList } from '@/lib/schemaResolver'; -import { coerceLabel } from '@/lib/objectOptions'; -import { jmapGet, getAccountId } from '@/services/jmap/client'; + import { DynamicView } from './DynamicView'; +import { recordFrom, recordTitle } from './recordView'; interface DynamicViewPageProps { viewName: string; objectId: string; } +/** One record, read-only, for an account that may see it but not change it. */ export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) { const { t } = useTranslation(); const navigate = useNavigate(); const schema = useSchemaStore((s) => s.schema); - const [data, setData] = useState | null>(null); + const [record, setRecord] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!schema) return; - let cancelled = false; - - async function load() { + let live = true; + (async () => { try { - const resolved = resolveObject(schema!, viewName); + const resolved = resolveObject(schema, viewName); if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object')); - - const accountId = getAccountId(resolved.objectName); - const responses = await jmapGet(resolved.objectName, accountId, [objectId]); - - if (cancelled) return; - - const getResult = responses.find(([name]) => name.endsWith('/get')); - if (!getResult) throw new Error(t('view.noGetResponse', 'No get response')); - - const result = getResult[1] as { list?: Array> }; - const item = result.list?.[0]; - if (!item) throw new Error(t('view.objectNotFound', 'Object not found')); - - setData(item); + const responses = await jmapGet(resolved.objectName, getAccountId(resolved.objectName), [objectId]); + if (live) setRecord(recordFrom(responses, t)); } catch (e) { - if (!cancelled) { - setError(e instanceof Error ? e.message : t('view.failedToLoad', 'Failed to load')); - } + if (live) setError(e instanceof Error ? e.message : t('view.failedToLoad', 'Failed to load')); } finally { - if (!cancelled) setLoading(false); + if (live) setLoading(false); } - } - - load(); + })(); return () => { - cancelled = true; + live = false; }; }, [schema, viewName, objectId, t]); + const back = ( + + ); + if (loading) { return (
@@ -73,13 +66,10 @@ export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) { ); } - if (error || !data || !schema) { + if (error || !record || !schema) { return (
- + {back}
{error ?? t('view.failedToLoad', 'Failed to load')}
); @@ -88,26 +78,15 @@ export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) { const resolved = resolveObject(schema, viewName); if (!resolved) return null; - const list = resolveList(schema, viewName, resolved.objectName); - const labelProp = list?.labelProperty ?? list?.columns?.[0]?.name; - const rawLabel = labelProp ? coerceLabel(data[labelProp], '') : ''; - const displayName = rawLabel.length > 0 ? rawLabel : undefined; - const singularName = list?.singularName ?? resolved.objectName.replace(/^x:/, ''); - const title = displayName - ? `${singularName.charAt(0).toUpperCase() + singularName.slice(1)}: ${displayName}` - : singularName.charAt(0).toUpperCase() + singularName.slice(1); - return (
- -

{title}

+ {back} +

+ {recordTitle(resolveList(schema, viewName, resolved.objectName), resolved.objectName, record)} +

- - +
); } diff --git a/src/components/views/recordView.test.ts b/src/components/views/recordView.test.ts new file mode 100644 index 0000000..65f1e1a --- /dev/null +++ b/src/components/views/recordView.test.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import type { TFunction } from 'i18next'; +import { recordFrom, recordTitle } from './recordView'; +import type { JmapMethodResponse } from '@/types/jmap'; +import type { List } from '@/types/schema'; + +const t = ((_key: string, fallback: string) => fallback) as unknown as TFunction; + +describe('recordFrom', () => { + it('takes the first record of the get answer', () => { + const responses = [ + ['x:Domain/query', { ids: ['b'] }, '0'], + ['x:Domain/get', { list: [{ id: 'b', name: 'example.test' }, { id: 'c' }] }, '0'], + ] as unknown as JmapMethodResponse[]; + expect(recordFrom(responses, t)).toEqual({ id: 'b', name: 'example.test' }); + }); + + it('says what went wrong without one', () => { + expect(() => recordFrom([], t)).toThrow('No get response'); + const empty = [['x:Domain/get', { list: [], notFound: ['b'] }, '0']] as unknown as JmapMethodResponse[]; + expect(() => recordFrom(empty, t)).toThrow('Object not found'); + }); +}); + +describe('recordTitle', () => { + const list = { singularName: 'domain', labelProperty: 'name', columns: [{ name: 'id' }] } as unknown as List; + + it('names the kind, then the record by its label property', () => { + expect(recordTitle(list, 'x:Domain', { name: 'example.test' })).toBe('Domain: example.test'); + }); + + it('falls back to the first column, then to the kind alone', () => { + const noLabel = { singularName: 'tenant', columns: [{ name: 'description' }] } as unknown as List; + expect(recordTitle(noLabel, 'x:Tenant', { description: 'Acme' })).toBe('Tenant: Acme'); + expect(recordTitle(noLabel, 'x:Tenant', { description: '' })).toBe('Tenant'); + expect(recordTitle(list, 'x:Domain', {})).toBe('Domain'); + }); + + it('uses the object name when there is no list', () => { + expect(recordTitle(null, 'x:WebHook', { name: 'x' })).toBe('WebHook'); + }); +}); diff --git a/src/components/views/recordView.ts b/src/components/views/recordView.ts new file mode 100644 index 0000000..9d57611 --- /dev/null +++ b/src/components/views/recordView.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** Reading one record for the read-only page, and naming it. */ +import type { TFunction } from 'i18next'; +import { coerceLabel } from '@/lib/objectOptions'; +import type { JmapMethodResponse } from '@/types/jmap'; +import type { List } from '@/types/schema'; + +/** The record from a `/get` answer; throws, with a message to show, when there isn't one. */ +export function recordFrom(responses: JmapMethodResponse[], t: TFunction): Record { + const answer = responses.find(([name]) => name.endsWith('/get')); + if (!answer) throw new Error(t('view.noGetResponse', 'No get response')); + const record = (answer[1] as { list?: Record[] }).list?.[0]; + if (!record) throw new Error(t('view.objectNotFound', 'Object not found')); + return record; +} + +const capitalized = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +/** + * The page heading: the kind of record, then its name if it has one, + * as the list labels it (its label property, else its first column). + */ +export function recordTitle(list: List | null, objectName: string, record: Record): string { + const kind = capitalized(list?.singularName ?? objectName.replace(/^x:/, '')); + const labelProperty = list?.labelProperty ?? list?.columns?.[0]?.name; + const name = labelProperty ? coerceLabel(record[labelProperty], '') : ''; + return name ? `${kind}: ${name}` : kind; +} diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index 0e426d6..9d2fd1c 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -1,133 +1,56 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { List, Plus, Settings } from 'lucide-react'; -import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore'; +import { entryPath, getObjectKind, groupByType, searchEntries } from '@/lib/globalSearch'; import { useAccountStore } from '@/stores/accountStore'; -import { resolveObject } from '@/lib/schemaResolver'; -import type { Schema } from '@/types/schema'; +import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore'; -const MAX_RESULTS = 15; const DEBOUNCE_MS = 300; -const TYPE_ORDER: Record = { - link: 0, - form: 1, - field: 2, -}; - -export type ObjectKind = 'singleton' | 'object' | null; - -export function getObjectKind(schema: Schema, viewName: string): ObjectKind { - const resolved = resolveObject(schema, viewName); - if (!resolved) return null; - return resolved.objectType.type === 'singleton' ? 'singleton' : 'object'; -} - -export function getActionInfo( - entryType: SearchIndexEntry['type'], - objectKind: ObjectKind, - t: (key: string, fallback: string) => string, -): { label: string; Icon: typeof List } { - if (objectKind === 'singleton') { - return { label: t('globalSearch.settings', 'Settings'), Icon: Settings }; - } - return entryType === 'link' - ? { label: t('globalSearch.list', 'List'), Icon: List } - : { label: t('globalSearch.create', 'Create'), Icon: Plus }; -} - -function getNavigationPath( - entryType: SearchIndexEntry['type'], - objectKind: ObjectKind, - section: string, - viewName: string, -): string { - if (objectKind === 'singleton') return `/${section}/${viewName}/singleton`; - return entryType === 'link' ? `/${section}/${viewName}` : `/${section}/${viewName}/new`; -} - -export function friendlyName(viewName: string): string { - const stripped = viewName.replace(/^x:/, ''); - const parts = stripped.split('/'); - return parts[parts.length - 1]; -} - +/** The command bar's search: the query as typed, results for it once typing pauses, and opening one. */ export function useGlobalSearch(onAfterSelect?: () => void) { const navigate = useNavigate(); const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); - const timerRef = useRef | null>(null); + const timer = useRef | undefined>(undefined); const schema = useSchemaStore((s) => s.schema); const searchIndex = useSchemaStore((s) => s.searchIndex); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); - const handleQueryChange = useCallback((value: string) => { + const type = useCallback((value: string) => { setQuery(value); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS); + clearTimeout(timer.current); + timer.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS); }, []); - useEffect(() => { - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, []); + useEffect(() => () => clearTimeout(timer.current), []); - const results = useMemo(() => { - if (!debouncedQuery.trim() || !schema) return []; - - const tokens = debouncedQuery - .toLowerCase() - .split(/\s+/) - .filter((s) => s.length > 0); - if (tokens.length === 0) return []; - - const filtered = searchIndex.filter((entry) => { - const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase(); - for (const token of tokens) { - if (!haystack.includes(token)) return false; - } - const resolved = resolveObject(schema, entry.viewName); - if (!resolved) return false; - return hasObjectPermission(resolved.permissionPrefix, 'Get'); - }); - - filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); - return filtered.slice(0, MAX_RESULTS); - }, [debouncedQuery, searchIndex, schema, hasObjectPermission]); - - const groups = useMemo(() => { - const map = new Map(); - for (const entry of results) { - const arr = map.get(entry.type); - if (arr) arr.push(entry); - else map.set(entry.type, [entry]); - } - return map; - }, [results]); + const groups = useMemo( + () => + groupByType(searchEntries(searchIndex, debouncedQuery, schema, (prefix) => hasObjectPermission(prefix, 'Get'))), + [debouncedQuery, searchIndex, schema, hasObjectPermission], + ); const selectEntry = useCallback( (entry: SearchIndexEntry) => { if (!schema) return; - const objectKind = getObjectKind(schema, entry.viewName); - navigate(getNavigationPath(entry.type, objectKind, entry.section, entry.viewName)); + navigate(entryPath(entry, getObjectKind(schema, entry.viewName))); onAfterSelect?.(); }, [schema, navigate, onAfterSelect], ); const reset = useCallback(() => { - if (timerRef.current) clearTimeout(timerRef.current); + clearTimeout(timer.current); setQuery(''); setDebouncedQuery(''); }, []); - return { query, setQuery: handleQueryChange, debouncedQuery, results, groups, selectEntry, reset, schema }; + return { query, setQuery: type, debouncedQuery, groups, selectEntry, reset, schema }; } diff --git a/src/lib/globalSearch.test.ts b/src/lib/globalSearch.test.ts new file mode 100644 index 0000000..5b2605e --- /dev/null +++ b/src/lib/globalSearch.test.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { List, Plus, Settings } from 'lucide-react'; +import { + MAX_RESULTS, + entryPath, + friendlyName, + getActionInfo, + getObjectKind, + groupByType, + searchEntries, +} from './globalSearch'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; +import type { Schema } from '@/types/schema'; + +const schema = { + objects: { + 'x:Domain': { type: 'object', description: '', permissionPrefix: 'sysDomain' }, + 'x:Account': { type: 'object', description: '', permissionPrefix: 'sysAccount' }, + 'x:Account/User': { type: 'view', objectName: 'x:Account' }, + 'x:Security': { type: 'singleton', description: '', permissionPrefix: 'sysSecurity' }, + }, +} as unknown as Schema; + +const entry = (text: string, type: SearchIndexEntry['type'], viewName: string, keywords?: string[]) => + ({ text, type, viewName, section: 'Management', breadcrumb: '', keywords }) as SearchIndexEntry; + +const index = [ + entry('Domain name', 'field', 'x:Domain'), + entry('DKIM signing', 'form', 'x:Domain'), + entry('Domains', 'link', 'x:Domain', ['dns', 'zone']), + entry('People', 'link', 'x:Account/User', ['users']), + entry('Security', 'link', 'x:Security'), + entry('Gone', 'link', 'x:Missing'), +]; +const all = () => true; + +describe('searchEntries', () => { + it('needs every word, in the text or the keywords, ignoring case', () => { + expect(searchEntries(index, 'domain', schema, all).map((e) => e.text)).toEqual(['Domains', 'Domain name']); + expect(searchEntries(index, ' DNS zone ', schema, all).map((e) => e.text)).toEqual(['Domains']); + expect(searchEntries(index, 'domain users', schema, all)).toEqual([]); + }); + + it('puts pages first, then sections, then fields', () => { + const results = searchEntries(index, 'd', schema, all); + expect(results.map((e) => e.type)).toEqual(['link', 'form', 'field']); + }); + + it('leaves out what the account may not read, and unknown views', () => { + expect(searchEntries(index, 'people', schema, (p) => p !== 'sysAccount')).toEqual([]); + expect(searchEntries(index, 'gone', schema, all)).toEqual([]); + }); + + it('finds nothing without a query or a schema', () => { + expect(searchEntries(index, ' ', schema, all)).toEqual([]); + expect(searchEntries(index, 'domain', null, all)).toEqual([]); + }); + + it('keeps at most fifteen', () => { + const many = Array.from({ length: 20 }, (_, i) => entry(`Domain ${i}`, 'field', 'x:Domain')); + expect(searchEntries(many, 'domain', schema, all)).toHaveLength(MAX_RESULTS); + }); +}); + +describe('groupByType', () => { + it('groups by kind in order of first appearance', () => { + const groups = groupByType([index[2], index[0], index[3]]); + expect([...groups.keys()]).toEqual(['link', 'field']); + expect(groups.get('link')!.map((e) => e.text)).toEqual(['Domains', 'People']); + }); +}); + +describe('where a result leads', () => { + it('opens settings as the singleton, pages as lists, the rest as a new record', () => { + expect(getObjectKind(schema, 'x:Security')).toBe('singleton'); + expect(getObjectKind(schema, 'x:Account/User')).toBe('object'); + expect(getObjectKind(schema, 'x:Missing')).toBeNull(); + expect(entryPath(index[4], 'singleton')).toBe('/Management/x:Security/singleton'); + expect(entryPath(index[2], 'object')).toBe('/Management/x:Domain'); + expect(entryPath(index[0], 'object')).toBe('/Management/x:Domain/new'); + }); + + it('labels the result to match', () => { + const t = (_k: string, fallback: string) => fallback; + expect(getActionInfo('field', 'singleton', t)).toEqual({ label: 'Settings', Icon: Settings }); + expect(getActionInfo('link', 'object', t)).toEqual({ label: 'List', Icon: List }); + expect(getActionInfo('form', null, t)).toEqual({ label: 'Create', Icon: Plus }); + }); + + it('names a view after its last part', () => { + expect(friendlyName('x:Account/User')).toBe('User'); + expect(friendlyName('x:Domain')).toBe('Domain'); + expect(friendlyName('Plain text')).toBe('Plain text'); + }); +}); diff --git a/src/lib/globalSearch.ts b/src/lib/globalSearch.ts new file mode 100644 index 0000000..9c59cf4 --- /dev/null +++ b/src/lib/globalSearch.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** Searching the schema's pages, sections and fields, and where a result leads. */ +import { List, Plus, Settings } from 'lucide-react'; +import { resolveObject } from '@/lib/schemaResolver'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; +import type { Schema } from '@/types/schema'; + +export const MAX_RESULTS = 15; + +/** Pages first, then form sections, then fields. */ +const TYPE_ORDER: Record = { link: 0, form: 1, field: 2 }; + +export type ObjectKind = 'singleton' | 'object' | null; + +export function getObjectKind(schema: Schema, viewName: string): ObjectKind { + const resolved = resolveObject(schema, viewName); + if (!resolved) return null; + return resolved.objectType.type === 'singleton' ? 'singleton' : 'object'; +} + +/** What choosing a result does: open settings, open a list, or create a record. */ +export function getActionInfo( + entryType: SearchIndexEntry['type'], + objectKind: ObjectKind, + t: (key: string, fallback: string) => string, +): { label: string; Icon: typeof List } { + if (objectKind === 'singleton') return { label: t('globalSearch.settings', 'Settings'), Icon: Settings }; + return entryType === 'link' + ? { label: t('globalSearch.list', 'List'), Icon: List } + : { label: t('globalSearch.create', 'Create'), Icon: Plus }; +} + +/** Where a result leads: settings open as the singleton, pages as their list, sections and fields as a new record. */ +export function entryPath(entry: SearchIndexEntry, objectKind: ObjectKind): string { + const page = `/${entry.section}/${entry.viewName}`; + if (objectKind === 'singleton') return `${page}/singleton`; + return entry.type === 'link' ? page : `${page}/new`; +} + +/** A view name without the `x:` prefix or its parents: `x:Account/User` is `User`. */ +export function friendlyName(viewName: string): string { + return viewName.replace(/^x:/, '').split('/').pop() ?? ''; +} + +/** + * The entries holding every word of the query, in their text or keywords, + * that the account may read; pages first, at most fifteen. + */ +export function searchEntries( + index: SearchIndexEntry[], + query: string, + schema: Schema | null, + canGet: (permissionPrefix: string) => boolean, +): SearchIndexEntry[] { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + if (words.length === 0 || !schema) return []; + return index + .filter((entry) => { + const text = `${entry.text} ${entry.keywords?.join(' ') ?? ''}`.toLowerCase(); + if (!words.every((word) => text.includes(word))) return false; + const resolved = resolveObject(schema, entry.viewName); + return resolved !== null && canGet(resolved.permissionPrefix); + }) + .sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]) + .slice(0, MAX_RESULTS); +} + +/** Results by kind, in the order the kinds first appear. */ +export function groupByType(entries: SearchIndexEntry[]): Map { + const groups = new Map(); + for (const entry of entries) groups.set(entry.type, [...(groups.get(entry.type) ?? []), entry]); + return groups; +} diff --git a/src/pages/adminRoute.ts b/src/pages/adminRoute.ts index cc806f4..959c228 100644 --- a/src/pages/adminRoute.ts +++ b/src/pages/adminRoute.ts @@ -17,7 +17,7 @@ import { type HasPermission, } from '@/lib/layout'; import { sectionLandingLink } from '@/lib/lastVisited'; -import { friendlyName } from '@/hooks/useGlobalSearch'; +import { friendlyName } from '@/lib/globalSearch'; /** * The page and record an address names, from what follows the section. -- 2.54.0