Replace the TopBar dropdown search with a cmdk-based command palette opened via a visible trigger button (with platform-aware shortcut badge) or the Ctrl+K/Cmd+K global shortcut. Shared search logic moves to a useGlobalSearch hook; the palette reuses the existing ui/command components.
137 lines
4.5 KiB
TypeScript
137 lines
4.5 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
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 { useAccountStore } from '@/stores/accountStore';
|
|
import { resolveObject } from '@/lib/schemaResolver';
|
|
import type { Schema } from '@/types/schema';
|
|
|
|
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 (entryType === 'link') {
|
|
return objectKind === 'singleton'
|
|
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
: { label: t('globalSearch.list', 'List'), Icon: List };
|
|
}
|
|
return objectKind === 'singleton'
|
|
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
|
}
|
|
|
|
function getNavigationPath(
|
|
entryType: SearchIndexEntry['type'],
|
|
objectKind: ObjectKind,
|
|
section: string,
|
|
viewName: string,
|
|
): string {
|
|
if (entryType === 'link') {
|
|
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}`;
|
|
}
|
|
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}/new`;
|
|
}
|
|
|
|
export function friendlyName(viewName: string): string {
|
|
const stripped = viewName.replace(/^x:/, '');
|
|
const parts = stripped.split('/');
|
|
return parts[parts.length - 1];
|
|
}
|
|
|
|
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 schema = useSchemaStore((s) => s.schema);
|
|
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
|
|
|
const handleQueryChange = useCallback((value: string) => {
|
|
setQuery(value);
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.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 selectEntry = useCallback(
|
|
(entry: SearchIndexEntry) => {
|
|
if (!schema) return;
|
|
const objectKind = getObjectKind(schema, entry.viewName);
|
|
navigate(getNavigationPath(entry.type, objectKind, entry.section, entry.viewName));
|
|
onAfterSelect?.();
|
|
},
|
|
[schema, navigate, onAfterSelect],
|
|
);
|
|
|
|
const reset = useCallback(() => {
|
|
setQuery('');
|
|
setDebouncedQuery('');
|
|
}, []);
|
|
|
|
return { query, setQuery: handleQueryChange, results, groups, selectEntry, reset, schema };
|
|
}
|