Files
sign/apps/remix/app/components/general/app-command-menu.tsx

333 lines
9.2 KiB
TypeScript
Raw Normal View History

import { useCallback, useMemo, useState } from 'react';
2024-08-27 20:34:39 +09:00
import type { MessageDescriptor } from '@lingui/core';
2025-01-02 15:33:37 +11:00
import { msg } from '@lingui/core/macro';
2024-08-27 20:34:39 +09:00
import { useLingui } from '@lingui/react';
2025-01-02 15:33:37 +11:00
import { Trans } from '@lingui/react/macro';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useHotkeys } from 'react-hotkeys-hook';
2025-01-02 15:33:37 +11:00
import { useNavigate } from 'react-router';
import { Theme, useTheme } from 'remix-themes';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT,
2023-12-25 23:16:56 +00:00
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
fix: update document flow fetch logic (#1039) ## Description **Fixes issues with mismatching state between document steps.** For example, editing a recipient and proceeding to the next step may not display the updated recipient. And going back will display the old recipient instead of the updated values. **This PR also improves mutation and query speeds by adding logic to bypass query invalidation.** ```ts export const trpc = createTRPCReact<AppRouter>({ unstable_overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // This forces mutations to wait for all the queries on the page to reload, and in // this case one of the queries is `searchDocument` for the command overlay, which // on average takes ~500ms. This means that every single mutation must wait for this. await opts.queryClient.invalidateQueries(); }, }, }, }); ``` I've added workarounds to allow us to bypass things such as batching and invalidating queries. But I think we should instead remove this and update all the mutations where a query is required for a more optimised system. ## Example benchmarks Using stg-app vs this preview there's an average 50% speed increase across mutations. **Set signer step:** Average old speed: ~1100ms Average new speed: ~550ms **Set recipient step:** Average old speed: ~1200ms Average new speed: ~600ms **Set fields step:** Average old speed: ~1200ms Average new speed: ~600ms ## Related Issue This will resolve #470 ## Changes Made - Added ability to skip batch queries - Added a state to store the required document data. - Refetch the data between steps if/when required - Optimise mutations and queries ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --------- Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-03-26 21:12:41 +08:00
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
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 { useToast } from '@documenso/ui/primitives/use-toast';
const DOCUMENTS_PAGES = [
{
2024-08-27 20:34:39 +09:00
label: msg`All documents`,
path: '/documents?status=ALL',
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
},
2024-08-27 20:34:39 +09:00
{ label: msg`Draft documents`, path: '/documents?status=DRAFT' },
{
2024-08-27 20:34:39 +09:00
label: msg`Completed documents`,
path: '/documents?status=COMPLETED',
},
2024-08-27 20:34:39 +09:00
{ label: msg`Pending documents`, path: '/documents?status=PENDING' },
{ label: msg`Inbox documents`, path: '/documents?status=INBOX' },
];
2023-12-25 23:16:56 +00:00
const TEMPLATES_PAGES = [
{
2024-08-27 20:34:39 +09:00
label: msg`All templates`,
2023-12-25 23:16:56 +00:00
path: '/templates',
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
const SETTINGS_PAGES = [
{
2024-08-27 20:34:39 +09:00
label: msg`Settings`,
path: '/settings',
shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', ''),
},
2024-08-27 20:34:39 +09:00
{ label: msg`Profile`, path: '/settings/profile' },
{ label: msg`Password`, path: '/settings/password' },
];
2025-01-02 15:33:37 +11:00
export type AppCommandMenuProps = {
2023-11-09 14:38:26 +11:00
open?: boolean;
onOpenChange?: (_open: boolean) => void;
};
2025-01-02 15:33:37 +11:00
export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
2024-08-27 20:34:39 +09:00
const { _ } = useLingui();
2025-01-02 15:33:37 +11:00
const navigate = useNavigate();
2023-11-09 14:38:26 +11:00
const [isOpen, setIsOpen] = useState(() => open ?? false);
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
2023-11-09 14:38:26 +11:00
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery(
{
query: search,
},
{
placeholderData: (previousData) => previousData,
fix: update document flow fetch logic (#1039) ## Description **Fixes issues with mismatching state between document steps.** For example, editing a recipient and proceeding to the next step may not display the updated recipient. And going back will display the old recipient instead of the updated values. **This PR also improves mutation and query speeds by adding logic to bypass query invalidation.** ```ts export const trpc = createTRPCReact<AppRouter>({ unstable_overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // This forces mutations to wait for all the queries on the page to reload, and in // this case one of the queries is `searchDocument` for the command overlay, which // on average takes ~500ms. This means that every single mutation must wait for this. await opts.queryClient.invalidateQueries(); }, }, }, }); ``` I've added workarounds to allow us to bypass things such as batching and invalidating queries. But I think we should instead remove this and update all the mutations where a query is required for a more optimised system. ## Example benchmarks Using stg-app vs this preview there's an average 50% speed increase across mutations. **Set signer step:** Average old speed: ~1100ms Average new speed: ~550ms **Set recipient step:** Average old speed: ~1200ms Average new speed: ~600ms **Set fields step:** Average old speed: ~1200ms Average new speed: ~600ms ## Related Issue This will resolve #470 ## Changes Made - Added ability to skip batch queries - Added a state to store the required document data. - Refetch the data between steps if/when required - Optimise mutations and queries ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --------- Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-03-26 21:12:41 +08:00
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
return searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}));
}, [searchDocumentsData]);
const currentPage = pages[pages.length - 1];
const toggleOpen = () => {
2023-11-09 14:38:26 +11:00
setIsOpen((isOpen) => !isOpen);
onOpenChange?.(!isOpen);
if (isOpen) {
setPages([]);
setSearch('');
}
};
const setOpen = useCallback(
(open: boolean) => {
setIsOpen(open);
onOpenChange?.(open);
if (!open) {
setPages([]);
setSearch('');
}
},
[onOpenChange],
);
const push = useCallback(
(path: string) => {
2025-01-02 15:33:37 +11:00
void navigate(path);
2023-11-09 14:38:26 +11:00
setOpen(false);
},
2025-01-02 15:33:37 +11:00
[setOpen],
2023-11-09 14:38:26 +11:00
);
const addPage = (page: string) => {
setPages((pages) => [...pages, page]);
setSearch('');
};
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
2023-12-25 23:16:56 +00:00
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
2023-12-25 23:16:56 +00:00
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();
2023-11-09 14:38:26 +11:00
if (currentPage === undefined) {
setOpen(false);
}
2023-11-09 14:38:26 +11:00
setPages((pages) => pages.slice(0, -1));
}
};
return (
<CommandDialog
commandProps={{
onKeyDown: handleKeyDown,
}}
open={open}
onOpenChange={setOpen}
>
<CommandInput
value={search}
onValueChange={setSearch}
2024-08-27 20:34:39 +09:00
placeholder={_(msg`Type a command or search...`)}
/>
2023-11-09 14:38:26 +11:00
<CommandList>
{isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
2024-08-27 20:34:39 +09:00
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)}
{!currentPage && (
<>
2024-08-27 20:34:39 +09:00
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
2024-08-27 20:34:39 +09:00
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
2023-12-25 23:16:56 +00:00
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
2024-08-27 20:34:39 +09:00
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
2024-08-27 20:34:39 +09:00
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
Change language
</CommandItem>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
Change theme
</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
2024-08-27 20:34:39 +09:00
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} />
</CommandGroup>
)}
</>
)}
2025-01-02 15:33:37 +11:00
{currentPage === 'theme' && <ThemeCommands />}
{currentPage === 'language' && <LanguageCommands />}
</CommandList>
</CommandDialog>
);
}
const Commands = ({
push,
pages,
}: {
push: (_path: string) => void;
2024-08-27 20:34:39 +09:00
pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[];
}) => {
2024-08-27 20:34:39 +09:00
const { _ } = useLingui();
return pages.map((page, idx) => (
<CommandItem
className="-mx-2 -my-1 rounded-lg"
key={page.path + idx}
2024-08-27 20:34:39 +09:00
value={page.value ?? (typeof page.label === 'string' ? page.label : _(page.label))}
onSelect={() => push(page.path)}
>
2024-08-27 20:34:39 +09:00
{typeof page.label === 'string' ? page.label : _(page.label)}
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
</CommandItem>
));
};
2025-01-02 15:33:37 +11:00
const ThemeCommands = () => {
2024-08-27 20:34:39 +09:00
const { _ } = useLingui();
2025-01-02 15:33:37 +11:00
const [, setTheme] = useTheme();
2025-01-02 15:33:37 +11:00
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;
return themes.map((theme) => (
<CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
<theme.icon className="mr-2" />
2024-08-27 20:34:39 +09:00
{_(theme.label)}
</CommandItem>
));
};
const LanguageCommands = () => {
const { i18n, _ } = useLingui();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const setLanguage = async (lang: string) => {
if (isLoading || lang === i18n.locale) {
return;
}
setIsLoading(true);
try {
2025-01-02 15:33:37 +11:00
await dynamicActivate(lang);
const formData = new FormData();
formData.append('lang', lang);
const response = await fetch('/api/locale', {
method: 'post',
body: formData,
});
if (!response.ok) {
throw new Error(response.statusText);
}
} catch (e) {
console.error(`Failed to set language: ${e}`);
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(msg`Unable to change the language at this time. Please try again later.`),
});
}
setIsLoading(false);
};
return Object.values(SUPPORTED_LANGUAGES).map((language) => (
<CommandItem
disabled={isLoading}
key={language.full}
onSelect={async () => setLanguage(language.short)}
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
>
<CheckIcon
className={cn('mr-2 h-4 w-4', i18n.locale === language.short ? 'opacity-100' : 'opacity-0')}
/>
{language.full}
</CommandItem>
));
};