Rewrite global search, the command palette, the record page and the error boundary #133

Merged
jcoffey-dev merged 1 commits from phase5/search into main 2026-10-19 05:21:51 +00:00
12 changed files with 544 additions and 266 deletions

No files matched your search

+72 -86
View File
@@ -1,27 +1,29 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<PaletteCommand['icon'], typeof Zap> = {
action: Zap,
@@ -31,89 +33,84 @@ const COMMAND_ICONS: Record<PaletteCommand['icon'], typeof Zap> = {
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<LaunchJob | null>(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<SearchIndexEntry['type'], string> = useMemo(
() => ({
link: t('globalSearch.pages', 'Pages'),
form: t('globalSearch.formSections', 'Form Sections'),
field: t('globalSearch.fields', 'Fields'),
}),
[t],
);
const groupLabels: Record<SearchIndexEntry['type'], string> = {
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) {
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
<Command
shouldFilter={false}
value={selected}
onValueChange={setSelected}
loop
className="[&_[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"
>
<Command shouldFilter={false} value={selected} onValueChange={setSelected} loop className={LIST_CLASSES}>
<CommandInput
placeholder={t('globalSearch.placeholderActions', 'Search pages and settings, or run an action...')}
value={query}
@@ -152,11 +143,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
{commands.map((command) => {
const Icon = COMMAND_ICONS[command.icon];
return (
<CommandItem
key={command.id}
value={`command-${command.id}`}
onSelect={() => runCommand(command)}
>
<CommandItem key={command.id} value={commandRow(command)} onSelect={() => run(command)}>
<Icon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{command.label}</span>
@@ -167,21 +154,20 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
})}
</CommandGroup>
)}
{Array.from(groups.entries()).map(([type, entries]) => (
{[...groups].map(([type, entries]) => (
<CommandGroup key={type} heading={groupLabels[type]}>
{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 (
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<CommandItem key={value} value={value} onSelect={() => selectEntry(entry)}>
<Icon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
</div>
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{label}</span>
</CommandItem>
);
})}
+36
View File
@@ -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<SearchIndexEntry['type'], SearchIndexEntry[]>([
['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');
});
});
+25
View File
@@ -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<PaletteCommand, 'id'>) => `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<PaletteCommand, 'id'>[],
groups: Map<SearchIndexEntry['type'], SearchIndexEntry[]>,
): string[] {
return [
...commands.map(commandRow),
...[...groups].flatMap(([type, entries]) => entries.map((entry, i) => entryRow(type, i, entry.viewName))),
];
}
@@ -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 <p>all good</p>;
}
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(
<ErrorBoundary>
<Fragile />
</ErrorBoundary>,
),
);
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(
<ErrorBoundary fallback={<p>fallback</p>}>
<Fragile />
</ErrorBoundary>,
),
);
expect(host.textContent).toBe('fallback');
});
it('draws its children while nothing fails', () => {
failing = false;
act(() =>
root.render(
<ErrorBoundary>
<Fragile />
</ErrorBoundary>,
),
);
expect(host.textContent).toBe('all good');
});
});
+22 -31
View File
@@ -1,7 +1,7 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<ErrorBoundaryProps, ErrorBoundaryState> {
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 (
<div role="alert" className="flex flex-col items-center justify-center gap-4 p-8">
<div className="text-destructive text-lg font-semibold">
{i18n.t('errorBoundary.title', 'Something went wrong')}
</div>
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')}
</p>
<Button variant="outline" onClick={this.handleReset}>
{i18n.t('errorBoundary.tryAgain', 'Try Again')}
</Button>
if (!this.state.hasError) return this.props.children;
if (this.props.fallback) return this.props.fallback;
return (
<div role="alert" className="flex flex-col items-center justify-center gap-4 p-8">
<div className="text-destructive text-lg font-semibold">
{i18n.t('errorBoundary.title', 'Something went wrong')}
</div>
);
}
return this.props.children;
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')}
</p>
<Button variant="outline" onClick={this.tryAgain}>
{i18n.t('errorBoundary.tryAgain', 'Try Again')}
</Button>
</div>
);
}
}
+32 -53
View File
@@ -1,70 +1,63 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<Record<string, unknown> | null>(null);
const [record, setRecord] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<Record<string, unknown>> };
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 = (
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
);
if (loading) {
return (
<div className="flex items-center justify-center p-12">
@@ -73,13 +66,10 @@ export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) {
);
}
if (error || !data || !schema) {
if (error || !record || !schema) {
return (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
{back}
<div className="text-destructive p-4">{error ?? t('view.failedToLoad', 'Failed to load')}</div>
</div>
);
@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<h1 className="text-xl font-semibold">{title}</h1>
{back}
<h1 className="text-xl font-semibold">
{recordTitle(resolveList(schema, viewName, resolved.objectName), resolved.objectName, record)}
</h1>
</div>
<DynamicView schema={schema} objectName={resolved.objectName} viewName={viewName} data={data} />
<DynamicView schema={schema} objectName={resolved.objectName} viewName={viewName} data={record} />
</div>
);
}
+48
View File
@@ -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');
});
});
+33
View File
@@ -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<string, unknown> {
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<string, unknown>[] }).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, unknown>): 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;
}
+18 -95
View File
@@ -1,133 +1,56 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<SearchIndexEntry['type'], number> = {
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<ReturnType<typeof setTimeout> | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | 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<SearchIndexEntry['type'], SearchIndexEntry[]>();
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 };
}
+101
View File
@@ -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');
});
});
+78
View File
@@ -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<SearchIndexEntry['type'], number> = { 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<SearchIndexEntry['type'], SearchIndexEntry[]> {
const groups = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
for (const entry of entries) groups.set(entry.type, [...(groups.get(entry.type) ?? []), entry]);
return groups;
}
+1 -1
View File
@@ -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.