diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index dc3268673..9746929b0 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -8,48 +8,66 @@ import { } from '@documenso/lib/constants/keyboard-shortcuts'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; import { dynamicActivate } from '@documenso/lib/utils/i18n'; -import { isPersonalLayout } from '@documenso/lib/utils/organisations'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; -import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandShortcut, -} from '@documenso/ui/primitives/command'; +import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@documenso/ui/primitives/command'; +import { Dialog, DialogContent } from '@documenso/ui/primitives/dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { keepPreviousData } from '@tanstack/react-query'; -import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { commandScore } from 'cmdk/dist/command-score'; +import { + ArrowLeftIcon, + CheckIcon, + CornerDownLeftIcon, + FileTextIcon, + GlobeIcon, + KeyRoundIcon, + LanguagesIcon, + LayoutTemplateIcon, + LoaderIcon, + MonitorIcon, + MoonIcon, + PaletteIcon, + SettingsIcon, + SunIcon, + UserIcon, +} from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { useNavigate } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { Theme, useTheme } from 'remix-themes'; +import { match } from 'ts-pattern'; import { useOptionalCurrentTeam } from '~/providers/team'; -const SETTINGS_PAGES = [ - { - label: msg`Settings`, - path: '/settings', - shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), - }, - { label: msg`Profile`, path: '/settings/profile' }, - { label: msg`Password`, path: '/settings/password' }, -]; +import type { PromptCategory, PromptItem } from './app-command-menu.types'; +import { useAdminSearchCategories } from './use-admin-search-categories'; + +/** + * The maximum number of results the personal document/template searches return. + */ +const PERSONAL_SEARCH_RESULTS_CAP = 20; + +/** + * The minimum score for a hardcoded item to count as a fuzzy match. + * + * Prevents searches like "pass" showing "Templates" + */ +const MIN_FUZZY_SCORE = 0.1; + +const PROMPT_GROUP_CLASSNAME = + 'border-0 p-0 pt-1 [&_[cmdk-group-heading]]:mt-0 [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.07em] [&_[cmdk-group-heading]]:opacity-100'; export type AppCommandMenuProps = { open?: boolean; onOpenChange?: (_open: boolean) => void; }; -export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { +export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => { const { _ } = useLingui(); const { organisations } = useSession(); @@ -58,18 +76,42 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const [isOpen, setIsOpen] = useState(() => open ?? false); const [search, setSearch] = useState(''); - const [pages, setPages] = useState([]); + const [activePage, setActivePage] = useState<'theme' | 'language' | null>(null); + const [activeChip, setActiveChip] = useState('all'); + const [commandValue, setCommandValue] = useState(''); + + // Support both controlled and uncontrolled usage. + const isPromptOpen = open ?? isOpen; const debouncedSearch = useDebouncedValue(search, 200); - const hasValidSearch = debouncedSearch.trim().length > 0; + const trimmedSearch = debouncedSearch.trim(); - const { data: searchDocumentsData, isFetching: isFetchingDocuments } = trpcReact.document.search.useQuery( + // cmdk keeps a stale selection value behind when the entire result list is + // replaced, which prevents it from auto selecting the first new result. + // Controlling the value and clearing it whenever the query changes makes + // cmdk reliably select the first item once the new results register. + useEffect(() => { + setCommandValue(''); + }, [debouncedSearch, activePage]); + + const hasValidSearch = trimmedSearch.length > 0; + + const { + data: searchDocumentsData, + isFetching: isFetchingDocuments, + isError: isDocumentsSearchError, + } = trpcReact.document.search.useQuery( { query: debouncedSearch, }, { - enabled: open === true && hasValidSearch, + // Sub pages filter their own local lists, so the searches pause while + // one is open. + enabled: isPromptOpen && activePage === null && hasValidSearch, placeholderData: keepPreviousData, + // Show immediate failure instead of a long spinner. + retry: false, + // Do not batch this due to relatively long request time compared to // other queries which are generally batched with this. ...SKIP_QUERY_BATCH_META, @@ -77,272 +119,730 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { }, ); - const { data: searchTemplatesData, isFetching: isFetchingTemplates } = trpcReact.template.search.useQuery( + const { + data: searchTemplatesData, + isFetching: isFetchingTemplates, + isError: isTemplatesSearchError, + } = trpcReact.template.search.useQuery( { query: debouncedSearch, }, { - enabled: open === true && hasValidSearch, + enabled: isPromptOpen && activePage === null && hasValidSearch, placeholderData: keepPreviousData, + retry: false, ...SKIP_QUERY_BATCH_META, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }, ); - const teamUrl = useMemo(() => { - let teamUrl = currentTeam?.url || null; + const { + isUserAdmin, + categories: adminSearchCategories, + isFetching: isFetchingAdminSearch, + isError: isAdminSearchError, + } = useAdminSearchCategories({ + query: trimmedSearch, + open: isPromptOpen && activePage === null, + }); - if (!teamUrl && isPersonalLayout(organisations)) { - teamUrl = organisations[0].teams[0]?.url || null; + // Hide the page scrollbar while the prompt is open. Radix's scroll lock + // blocks wheel and touch scrolling, but the page scrolls on the root + // element so its scrollbar stays visible and draggable. + useEffect(() => { + if (!isPromptOpen) { + return; } - return teamUrl; - }, [currentTeam, organisations]); + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const documentPageLinks = useMemo(() => { - if (!teamUrl) { - return []; + const previousOverflow = document.documentElement.style.overflow; + const previousPaddingRight = document.body.style.paddingRight; + + document.documentElement.style.overflow = 'hidden'; + + // Compensate for the removed scrollbar so the page doesn't shift. + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; } - return [ - { - label: msg`All documents`, - path: `/t/${teamUrl}/documents?status=ALL`, - shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), - }, - { - label: msg`Draft documents`, - path: `/t/${teamUrl}/documents?status=DRAFT`, - }, - { - label: msg`Completed documents`, - path: `/t/${teamUrl}/documents?status=COMPLETED`, - }, - { - label: msg`Pending documents`, - path: `/t/${teamUrl}/documents?status=PENDING`, - }, - { - label: msg`Inbox documents`, - path: `/t/${teamUrl}/documents?status=INBOX`, - }, - ]; - }, [currentTeam, organisations]); - - const templatePageLinks = useMemo(() => { - if (!teamUrl) { - return []; - } - - return [ - { - label: msg`All templates`, - path: `/t/${teamUrl}/templates`, - shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), - }, - ]; - }, [currentTeam, organisations]); - - const documentSearchResults = - hasValidSearch && searchDocumentsData - ? searchDocumentsData.map((document) => ({ - label: document.title, - path: document.path, - value: document.value, - })) - : []; - - const templateSearchResults = - hasValidSearch && searchTemplatesData - ? searchTemplatesData.map((template) => ({ - label: template.title, - path: template.path, - value: template.value, - })) - : []; - - const currentPage = pages[pages.length - 1]; - - const toggleOpen = () => { - setIsOpen((isOpen) => !isOpen); - onOpenChange?.(!isOpen); - - if (isOpen) { - setPages([]); - setSearch(''); - } - }; + return () => { + document.documentElement.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPaddingRight; + }; + }, [isPromptOpen]); const setOpen = useCallback( - (open: boolean) => { - setIsOpen(open); - onOpenChange?.(open); + (nextOpen: boolean) => { + setIsOpen(nextOpen); + onOpenChange?.(nextOpen); - if (!open) { - setPages([]); + if (!nextOpen) { + setActivePage(null); + setActiveChip('all'); setSearch(''); + setCommandValue(''); } }, [onOpenChange], ); + const toggleOpen = () => { + setOpen(!isPromptOpen); + }; + const push = useCallback( (path: string) => { void navigate(path); setOpen(false); }, - [setOpen], + [navigate, setOpen], ); - const addPage = (page: string) => { - setPages((pages) => [...pages, page]); + const goToPage = useCallback((page: 'theme' | 'language') => { + setActivePage(page); setSearch(''); - }; + }, []); - const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); - const goToDocuments = useCallback(() => push(documentPageLinks[0].path), [push]); - const goToTemplates = useCallback(() => push(templatePageLinks[0].path), [push]); + const resolveItemLabel = useCallback( + (label: string | MessageDescriptor) => (typeof label === 'string' ? label : _(label)), + [_], + ); + + // Fall back to the first available team so the default view always shows + // the document/template page links, even outside a team context such as the + // admin pages. + const teamUrl = useMemo( + () => currentTeam?.url || organisations[0]?.teams[0]?.url || null, + [currentTeam, organisations], + ); + + // Fuzzy match and rank the hardcoded items using the same scorer cmdk uses + // internally, so abbreviations like "setg" still match "Settings". + const filterBySearch = useCallback( + (items: PromptItem[]) => { + if (!hasValidSearch) { + return items; + } + + return items + .map((item) => ({ item, score: commandScore(resolveItemLabel(item.label), trimmedSearch) })) + .filter(({ score }) => score >= MIN_FUZZY_SCORE) + .sort((a, b) => b.score - a.score) + .map(({ item }) => item); + }, + [hasValidSearch, trimmedSearch, resolveItemLabel], + ); + + const categories = useMemo(() => { + const documentPageLinks: PromptItem[] = teamUrl + ? [ + { + id: 'documents-all', + label: msg`All documents`, + path: `/t/${teamUrl}/documents?status=ALL`, + icon: FileTextIcon, + shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''), + }, + { + id: 'documents-draft', + label: msg`Draft documents`, + path: `/t/${teamUrl}/documents?status=DRAFT`, + icon: FileTextIcon, + }, + { + id: 'documents-completed', + label: msg`Completed documents`, + path: `/t/${teamUrl}/documents?status=COMPLETED`, + icon: FileTextIcon, + }, + { + id: 'documents-pending', + label: msg`Pending documents`, + path: `/t/${teamUrl}/documents?status=PENDING`, + icon: FileTextIcon, + }, + { + id: 'documents-inbox', + label: msg`Inbox documents`, + path: `/t/${teamUrl}/documents?status=INBOX`, + icon: FileTextIcon, + }, + ] + : []; + + const templatePageLinks: PromptItem[] = teamUrl + ? [ + { + id: 'templates-all', + label: msg`All templates`, + path: `/t/${teamUrl}/templates`, + icon: LayoutTemplateIcon, + shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), + }, + ] + : []; + + const settingsLinks: PromptItem[] = [ + { + id: 'settings-main', + label: msg`Settings`, + path: '/settings', + icon: SettingsIcon, + shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''), + }, + { id: 'settings-profile', label: msg`Profile`, path: '/settings/profile', icon: UserIcon }, + { id: 'settings-password', label: msg`Password`, path: '/settings/security', icon: KeyRoundIcon }, + { + id: 'settings-language', + label: msg`Change language`, + icon: LanguagesIcon, + onAction: () => goToPage('language'), + }, + { id: 'settings-theme', label: msg`Change theme`, icon: PaletteIcon, onAction: () => goToPage('theme') }, + ]; + + const personalDocumentItems: PromptItem[] = + hasValidSearch && searchDocumentsData + ? searchDocumentsData.map((document) => ({ + id: `personal-document-${document.path}`, + label: document.title, + path: document.path, + icon: FileTextIcon, + })) + : []; + + const personalTemplateItems: PromptItem[] = + hasValidSearch && searchTemplatesData + ? searchTemplatesData.map((template) => ({ + id: `personal-template-${template.path}`, + label: template.title, + path: template.path, + icon: LayoutTemplateIcon, + })) + : []; + + const documentItems = [...filterBySearch(documentPageLinks), ...personalDocumentItems]; + + const templateItems = [...filterBySearch(templatePageLinks), ...personalTemplateItems]; + + const settingsItems = filterBySearch(settingsLinks); + + const allCategories: PromptCategory[] = [ + ...adminSearchCategories, + { + id: 'documents', + label: msg`Documents`, + items: documentItems, + count: documentItems.length, + chipCount: personalDocumentItems.length > 0 ? personalDocumentItems.length : null, + isCapped: personalDocumentItems.length >= PERSONAL_SEARCH_RESULTS_CAP, + isGlobal: false, + }, + { + id: 'templates', + label: msg`Templates`, + items: templateItems, + count: templateItems.length, + chipCount: personalTemplateItems.length > 0 ? personalTemplateItems.length : null, + isCapped: personalTemplateItems.length >= PERSONAL_SEARCH_RESULTS_CAP, + isGlobal: false, + }, + { + id: 'settings', + label: msg`Settings`, + items: settingsItems, + count: settingsItems.length, + chipCount: settingsItems.length, + isCapped: false, + isGlobal: false, + }, + ]; + + return allCategories.filter((category) => category.items.length > 0); + }, [ + teamUrl, + hasValidSearch, + searchDocumentsData, + searchTemplatesData, + adminSearchCategories, + filterBySearch, + goToPage, + ]); + + const effectiveChip = categories.some((category) => category.id === activeChip && category.chipCount !== null) + ? activeChip + : 'all'; + + const visibleCategories = + effectiveChip === 'all' ? categories : categories.filter((category) => category.id === effectiveChip); + + const totalVisibleCount = visibleCategories.reduce((total, category) => total + category.count, 0); + const isVisibleCountCapped = visibleCategories.some((category) => category.isCapped); + + const totalAllCount = categories.reduce((total, category) => total + category.count, 0); + const isAllCountCapped = categories.some((category) => category.isCapped); + + const isAnySearchFetching = isFetchingDocuments || isFetchingTemplates || isFetchingAdminSearch; + + const hasSearchError = isDocumentsSearchError || isTemplatesSearchError || isAdminSearchError; + + const formatChipCount = (count: number, isCapped: boolean) => (isCapped ? `≥${count}` : `${count}`); + + const goToSettings = useCallback(() => push('/settings'), [push]); + const goToDocuments = useCallback(() => { + if (teamUrl) { + push(`/t/${teamUrl}/documents?status=ALL`); + } + }, [push, teamUrl]); + const goToTemplates = useCallback(() => { + if (teamUrl) { + push(`/t/${teamUrl}/templates`); + } + }, [push, teamUrl]); useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); - const handleKeyDown = (e: React.KeyboardEvent) => { - // Escape goes to previous page - // Backspace goes to previous page when search is empty - if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) { - e.preventDefault(); + const handleKeyDown = (event: React.KeyboardEvent) => { + // Escape goes to the previous page, or closes the prompt at the root. + // Backspace goes to the previous page when the search is empty. + if (event.key === 'Escape' || (event.key === 'Backspace' && !search)) { + event.preventDefault(); - if (currentPage === undefined) { + if (activePage === null) { setOpen(false); } - setPages((pages) => pages.slice(0, -1)); + setActivePage(null); } }; + const isSearchLoading = isAnySearchFetching && hasValidSearch; + + const showSearchError = hasValidSearch && !isAnySearchFetching && hasSearchError; + + const showNoResults = hasValidSearch && totalVisibleCount === 0 && !isAnySearchFetching && !hasSearchError; + + const placeholder = match(activePage) + .with('theme', () => msg`Search themes…`) + .with('language', () => msg`Search languages…`) + .otherwise(() => (isUserAdmin ? msg`Search documents, users, organisations…` : msg`Type a command or search...`)); + return ( - - + + + +
+ - - - No results found. - + +
- {templatePageLinks.length > 0 && ( - - - - )} + {activePage === null && ( +
+ setActiveChip('all')} + /> - - - + {categories + .filter((category) => category.chipCount !== null) + .map((category) => ( + setActiveChip(category.id)} + /> + ))} +
+ )} - - addPage('language')}> - {_(msg`Change language`)} - - addPage('theme')}> - {_(msg`Change theme`)} - - - - {(isFetchingDocuments || documentSearchResults.length > 0) && ( - - {isFetchingDocuments ? ( -
- + + {activePage === null && ( + <> + {isSearchLoading && ( + // The single loading state, replacing the results while any + // search is in flight. Mirrors the padding and content + // height of the no results state below so swapping between + // them doesn't change the height of the prompt. +
+
+ +
- ) : ( - )} - - )} - {(isFetchingTemplates || templateSearchResults.length > 0) && ( - - {isFetchingTemplates ? ( -
- + {!isSearchLoading && + visibleCategories.map((category) => ( + + + ))} + + {showSearchError && totalVisibleCount > 0 && ( + // Partial failure: the results from the searches that + // succeeded stay visible, flagged as incomplete. +
+ Some searches failed — results may be incomplete.
- ) : ( - )} - - )} - - )} - {currentPage === 'theme' && } - {currentPage === 'language' && } - - + {showSearchError && totalVisibleCount === 0 && ( + // Total failure: an honest error state instead of a + // misleading "No results", height-matched to it so the + // prompt doesn't jump. +
+
+ Something went wrong +
+
+ We couldn’t complete the search. Try again. +
+
+ )} + + {showNoResults && ( +
+
+ No results for “{trimmedSearch}” +
+
+ Try a different search or switch category. +
+
+ )} + + )} + + {activePage === 'theme' && ( + setActivePage(null)} /> + )} + {activePage === 'language' && ( + setActivePage(null)} /> + )} + + +
+
+ + + + Navigate + + + + Open + + + esc + {activePage === null ? Close : Back} + +
+ + + {hasValidSearch ? ( + {formatChipCount(totalVisibleCount, isVisibleCountCapped)} results + ) : ( + {totalVisibleCount} items + )} + +
+ + +
); -} +}; -const Commands = ({ - push, - pages, +const PromptChip = ({ + label, + count, + isActive, + isGlobal = false, + onSelect, }: { - push: (_path: string) => void; - pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[]; + label: string; + count: string; + isActive: boolean; + isGlobal?: boolean; + onSelect: () => void; }) => { const { _ } = useLingui(); - return pages.map((page, idx) => ( - push(page.path)} + return ( + + ); }; -const ThemeCommands = () => { +const PromptKbd = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +const HighlightedText = ({ text, query }: { text: string; query: string }) => { + if (!query) { + return <>{text}; + } + + const index = text.toLowerCase().indexOf(query.toLowerCase()); + + if (index === -1) { + return <>{text}; + } + + return ( + <> + {text.slice(0, index)} + {text.slice(index, index + query.length)} + {text.slice(index + query.length)} + + ); +}; + +const PromptCommandItem = ({ + item, + query, + push, + disabled = false, +}: { + item: PromptItem; + query: string; + push: (_path: string) => void; + disabled?: boolean; +}) => { const { _ } = useLingui(); - const [, setTheme] = useTheme(); + const label = typeof item.label === 'string' ? item.label : _(item.label); - const themes = [ - { label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun }, - { label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon }, - { label: msg`System Theme`, theme: null, icon: Monitor }, - ] as const; + const onSelect = () => { + if (item.onAction) { + item.onAction(); + return; + } - return themes.map((theme) => ( + if (item.path) { + push(item.path); + } + }; + + const content = ( + <> + + {item.initials ? item.initials : item.icon && } + + + + + + + {item.sublabel && ( + + + + )} + + + {item.shortcut && {item.shortcut}} + + {item.isChecked && } + + + + + + ); + + return ( setTheme(theme.theme)} - className="mx-2 -my-1 rounded-lg first:mt-2 last:mb-2" + value={item.id} + onSelect={onSelect} + disabled={disabled} + className="group items-center gap-3 rounded-lg px-2.5 py-2" > - - {_(theme.label)} + {item.path ? ( + { + // Let the browser handle modified clicks natively, such as opening + // the link in a new tab, without navigating or closing the prompt. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + event.stopPropagation(); + return; + } + + // Plain clicks bubble to the CommandItem which navigates and + // closes the prompt via onSelect. + event.preventDefault(); + }} + > + {content} + + ) : ( + content + )} - )); + ); }; -const LanguageCommands = () => { +const PromptBackCommand = ({ onBack }: { onBack: () => void }) => { + return ( + undefined} + item={{ + id: 'back', + label: msg`Back`, + icon: ArrowLeftIcon, + onAction: onBack, + }} + /> + ); +}; + +const PromptThemeCommands = ({ + query, + push, + onBack, +}: { + query: string; + push: (_path: string) => void; + onBack: () => void; +}) => { + const { _ } = useLingui(); + + const [theme, setTheme, metadata] = useTheme(); + + const themes = [ + { id: 'theme-light', label: msg`Light Mode`, icon: SunIcon, theme: Theme.LIGHT }, + { id: 'theme-dark', label: msg`Dark Mode`, icon: MoonIcon, theme: Theme.DARK }, + { id: 'theme-system', label: msg`System Theme`, icon: MonitorIcon, theme: null }, + ] as const; + + const visibleThemes = themes.filter((item) => !query || commandScore(_(item.label), query) >= MIN_FUZZY_SCORE); + + const isThemeChecked = (itemTheme: Theme | null) => { + if (itemTheme === null) { + return metadata.definedBy === 'SYSTEM'; + } + + return metadata.definedBy === 'USER' && theme === itemTheme; + }; + + return ( + <> + + + + {visibleThemes.map((item) => ( + setTheme(item.theme), + isChecked: isThemeChecked(item.theme), + }} + /> + ))} + + + ); +}; + +const PromptLanguageCommands = ({ + query, + push, + onBack, +}: { + query: string; + push: (_path: string) => void; + onBack: () => void; +}) => { const { i18n, _ } = useLingui(); const { toast } = useToast(); @@ -383,15 +883,31 @@ const LanguageCommands = () => { setIsLoading(false); }; - return Object.values(SUPPORTED_LANGUAGES).map((language) => ( - setLanguage(language.short)} - className="mx-2 -my-1 rounded-lg first:mt-2 last:mb-2" - > - - {_(language.full)} - - )); + const visibleLanguages = Object.values(SUPPORTED_LANGUAGES).filter( + (language) => !query || commandScore(_(language.full), query) >= MIN_FUZZY_SCORE, + ); + + return ( + <> + + + + {visibleLanguages.map((language) => ( + setLanguage(language.short), + isChecked: i18n.locale === language.short, + }} + /> + ))} + + + ); }; diff --git a/apps/remix/app/components/general/app-command-menu.types.ts b/apps/remix/app/components/general/app-command-menu.types.ts new file mode 100644 index 000000000..e9ab47a69 --- /dev/null +++ b/apps/remix/app/components/general/app-command-menu.types.ts @@ -0,0 +1,36 @@ +import type { MessageDescriptor } from '@lingui/core'; +import type { LucideIcon } from 'lucide-react'; + +export type PromptItem = { + id: string; + label: string | MessageDescriptor; + sublabel?: string; + path?: string; + onAction?: () => void; + icon?: LucideIcon; + initials?: string; + shortcut?: string; + isChecked?: boolean; +}; + +export type PromptCategory = { + id: string; + label: MessageDescriptor; + items: PromptItem[]; + /** + * The number of actual results, excluding utility rows such as the + * "View all results" link. + */ + count: number; + /** + * The count shown on the category chip, or null to not show a chip at all. + * Categories which only contain hardcoded page links have no chip. + */ + chipCount: number | null; + isCapped: boolean; + /** + * Global admin categories are marked with a globe icon to distinguish them + * from the equally named personal categories. + */ + isGlobal: boolean; +}; diff --git a/apps/remix/app/components/general/use-admin-search-categories.ts b/apps/remix/app/components/general/use-admin-search-categories.ts new file mode 100644 index 000000000..fc950b708 --- /dev/null +++ b/apps/remix/app/components/general/use-admin-search-categories.ts @@ -0,0 +1,144 @@ +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; +import { isAdmin } from '@documenso/lib/utils/is-admin'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types'; +import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types'; + +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { keepPreviousData } from '@tanstack/react-query'; +import type { LucideIcon } from 'lucide-react'; +import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import type { PromptCategory, PromptItem } from './app-command-menu.types'; + +/** + * The maximum number of results the admin search returns per resource type. + */ +const ADMIN_SEARCH_RESULTS_CAP = 5; + +const ADMIN_GROUP_LABELS: Record = { + document: msg`Documents`, + user: msg`Users`, + organisation: msg`Organisations`, + team: msg`Teams`, + recipient: msg`Recipients`, + subscription: msg`Subscriptions`, +}; + +const ADMIN_GROUP_ICONS: Record = { + document: FileTextIcon, + user: UserIcon, + organisation: Building2Icon, + team: UsersIcon, + recipient: UserIcon, + subscription: CreditCardIcon, +}; + +/** + * Admin list pages which support prefilling their search from the URL, used + * for the "View all results" links on capped groups. Teams, recipients and + * subscriptions have no admin list pages. + */ +const ADMIN_GROUP_LIST_PATHS: Partial string>> = { + document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`, + user: (query) => `/admin/users?search=${encodeURIComponent(query)}`, + organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`, +}; + +export type UseAdminSearchCategoriesOptions = { + /** + * The trimmed, debounced search query. + */ + query: string; + open: boolean; +}; + +/** + * The isolated admin portion of the command prompt: searches every admin + * resource and maps the results to prompt categories marked as global. + * + * Returns no categories and never queries for non admin users. The admin + * search endpoint is additionally guarded server side by the admin procedure. + */ +export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => { + const { user } = useSession(); + + const isUserAdmin = isAdmin(user); + + // Admin searches hit every resource table, so require a longer query unless + // it is a number, which could be a resource ID of any length. Queries over + // the endpoint's length limit are skipped entirely instead of being sent + // and rejected. + const hasValidAdminSearch = + isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query)); + + const { + data: adminSearchData, + isFetching, + isError, + } = trpcReact.admin.search.useQuery( + { + query, + }, + { + enabled: open && hasValidAdminSearch, + placeholderData: keepPreviousData, + // Retyping is the retry in a search-as-you-type flow: fail fast so the + // prompt can surface an honest error state instead of retrying. + retry: false, + ...SKIP_QUERY_BATCH_META, + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + }, + ); + + const categories = useMemo((): PromptCategory[] => { + if (!hasValidAdminSearch || !adminSearchData) { + return []; + } + + return adminSearchData.groups.map((group) => { + const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP; + const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type]; + + const items: PromptItem[] = group.results.map((result) => ({ + id: `admin-${group.type}-${result.value}`, + label: result.label, + sublabel: result.sublabel, + path: result.path, + icon: ADMIN_GROUP_ICONS[group.type], + initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined, + })); + + // Capped groups link to the full admin list page with the search + // prefilled so the cap is never a dead end. + if (isCapped && buildListPath) { + items.push({ + id: `admin-${group.type}-view-all`, + label: msg`View all results`, + path: buildListPath(query), + icon: ArrowRightIcon, + }); + } + + return { + id: `admin-${group.type}`, + label: ADMIN_GROUP_LABELS[group.type], + items, + count: group.results.length, + chipCount: group.results.length, + isCapped, + isGlobal: true, + }; + }); + }, [hasValidAdminSearch, adminSearchData, query]); + + return { + isUserAdmin, + categories, + isFetching, + isError, + }; +}; diff --git a/packages/app-tests/e2e/admin/global-search.spec.ts b/packages/app-tests/e2e/admin/global-search.spec.ts new file mode 100644 index 000000000..d8a1b5fbe --- /dev/null +++ b/packages/app-tests/e2e/admin/global-search.spec.ts @@ -0,0 +1,439 @@ +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin } from '../fixtures/authentication'; +import { openCommandMenu } from '../fixtures/command-menu'; + +test.describe.configure({ mode: 'parallel' }); + +const nanoid = customAlphabet('1234567890abcdef', 10); + +const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…'; + +test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: targetUser } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id)); + + await expect(page.getByText('Global Users', { exact: true })).toBeVisible(); + + // The category chips include the admin groups with their result counts. + await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible(); + + const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first(); + + // Admin results are real links so they support native link behaviour such + // as opening in a new tab. + await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`); + + await userOption.click(); + + await page.waitForURL(`/admin/users/${targetUser.id}`); +}); + +test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { team: targetTeam } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id)); + + await expect(page.getByText('Global Teams', { exact: true })).toBeVisible(); + + await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click(); + + await page.waitForURL(`/admin/teams/${targetTeam.id}`); +}); + +test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `admin-ui-search-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title); + + await expect(page.getByText('Global Documents', { exact: true })).toBeVisible(); + + await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click(); + + await page.waitForURL(`/admin/documents/${document.id}`); +}); + +test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `admin-ui-search-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id); + + await expect(page.getByText('Global Documents', { exact: true })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const adminSearchRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('admin.search')) { + adminSearchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first(); + + // A 3 character non-numeric query must not trigger the admin search. The + // personal document search fires for any non-empty query, so its response + // is the synchronization anchor proving the debounced queries have fired. + const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search')); + + await input.fill('abc'); + + await documentSearchResponse; + + await expect(page.getByText(/^Global /)).toHaveCount(0); + expect(adminSearchRequests).toHaveLength(0); + + // A numeric query fires regardless of length. + const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search')); + + await input.fill('7'); + + await adminSearchRequest; +}); + +test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: targetUser } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first(); + + const initialY = (await input.boundingBox())?.y; + + expect(initialY).toBeGreaterThan(0); + + // The height of the prompt may change as results come and go, but the + // search bar must never move. + await input.fill(String(targetUser.id)); + + await expect(page.getByText('Global Users', { exact: true })).toBeVisible(); + + const resultsY = (await input.boundingBox())?.y; + + expect(resultsY).toBe(initialY); + + // The search bar must not move when there are no results at all. + await input.fill('zzzz-no-such-thing-9x7q'); + + await expect(page.getByText('No results for')).toBeVisible(); + + const emptyY = (await input.boundingBox())?.y; + + expect(emptyY).toBe(initialY); +}); + +test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + // Admin pages have no current team, the page links must still show. + await page.goto('/admin/stats'); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible(); + + // Chips only show for categories with actual results, not for the + // hardcoded page links. + await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click(); + + // The sub page has a contextual placeholder and a back option. + await expect(page.getByPlaceholder('Search themes…')).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible(); + + await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible(); + + await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click(); + + await expect(page.locator('html')).toHaveClass(/dark/); + + // The back option returns to the root view. + await page.getByRole('option').filter({ hasText: 'Back' }).first().click(); + + await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const namePrefix = `viewall-${nanoid()}`; + + // Seed enough users sharing a name prefix to hit the 5 result cap. + for (let i = 0; i < 5; i++) { + await seedUser({ name: `${namePrefix}-${i}` }); + } + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix); + + await expect(page.getByText('Global Users', { exact: true })).toBeVisible(); + + const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first(); + + await expect(viewAllOption.getByRole('link')).toHaveAttribute( + 'href', + `/admin/users?search=${encodeURIComponent(namePrefix)}`, + ); +}); + +test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: firstUser } = await seedUser(); + const { user: secondUser } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first(); + + // First search selects the first result. + await input.fill(String(firstUser.id)); + + await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible(); + await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true'); + + // A subsequent search with entirely new results must select the first + // result again. + await input.fill(String(secondUser.id)); + + await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible(); + await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true'); +}); + +test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + // "setg" is a non-contiguous abbreviation of "Settings". + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg'); + + // Wait for the debounced filter to apply first, "Draft documents" can + // never match "setg" under either matching strategy. + await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0); + + await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await expect + .poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow)) + .toBe('hidden'); + + await page.keyboard.press('Escape'); + + await expect + .poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow)) + .toBe('visible'); +}); + +test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => { + const { user, team } = await seedUser({ isAdmin: false }); + + const document = await seedPendingDocument(user, team.id, []); + + const adminSearchRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('admin.search')) { + adminSearchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: user.email }); + + // Non-admins get the same prompt with a non-admin placeholder. + await openCommandMenu(page, 'Type a command or search...'); + + await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0); + + await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + + // Wait for the regular (non-admin) search to resolve so we know the + // debounced queries have fired. + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); + + await expect(page.getByText(/^Global /)).toHaveCount(0); + expect(adminSearchRequests).toHaveLength(0); +}); + +test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const searchRequests: string[] = []; + + page.on('request', (request) => { + if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) { + searchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click(); + + const input = page.getByPlaceholder('Search themes…'); + + await expect(input).toBeVisible(); + + // Long enough to pass the admin search threshold if it were enabled. + await input.fill('dark'); + + // The client-side filter applying proves the typing registered. + await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible(); + await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0); + + // Wait out the 200ms search debounce with a wide margin before asserting + // that no requests fired: there is no response to anchor on when the + // desired behaviour is "no requests at all". + await page.waitForTimeout(750); + + expect(searchRequests).toHaveLength(0); +}); + +test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q'); + + // A failed search must be honest about it, not claim there are no results. + await expect(page.getByText('Something went wrong')).toBeVisible(); + await expect(page.getByText('No results for')).toHaveCount(0); +}); + +test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => { + const { user: adminUser, team } = await seedUser({ isAdmin: true }); + + const document = await seedPendingDocument(adminUser, team.id, [], { + createDocumentOptions: { title: `partial-fail-${nanoid()}` }, + }); + + // Only the admin search fails: the personal searches succeed. + await page.route(/api\/trpc\/admin\.search/, async (route) => { + await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title); + + // The successful personal document search must still render its results. + await expect(page.getByRole('option', { name: document.title })).toBeVisible(); + + // The failed admin search must be flagged rather than silently dropped. + await expect(page.getByText('Some searches failed')).toBeVisible(); +}); + +test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + const adminSearchRequests: string[] = []; + + page.on('request', (request) => { + if (request.url().includes('admin.search')) { + adminSearchRequests.push(request.url()); + } + }); + + await apiSignin({ page, email: adminUser.email }); + + await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER); + + // The admin search endpoint rejects queries longer than 100 characters, so + // the client must not send them. The personal searches accept up to 1024 + // characters and still run, anchoring the debounced query flush. + const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search')); + + await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150)); + + await documentSearchResponse; + + // The personal searches ran and found nothing: the honest empty state, with + // no error in sight. + await expect(page.getByText('No results for')).toBeVisible(); + await expect(page.getByText('Something went wrong')).toHaveCount(0); + + expect(adminSearchRequests).toHaveLength(0); +}); diff --git a/packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts b/packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts new file mode 100644 index 000000000..5d253dff7 --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/admin/admin-search.spec.ts @@ -0,0 +1,248 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { apiSignin } from '../../../fixtures/authentication'; + +const nanoid = customAlphabet('1234567890abcdef', 10); + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +type AdminSearchGroup = { + type: string; + results: Array<{ label: string; sublabel?: string; path: string; value: string }>; +}; + +const callAdminSearch = async (page: Page, query: string) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: { query } })); + const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`; + + const res = await page.context().request.get(url); + + return { + res, + groups: res.ok() + ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + ((await res.json()).result.data.json.groups as AdminSearchGroup[]) + : null, + }; +}; + +const findGroup = (groups: AdminSearchGroup[] | null, type: string) => + (groups ?? []).find((group) => group.type === type); + +// ─── Access control ────────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => { + const { res } = await callAdminSearch(page, 'anything'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); +}); + +test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => { + const { user: nonAdminUser } = await seedUser({ isAdmin: false }); + + await apiSignin({ page, email: nonAdminUser.email }); + + const { res } = await callAdminSearch(page, 'anything'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); +}); + +// ─── Numeric queries: verified ID lookups ──────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: targetUser, team: targetTeam } = await seedUser(); + + await apiSignin({ page, email: adminUser.email }); + + // Search by user ID. + const userSearch = await callAdminSearch(page, String(targetUser.id)); + + expect(userSearch.res.ok()).toBeTruthy(); + + const userGroup = findGroup(userSearch.groups, 'user'); + expect(userGroup).toBeDefined(); + expect(userGroup?.results).toHaveLength(1); + expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`); + expect(userGroup?.results[0].sublabel).toContain(targetUser.email); + + // The cmdk `value` contract: value must contain the raw query. + expect(userGroup?.results[0].value).toContain(String(targetUser.id)); + + // Search by team ID. + const teamSearch = await callAdminSearch(page, String(targetTeam.id)); + + expect(teamSearch.res.ok()).toBeTruthy(); + + const teamGroup = findGroup(teamSearch.groups, 'team'); + expect(teamGroup).toBeDefined(); + expect(teamGroup?.results).toHaveLength(1); + expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`); + expect(teamGroup?.results[0].label).toBe(targetTeam.name); +}); + +test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + const { user: recipientUser } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, [recipientUser]); + const legacyDocumentId = document.secondaryId.replace('document_', ''); + const recipient = document.recipients[0]; + + await apiSignin({ page, email: adminUser.email }); + + // Search by legacy document ID (bare number). + const documentSearch = await callAdminSearch(page, legacyDocumentId); + + expect(documentSearch.res.ok()).toBeTruthy(); + + const documentGroup = findGroup(documentSearch.groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results).toHaveLength(1); + expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); + expect(documentGroup?.results[0].label).toBe(document.title); + + // Search by recipient ID: links to the parent document. + const recipientSearch = await callAdminSearch(page, String(recipient.id)); + + expect(recipientSearch.res.ok()).toBeTruthy(); + + const recipientGroup = findGroup(recipientSearch.groups, 'recipient'); + expect(recipientGroup).toBeDefined(); + expect(recipientGroup?.results).toHaveLength(1); + expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); + expect(recipientGroup?.results[0].label).toBe(recipient.email); + + // Search by the full document_ secondary ID: exercises the prefix branch. + const secondaryIdSearch = await callAdminSearch(page, document.secondaryId); + + expect(secondaryIdSearch.res.ok()).toBeTruthy(); + + const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document'); + expect(secondaryIdGroup).toBeDefined(); + expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); +}); + +test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, groups } = await callAdminSearch(page, '999999999'); + + expect(res.ok()).toBeTruthy(); + expect(groups).toEqual([]); +}); + +test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + // 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be + // treated as text (and must not 500). + const oversizedNumber = '99999999999999'; + + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, groups } = await callAdminSearch(page, oversizedNumber); + + expect(res.ok()).toBeTruthy(); + + const documentGroup = findGroup(groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`); +}); + +// ─── Prefixed ID queries: exact lookups ────────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, organisation, team } = await seedUser(); + + const document = await seedPendingDocument(sender, team.id, []); + + await apiSignin({ page, email: adminUser.email }); + + // envelope_ resolves the document. + const envelopeSearch = await callAdminSearch(page, document.id); + + expect(envelopeSearch.res.ok()).toBeTruthy(); + + const documentGroup = findGroup(envelopeSearch.groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`); + + // Only the document group is returned for a recognized prefix. + expect(envelopeSearch.groups).toHaveLength(1); + + // org_ resolves the organisation. + const orgSearch = await callAdminSearch(page, organisation.id); + + expect(orgSearch.res.ok()).toBeTruthy(); + + const orgGroup = findGroup(orgSearch.groups, 'organisation'); + expect(orgGroup).toBeDefined(); + expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`); + expect(orgGroup?.results[0].label).toBe(organisation.name); + + // Only the organisation group is returned for a recognized prefix. + expect(orgSearch.groups).toHaveLength(1); +}); + +// ─── Free text queries ─────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: sender, team } = await seedUser(); + + // A unique title: the default seeded title is shared across the whole suite, + // and global search only returns the newest few matches. + const document = await seedPendingDocument(sender, team.id, [], { + createDocumentOptions: { title: `admin-search-${nanoid()}` }, + }); + + await apiSignin({ page, email: adminUser.email }); + + // Search by document title. + const titleSearch = await callAdminSearch(page, document.title); + + expect(titleSearch.res.ok()).toBeTruthy(); + + const documentGroup = findGroup(titleSearch.groups, 'document'); + expect(documentGroup).toBeDefined(); + expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`); + + // Search by user email (emails are unique nanoid-based, so this is specific). + const emailSearch = await callAdminSearch(page, sender.email); + + expect(emailSearch.res.ok()).toBeTruthy(); + + const userGroup = findGroup(emailSearch.groups, 'user'); + expect(userGroup).toBeDefined(); + expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`); +}); + +test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q'); + + expect(res.ok()).toBeTruthy(); + expect(groups).toEqual([]); +}); diff --git a/packages/app-tests/e2e/command-menu/document-search.spec.ts b/packages/app-tests/e2e/command-menu/document-search.spec.ts index a30823502..e4c422d58 100644 --- a/packages/app-tests/e2e/command-menu/document-search.spec.ts +++ b/packages/app-tests/e2e/command-menu/document-search.spec.ts @@ -3,6 +3,9 @@ import { seedUser } from '@documenso/prisma/seed/users'; import { expect, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; +import { openCommandMenu } from '../fixtures/command-menu'; + +const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...'; test('[COMMAND_MENU]: should see sent documents', async ({ page }) => { const { user, team } = await seedUser(); @@ -14,9 +17,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => { email: user.email, }); - await page.keyboard.press('Meta+K'); + await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER); - await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title); await expect(page.getByRole('option', { name: document.title })).toBeVisible(); }); @@ -30,9 +33,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => { email: recipient.email, }); - await page.keyboard.press('Meta+K'); + await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER); - await page.getByPlaceholder('Type a command or search...').first().fill(document.title); + await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title); await expect(page.getByRole('option', { name: document.title })).toBeVisible(); }); @@ -46,8 +49,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) = email: user.email, }); - await page.keyboard.press('Meta+K'); + await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER); - await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email); + await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email); await expect(page.getByRole('option', { name: document.title })).toBeVisible(); }); diff --git a/packages/app-tests/e2e/fixtures/command-menu.ts b/packages/app-tests/e2e/fixtures/command-menu.ts new file mode 100644 index 000000000..d57d75acc --- /dev/null +++ b/packages/app-tests/e2e/fixtures/command-menu.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Opens the app command menu via the keyboard shortcut. + * + * Retries the shortcut until the menu appears since the keypress is a no-op + * when it happens before the page has hydrated. + * + * @param placeholder The search input placeholder to wait for, which differs + * between admin and non-admin users. + */ +export const openCommandMenu = async (page: Page, placeholder: string) => { + await expect(async () => { + await page.keyboard.press('Meta+K'); + await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 }); + }).toPass({ timeout: 15_000 }); +}; diff --git a/packages/lib/server-only/admin/admin-global-search.ts b/packages/lib/server-only/admin/admin-global-search.ts new file mode 100644 index 000000000..514488a14 --- /dev/null +++ b/packages/lib/server-only/admin/admin-global-search.ts @@ -0,0 +1,372 @@ +import { prisma } from '@documenso/prisma'; +import { EnvelopeType } from '@prisma/client'; + +export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5; + +const MAX_POSTGRES_INT = 2147483647; + +const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const; + +export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number]; + +export type AdminGlobalSearchResult = { + label: string; + sublabel?: string; + path: string; + value: string; +}; + +export type AdminGlobalSearchGroup = { + type: AdminGlobalSearchResultType; + results: AdminGlobalSearchResult[]; +}; + +export type AdminGlobalSearchOptions = { + query: string; +}; + +type PartialResults = Partial>; + +export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise => { + const trimmedQuery = query.trim(); + + if (trimmedQuery.length === 0) { + return []; + } + + const resultsByType = await resolveSearch(trimmedQuery); + + return GROUP_ORDER.map((type) => ({ + type, + results: (resultsByType[type] ?? []).map((result) => ({ + ...result, + // Append the raw query so cmdk's client-side filter never hides + // server-verified results. + value: `${result.value} ${trimmedQuery}`, + })), + })).filter((group) => group.results.length > 0); +}; + +const resolveSearch = async (query: string): Promise => { + // Recognized ID prefixes resolve to a single exact lookup. + if (query.startsWith('envelope_')) { + return { document: await findDocumentsByExactId({ id: query }) }; + } + + if (query.startsWith('document_')) { + return { document: await findDocumentsByExactId({ secondaryId: query }) }; + } + + if (query.startsWith('org_')) { + return { organisation: await findOrganisationsByIdOrUrl(query) }; + } + + // Bare numbers are treated as verified ID lookups only. Oversized numbers + // fall through to text search. + const numericId = Number(query); + + if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) { + const [document, user, team, recipient, subscription] = await Promise.all([ + findDocumentsByExactId({ secondaryId: `document_${numericId}` }), + findUsersById(numericId), + findTeamsById(numericId), + findRecipientsById(numericId), + findSubscriptionsById(numericId), + ]); + + return { document, user, team, recipient, subscription }; + } + + // Free text searches all resource types in parallel. + const [document, user, organisation, team, recipient, subscription] = await Promise.all([ + findDocumentsByText(query), + findUsersByText(query), + findOrganisationsByText(query), + findTeamsByText(query), + findRecipientsByText(query), + findSubscriptionsByText(query), + ]); + + return { + document, + user, + organisation, + team, + recipient, + subscription, + }; +}; + +const joinSublabel = (parts: Array) => + parts.filter((part) => part && part.length > 0).join(' · ') || undefined; + +// ─── Documents ──────────────────────────────────────────────────────────────── + +const documentSelect = { + id: true, + title: true, + secondaryId: true, + user: { select: { email: true } }, +} as const; + +type DocumentRow = { + id: string; + title: string; + secondaryId: string; + user: { email: string }; +}; + +const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({ + label: envelope.title, + sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]), + path: `/admin/documents/${envelope.id}`, + value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`, +}); + +const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => { + const envelope = await prisma.envelope.findFirst({ + where: { ...where, type: EnvelopeType.DOCUMENT }, + select: documentSelect, + }); + + return envelope ? [mapDocument(envelope)] : []; +}; + +const findDocumentsByText = async (query: string) => { + const envelopes = await prisma.envelope.findMany({ + where: { + type: EnvelopeType.DOCUMENT, + title: { contains: query, mode: 'insensitive' }, + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: documentSelect, + }); + + return envelopes.map(mapDocument); +}; + +// ─── Users ──────────────────────────────────────────────────────────────────── + +const userSelect = { + id: true, + name: true, + email: true, +} as const; + +type UserRow = { id: number; name: string | null; email: string }; + +const mapUser = (user: UserRow): AdminGlobalSearchResult => ({ + label: user.name || user.email, + sublabel: joinSublabel([`#${user.id}`, user.email]), + path: `/admin/users/${user.id}`, + value: `user ${user.id} ${user.name ?? ''} ${user.email}`, +}); + +const findUsersById = async (id: number) => { + const user = await prisma.user.findFirst({ + where: { id }, + select: userSelect, + }); + + return user ? [mapUser(user)] : []; +}; + +const findUsersByText = async (query: string) => { + const users = await prisma.user.findMany({ + where: { + OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }], + }, + orderBy: { id: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: userSelect, + }); + + return users.map(mapUser); +}; + +// ─── Organisations ──────────────────────────────────────────────────────────── + +const organisationSelect = { + id: true, + name: true, + owner: { select: { email: true } }, +} as const; + +type OrganisationRow = { id: string; name: string; owner: { email: string } }; + +const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({ + label: organisation.name, + sublabel: joinSublabel([organisation.id, organisation.owner.email]), + path: `/admin/organisations/${organisation.id}`, + value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`, +}); + +const findOrganisationsByIdOrUrl = async (query: string) => { + const organisations = await prisma.organisation.findMany({ + where: { + OR: [{ id: query }, { url: query }], + }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: organisationSelect, + }); + + return organisations.map(mapOrganisation); +}; + +const findOrganisationsByText = async (query: string) => { + const organisations = await prisma.organisation.findMany({ + where: { + OR: [ + { name: { contains: query, mode: 'insensitive' } }, + { url: { contains: query, mode: 'insensitive' } }, + { customerId: { contains: query, mode: 'insensitive' } }, + { owner: { email: { contains: query, mode: 'insensitive' } } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: organisationSelect, + }); + + return organisations.map(mapOrganisation); +}; + +// ─── Teams ──────────────────────────────────────────────────────────────────── + +const teamSelect = { + id: true, + name: true, + url: true, + organisation: { select: { name: true } }, +} as const; + +type TeamRow = { id: number; name: string; url: string; organisation: { name: string } }; + +const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({ + label: team.name, + sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]), + path: `/admin/teams/${team.id}`, + value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`, +}); + +const findTeamsById = async (id: number) => { + const team = await prisma.team.findFirst({ + where: { id }, + select: teamSelect, + }); + + return team ? [mapTeam(team)] : []; +}; + +const findTeamsByText = async (query: string) => { + const teams = await prisma.team.findMany({ + where: { + OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }], + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: teamSelect, + }); + + return teams.map(mapTeam); +}; + +// ─── Recipients ─────────────────────────────────────────────────────────────── + +const recipientSelect = { + id: true, + name: true, + email: true, + envelope: { select: { id: true, title: true } }, +} as const; + +type RecipientRow = { + id: number; + name: string; + email: string; + envelope: { id: string; title: string }; +}; + +const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({ + label: recipient.email, + sublabel: joinSublabel([recipient.name, `on "${recipient.envelope.title}"`]), + path: `/admin/documents/${recipient.envelope.id}`, + value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`, +}); + +const findRecipientsById = async (id: number) => { + const recipient = await prisma.recipient.findFirst({ + where: { + id, + envelope: { type: EnvelopeType.DOCUMENT }, + }, + select: recipientSelect, + }); + + return recipient ? [mapRecipient(recipient)] : []; +}; + +const findRecipientsByText = async (query: string) => { + const recipients = await prisma.recipient.findMany({ + where: { + envelope: { type: EnvelopeType.DOCUMENT }, + OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }], + }, + orderBy: { id: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: recipientSelect, + }); + + return recipients.map(mapRecipient); +}; + +// ─── Subscriptions ──────────────────────────────────────────────────────────── + +const subscriptionSelect = { + id: true, + status: true, + planId: true, + customerId: true, + organisationId: true, +} as const; + +type SubscriptionRow = { + id: number; + status: string; + planId: string; + customerId: string; + organisationId: string; +}; + +const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({ + label: `Subscription #${subscription.id}`, + sublabel: joinSublabel([subscription.status, subscription.planId]), + path: `/admin/organisations/${subscription.organisationId}`, + value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`, +}); + +const findSubscriptionsById = async (id: number) => { + const subscription = await prisma.subscription.findFirst({ + where: { id }, + select: subscriptionSelect, + }); + + return subscription ? [mapSubscription(subscription)] : []; +}; + +const findSubscriptionsByText = async (query: string) => { + const subscriptions = await prisma.subscription.findMany({ + where: { + OR: [ + { planId: { contains: query, mode: 'insensitive' } }, + { customerId: { contains: query, mode: 'insensitive' } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: ADMIN_SEARCH_RESULTS_PER_TYPE, + select: subscriptionSelect, + }); + + return subscriptions.map(mapSubscription); +}; diff --git a/packages/trpc/server/admin-router/admin-search.ts b/packages/trpc/server/admin-router/admin-search.ts new file mode 100644 index 000000000..fe69e125a --- /dev/null +++ b/packages/trpc/server/admin-router/admin-search.ts @@ -0,0 +1,15 @@ +import { adminGlobalSearch } from '@documenso/lib/server-only/admin/admin-global-search'; + +import { adminProcedure } from '../trpc'; +import { ZAdminSearchRequestSchema, ZAdminSearchResponseSchema } from './admin-search.types'; + +export const adminSearchRoute = adminProcedure + .input(ZAdminSearchRequestSchema) + .output(ZAdminSearchResponseSchema) + .query(async ({ input }) => { + const { query } = input; + + const groups = await adminGlobalSearch({ query }); + + return { groups }; + }); diff --git a/packages/trpc/server/admin-router/admin-search.types.ts b/packages/trpc/server/admin-router/admin-search.types.ts new file mode 100644 index 000000000..32cc101ba --- /dev/null +++ b/packages/trpc/server/admin-router/admin-search.types.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const ZAdminSearchResultTypeSchema = z.enum([ + 'document', + 'user', + 'organisation', + 'team', + 'recipient', + 'subscription', +]); + +export const ZAdminSearchResultSchema = z.object({ + label: z.string(), + sublabel: z.string().optional(), + path: z.string(), + value: z.string(), +}); + +export const ADMIN_SEARCH_MAX_QUERY_LENGTH = 100; + +export const ZAdminSearchRequestSchema = z.object({ + query: z.string().trim().min(1).max(ADMIN_SEARCH_MAX_QUERY_LENGTH), +}); + +export const ZAdminSearchResponseSchema = z.object({ + groups: z.array( + z.object({ + type: ZAdminSearchResultTypeSchema, + results: ZAdminSearchResultSchema.array(), + }), + ), +}); + +export type TAdminSearchResultType = z.infer; +export type TAdminSearchResult = z.infer; +export type TAdminSearchRequest = z.infer; +export type TAdminSearchResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7aec1968f..45db5ccbc 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,4 +1,5 @@ import { router } from '../trpc'; +import { adminSearchRoute } from './admin-search'; import { createAdminOrganisationRoute } from './create-admin-organisation'; import { createStripeCustomerRoute } from './create-stripe-customer'; import { createSubscriptionClaimRoute } from './create-subscription-claim'; @@ -118,5 +119,6 @@ export const adminRouter = router({ teamMember: { delete: deleteAdminTeamMemberRoute, }, + search: adminSearchRoute, updateSiteSetting: updateSiteSettingRoute, });