Rewrite global search, the command palette, the record page and the error boundary #133
No files matched your search
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user