mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 03:32:14 +10:00
wip
This commit is contained in:
@ -0,0 +1,71 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
|
||||
|
||||
const onRecipientClick = () => {
|
||||
if (!signingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('my-1 flex items-center gap-2', {
|
||||
'cursor-pointer hover:underline': signingToken,
|
||||
})}
|
||||
role={signingToken ? 'button' : undefined}
|
||||
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
|
||||
onClick={onRecipientClick}
|
||||
>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
title={
|
||||
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
|
||||
}
|
||||
>
|
||||
<p>{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
const ZIndexes: { [key: string]: string } = {
|
||||
'10': 'z-10',
|
||||
'20': 'z-20',
|
||||
'30': 'z-30',
|
||||
'40': 'z-40',
|
||||
'50': 'z-50',
|
||||
};
|
||||
|
||||
export type StackAvatarProps = {
|
||||
first?: boolean;
|
||||
zIndex?: string;
|
||||
fallbackText?: string;
|
||||
type: RecipientStatusType;
|
||||
};
|
||||
|
||||
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
|
||||
let classes = '';
|
||||
let zIndexClass = '';
|
||||
const firstClass = first ? '' : '-ml-3';
|
||||
|
||||
if (zIndex) {
|
||||
zIndexClass = ZIndexes[zIndex] ?? '';
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case RecipientStatusType.UNSIGNED:
|
||||
classes = 'bg-dawn-200 text-dawn-900';
|
||||
break;
|
||||
case RecipientStatusType.OPENED:
|
||||
classes = 'bg-yellow-200 text-yellow-700';
|
||||
break;
|
||||
case RecipientStatusType.WAITING:
|
||||
classes = 'bg-water text-water-700';
|
||||
break;
|
||||
case RecipientStatusType.COMPLETED:
|
||||
classes = 'bg-documenso-200 text-documenso-800';
|
||||
break;
|
||||
case RecipientStatusType.REJECTED:
|
||||
classes = 'bg-red-200 text-red-800';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={` ${zIndexClass} ${firstClass} dark:border-border h-10 w-10 border-2 border-solid border-white`}
|
||||
>
|
||||
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,166 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { type DocumentStatus, type Recipient } from '@prisma/client';
|
||||
|
||||
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
import { StackAvatars } from './stack-avatars';
|
||||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
recipients: Recipient[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const StackAvatarsWithTooltip = ({
|
||||
documentStatus,
|
||||
recipients,
|
||||
position,
|
||||
children,
|
||||
}: StackAvatarsWithTooltipProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const waitingRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
|
||||
);
|
||||
|
||||
const openedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED,
|
||||
);
|
||||
|
||||
const completedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
|
||||
);
|
||||
|
||||
const uncompletedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
|
||||
);
|
||||
|
||||
const rejectedRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
return [
|
||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||
...otherRecipients.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
}),
|
||||
];
|
||||
}, [recipients]);
|
||||
|
||||
return (
|
||||
<PopoverHover
|
||||
trigger={children || <StackAvatars recipients={sortedRecipients} />}
|
||||
contentProps={{
|
||||
className: 'flex flex-col gap-y-5 py-2',
|
||||
side: position,
|
||||
}}
|
||||
>
|
||||
{completedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rejectedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient: Recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waitingRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncompletedRecipients.length > 0 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
documentStatus={documentStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverHover>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
||||
const renderStackAvatars = (recipients: Recipient[]) => {
|
||||
const zIndex = 50;
|
||||
const itemsToRender = recipients.slice(0, 5);
|
||||
const remainingItems = recipients.length - itemsToRender.length;
|
||||
|
||||
return itemsToRender.map((recipient: Recipient, index: number) => {
|
||||
const first = index === 0;
|
||||
|
||||
if (index === 4 && remainingItems > 0) {
|
||||
return (
|
||||
<StackAvatar
|
||||
key="extra-recipient"
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getExtraRecipientsType(recipients.slice(4))}
|
||||
fallbackText={`+${remainingItems + 1}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
first={first}
|
||||
zIndex={String(zIndex - index * 10)}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <>{renderStackAvatars(recipients)}</>;
|
||||
}
|
||||
321
apps/remix/app/components/(dashboard)/common/command-menu.tsx
Normal file
321
apps/remix/app/components/(dashboard)/common/command-menu.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
TEMPLATES_PAGE_SHORTCUT,
|
||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||
import {
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { switchI18NLanguage } from '@documenso/lib/server-only/i18n/switch-i18n-language';
|
||||
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 { THEMES_TYPE } from '@documenso/ui/primitives/constants';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const DOCUMENTS_PAGES = [
|
||||
{
|
||||
label: msg`All documents`,
|
||||
path: '/documents?status=ALL',
|
||||
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
{ label: msg`Draft documents`, path: '/documents?status=DRAFT' },
|
||||
{
|
||||
label: msg`Completed documents`,
|
||||
path: '/documents?status=COMPLETED',
|
||||
},
|
||||
{ label: msg`Pending documents`, path: '/documents?status=PENDING' },
|
||||
{ label: msg`Inbox documents`, path: '/documents?status=INBOX' },
|
||||
];
|
||||
|
||||
const TEMPLATES_PAGES = [
|
||||
{
|
||||
label: msg`All templates`,
|
||||
path: '/templates',
|
||||
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
export type CommandMenuProps = {
|
||||
open?: boolean;
|
||||
onOpenChange?: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const { _ } = useLingui();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(() => open ?? false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
|
||||
trpcReact.document.searchDocuments.useQuery(
|
||||
{
|
||||
query: search,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
// 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 = () => {
|
||||
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) => {
|
||||
void navigate(path);
|
||||
setOpen(false);
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
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]);
|
||||
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);
|
||||
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();
|
||||
|
||||
if (currentPage === undefined) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
commandProps={{
|
||||
onKeyDown: handleKeyDown,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={_(msg`Type a command or search...`)}
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
{isSearchingDocuments ? (
|
||||
<CommandEmpty>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="animate-spin">
|
||||
<Loader />
|
||||
</span>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandEmpty>
|
||||
<Trans>No results found.</Trans>
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{!currentPage && (
|
||||
<>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Documents`)}>
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
<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 && (
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||
<Commands push={push} pages={searchResults} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||
{currentPage === 'language' && <LanguageCommands />}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const Commands = ({
|
||||
push,
|
||||
pages,
|
||||
}: {
|
||||
push: (_path: string) => void;
|
||||
pages: { label: MessageDescriptor | string; path: string; shortcut?: string; value?: string }[];
|
||||
}) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return pages.map((page, idx) => (
|
||||
<CommandItem
|
||||
className="-mx-2 -my-1 rounded-lg"
|
||||
key={page.path + idx}
|
||||
value={page.value ?? (typeof page.label === 'string' ? page.label : _(page.label))}
|
||||
onSelect={() => push(page.path)}
|
||||
>
|
||||
{typeof page.label === 'string' ? page.label : _(page.label)}
|
||||
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||
</CommandItem>
|
||||
));
|
||||
};
|
||||
|
||||
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const THEMES = useMemo(
|
||||
() => [
|
||||
{ label: msg`Light Mode`, theme: THEMES_TYPE.LIGHT, icon: Sun },
|
||||
{ label: msg`Dark Mode`, theme: THEMES_TYPE.DARK, icon: Moon },
|
||||
{ label: msg`System Theme`, theme: THEMES_TYPE.SYSTEM, icon: Monitor },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
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" />
|
||||
{_(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 {
|
||||
await dynamicActivate(i18n, lang);
|
||||
await switchI18NLanguage(lang);
|
||||
} catch (err) {
|
||||
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>
|
||||
));
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('search', term);
|
||||
} else {
|
||||
params.delete('search');
|
||||
}
|
||||
|
||||
// Todo: Test
|
||||
void navigate(`/documents?${params.toString()}`);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSearch(searchTerm);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
apps/remix/app/components/(dashboard)/layout/banner.tsx
Normal file
29
apps/remix/app/components/(dashboard)/layout/banner.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
|
||||
export const Banner = async () => {
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{banner && banner.enabled && (
|
||||
<div className="mb-2" style={{ background: banner.data.bgColor }}>
|
||||
<div
|
||||
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
|
||||
style={{ color: banner.data.textColor }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Banner
|
||||
// Custom Text
|
||||
// Custom Text with Custom Icon
|
||||
90
apps/remix/app/components/(dashboard)/layout/desktop-nav.tsx
Normal file
90
apps/remix/app/components/(dashboard)/layout/desktop-nav.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
const navigationLinks = [
|
||||
{
|
||||
href: '/documents',
|
||||
label: msg`Documents`,
|
||||
},
|
||||
{
|
||||
href: '/templates',
|
||||
label: msg`Templates`,
|
||||
},
|
||||
];
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & {
|
||||
setIsCommandMenuOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
||||
|
||||
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-baseline gap-x-6">
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
to={`${rootHref}${href}`}
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||
`${rootHref}${href}`,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{_(label)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
||||
onClick={() => setIsCommandMenuOpen(true)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
<Trans>Search</Trans>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
|
||||
{modifierKey}+K
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
apps/remix/app/components/(dashboard)/layout/header.tsx
Normal file
96
apps/remix/app/components/(dashboard)/layout/header.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import type { User } from '@prisma/client';
|
||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
import { DesktopNav } from './desktop-nav';
|
||||
import { MenuSwitcher } from './menu-switcher';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
user: User;
|
||||
teams: TGetTeamsResponse;
|
||||
};
|
||||
|
||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
const { pathname } = useLocation(); // Todo: Test
|
||||
|
||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setScrollY(window.scrollY);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll);
|
||||
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const isPathTeamUrl = (teamUrl: string) => {
|
||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname.split('/')[2] === teamUrl;
|
||||
};
|
||||
|
||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
to={`${getRootHref(params, { returnEmptyRootString: true })}/documents`}
|
||||
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||
>
|
||||
<Logo className="h-6 w-auto" />
|
||||
</Link>
|
||||
|
||||
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
|
||||
|
||||
<div
|
||||
className="flex gap-x-4 md:ml-8"
|
||||
title={selectedTeam ? selectedTeam.name : (user.name ?? '')}
|
||||
>
|
||||
<MenuSwitcher user={user} teams={teams} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||
<SearchIcon className="text-muted-foreground h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
||||
<MenuIcon className="text-muted-foreground h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
||||
|
||||
<MobileNavigation
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
288
apps/remix/app/components/(dashboard)/layout/menu-switcher.tsx
Normal file
288
apps/remix/app/components/(dashboard)/layout/menu-switcher.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { User } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import type { TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
const MotionLink = motion(Link);
|
||||
|
||||
export type MenuSwitcherProps = {
|
||||
user: User;
|
||||
teams: TGetTeamsResponse;
|
||||
};
|
||||
|
||||
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
||||
initialData: initialTeamsData,
|
||||
});
|
||||
|
||||
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
|
||||
|
||||
const isPathTeamUrl = (teamUrl: string) => {
|
||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathname.split('/')[2] === teamUrl;
|
||||
};
|
||||
|
||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
||||
|
||||
const formatAvatarFallback = (teamName?: string) => {
|
||||
if (teamName !== undefined) {
|
||||
return teamName.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
||||
};
|
||||
|
||||
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
|
||||
if (!team) {
|
||||
return _(msg`Personal Account`);
|
||||
}
|
||||
|
||||
if (team.ownerUserId === user.id) {
|
||||
return _(msg`Owner`);
|
||||
}
|
||||
|
||||
return _(TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the redirect URL so we can switch between documents and templates page
|
||||
* seemlessly between teams and personal accounts.
|
||||
*/
|
||||
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
||||
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
|
||||
|
||||
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
||||
|
||||
if (currentPathname === '/templates') {
|
||||
return `${baseUrl}templates`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="menu-switcher"
|
||||
variant="none"
|
||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(
|
||||
selectedTeam ? selectedTeam.avatarImageId : user.avatarImageId,
|
||||
)}
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
primaryText={selectedTeam ? selectedTeam.name : user.name}
|
||||
secondaryText={formatSecondaryAvatarText(selectedTeam)}
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
textSectionClassName="hidden lg:flex"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
{teams ? (
|
||||
<>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Personal</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={formatRedirectUrlOnSwitch()}>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(user.avatarImageId)}
|
||||
avatarFallback={formatAvatarFallback()}
|
||||
primaryText={user.name}
|
||||
secondaryText={formatSecondaryAvatarText()}
|
||||
rightSideComponent={
|
||||
!pathname?.startsWith(`/t/`) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="mt-2" />
|
||||
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p>
|
||||
<Trans>Teams</Trans>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row space-x-2">
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
title={_(msg`Manage teams`)}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
||||
asChild
|
||||
>
|
||||
<Link to="/settings/teams">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
title={_(msg`Create team`)}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
||||
asChild
|
||||
>
|
||||
<Link to="/settings/teams?action=add-team">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<MotionLink
|
||||
initial="initial"
|
||||
animate="initial"
|
||||
whileHover="animate"
|
||||
to={formatRedirectUrlOnSwitch(team.url)}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarSrc={formatAvatarUrl(team.avatarImageId)}
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
textSectionClassName="w-[200px]"
|
||||
secondaryText={
|
||||
<div className="relative w-full">
|
||||
<motion.span
|
||||
className="overflow-hidden"
|
||||
variants={{
|
||||
initial: { opacity: 1, translateY: 0 },
|
||||
animate: { opacity: 0, translateY: '100%' },
|
||||
}}
|
||||
>
|
||||
{formatSecondaryAvatarText(team)}
|
||||
</motion.span>
|
||||
|
||||
<motion.span
|
||||
className="absolute inset-0"
|
||||
variants={{
|
||||
initial: { opacity: 0, translateY: '100%' },
|
||||
animate: { opacity: 1, translateY: 0 },
|
||||
}}
|
||||
>{`/t/${team.url}`}</motion.span>
|
||||
</div>
|
||||
}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</MotionLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
to="/settings/teams?action=add-team"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<Trans>Create team</Trans>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isUserAdmin && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/admin">
|
||||
<Trans>Admin panel</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to="/settings/profile">
|
||||
<Trans>User settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{selectedTeam &&
|
||||
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link to={`/t/${selectedTeam.url}/settings/`}>
|
||||
<Trans>Team settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-muted-foreground px-4 py-2"
|
||||
onClick={() => setLanguageSwitcherOpen(true)}
|
||||
>
|
||||
<Trans>Language</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive/90 hover:!text-destructive px-4 py-2"
|
||||
onSelect={async () => authClient.signOut()}
|
||||
>
|
||||
<Trans>Sign Out</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,91 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
|
||||
import LogoImage from '@documenso/assets/logo.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||
|
||||
export type MobileNavigationProps = {
|
||||
isMenuOpen: boolean;
|
||||
onMenuOpenChange?: (_value: boolean) => void;
|
||||
};
|
||||
|
||||
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const params = useParams();
|
||||
|
||||
const handleMenuItemClick = () => {
|
||||
onMenuOpenChange?.(false);
|
||||
};
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
|
||||
const menuNavigationLinks = [
|
||||
{
|
||||
href: `${rootHref}/documents`,
|
||||
text: msg`Documents`,
|
||||
},
|
||||
{
|
||||
href: `${rootHref}/templates`,
|
||||
text: msg`Templates`,
|
||||
},
|
||||
{
|
||||
href: '/settings/teams',
|
||||
text: msg`Teams`,
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
text: msg`Settings`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[350px] flex-col">
|
||||
<Link to="/" onClick={handleMenuItemClick}>
|
||||
<img
|
||||
src={LogoImage}
|
||||
alt="Documenso Logo"
|
||||
className="dark:invert"
|
||||
width={170}
|
||||
height={25}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="mt-8 flex w-full flex-col items-start gap-y-4">
|
||||
{menuNavigationLinks.map(({ href, text }) => (
|
||||
<Link
|
||||
key={href}
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
to={href}
|
||||
onClick={() => handleMenuItemClick()}
|
||||
>
|
||||
{_(text)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||
onClick={async () => authClient.signOut()}
|
||||
>
|
||||
<Trans>Sign Out</Trans>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full flex-col space-y-4 self-end">
|
||||
<div className="w-fit">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type VerifyEmailBannerProps = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||
|
||||
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||
|
||||
const { mutateAsync: sendConfirmationEmail, isPending } =
|
||||
trpc.profile.sendConfirmationEmail.useMutation();
|
||||
|
||||
const onResendConfirmationEmail = async () => {
|
||||
try {
|
||||
setIsButtonDisabled(true);
|
||||
|
||||
await sendConfirmationEmail({ email: email });
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Verification email sent successfully.`),
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
|
||||
} catch (err) {
|
||||
setIsButtonDisabled(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Something went wrong while sending the confirmation email.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||
// if it was within the past 24 hours, don't show it again
|
||||
// otherwise, show it again and update the localStorage timestamp
|
||||
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||
'emailVerificationDialogLastShown',
|
||||
);
|
||||
|
||||
if (emailVerificationDialogLastShown) {
|
||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||
|
||||
if (Date.now() - lastShownTimestamp < ONE_DAY) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
|
||||
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
<Trans>Verify your email address to unlock all features.</Trans>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
|
||||
disabled={isButtonDisabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
{isButtonDisabled ? (
|
||||
<Trans>Verification Email Sent</Trans>
|
||||
) : (
|
||||
<Trans>Verify Now</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<Trans>Verify your email address</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox
|
||||
and click the link in the email to verify your account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
disabled={isButtonDisabled}
|
||||
loading={isPending}
|
||||
onClick={onResendConfirmationEmail}
|
||||
>
|
||||
{isPending ? <Trans>Sending...</Trans> : <Trans>Resend Confirmation Email</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type CardMetricProps = {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
value: string | number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full max-h-full flex-col px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
|
||||
<div className="flex items-start">
|
||||
{Icon && (
|
||||
<div className="mr-2 h-4 w-4">
|
||||
<Icon className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
|
||||
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
import { isPeriodSelectorValue } from './types';
|
||||
|
||||
export const PeriodSelector = () => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const p = searchParams?.get('period') ?? 'all';
|
||||
|
||||
return isPeriodSelectorValue(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === '' || newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">
|
||||
<Trans>All Time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
<Trans>Last 7 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="14d">
|
||||
<Trans>Last 14 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
<Trans>Last 30 days</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
|
||||
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
export const RefreshOnFocus = () => {
|
||||
// Todo: Would this still work?
|
||||
// const { refresh } = useRouter();
|
||||
|
||||
// const onFocus = useCallback(() => {
|
||||
// refresh();
|
||||
// }, [refresh]);
|
||||
|
||||
// useEffect(() => {
|
||||
// window.addEventListener('focus', onFocus);
|
||||
|
||||
// return () => {
|
||||
// window.removeEventListener('focus', onFocus);
|
||||
// };
|
||||
// }, [onFocus]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,117 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isBillingEnabled = false; // Todo getFlag('app_billing');
|
||||
const isPublicProfileEnabled = true; // Todo getFlag('app_public_profile');
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link to="/settings/profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
<Trans>Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Teams</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
<Trans>Security</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type SettingsHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
hideDivider?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SettingsHeader = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
hideDivider,
|
||||
}: SettingsHeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{!hideDivider && <hr className="my-4" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,119 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Braces, CreditCard, Globe2Icon, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isBillingEnabled = false; // Todo getFlag('app_billing');
|
||||
const isPublicProfileEnabled = true; // Todo getFlag('app_public_profile');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<Link to="/settings/profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
<Trans>Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isPublicProfileEnabled && (
|
||||
<Link to="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Teams</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Lock className="mr-2 h-5 w-5" />
|
||||
<Trans>Security</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/tokens">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/tokens') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/webhooks">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<Link to="/settings/billing">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
<Trans>Billing</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { msg } from '@lingui/macro';
|
||||
|
||||
export const EXPIRATION_DATES = {
|
||||
ONE_WEEK: msg`7 days`,
|
||||
ONE_MONTH: msg`1 month`,
|
||||
THREE_MONTHS: msg`3 months`,
|
||||
SIX_MONTHS: msg`6 months`,
|
||||
ONE_YEAR: msg`12 months`,
|
||||
} as const;
|
||||
@ -0,0 +1,190 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { ApiToken } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DeleteTokenDialogProps = {
|
||||
teamId?: number;
|
||||
token: Pick<ApiToken, 'id' | 'name'>;
|
||||
onDelete?: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function DeleteTokenDialog({
|
||||
teamId,
|
||||
token,
|
||||
onDelete,
|
||||
children,
|
||||
}: DeleteTokenDialogProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const deleteMessage = _(msg`delete ${token.name}`);
|
||||
|
||||
const ZDeleteTokenDialogSchema = z.object({
|
||||
tokenName: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||
}),
|
||||
});
|
||||
|
||||
type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenDialogSchema>;
|
||||
|
||||
const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({
|
||||
onSuccess() {
|
||||
onDelete?.();
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<TDeleteTokenByIdMutationSchema>({
|
||||
resolver: zodResolver(ZDeleteTokenDialogSchema),
|
||||
values: {
|
||||
tokenName: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await deleteTokenMutation({
|
||||
id: token.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Token deleted`),
|
||||
description: _(msg`The token was deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
// router.refresh(); // Todo
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete this token. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
}
|
||||
}, [isOpen, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
|
||||
>
|
||||
<DialogTrigger asChild={true}>
|
||||
{children ?? (
|
||||
<Button className="mr-4" variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure you want to delete this token?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that this action is irreversible. Once confirmed, your token will be
|
||||
permanently deleted.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>I'm sure! Delete it</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
|
||||
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
||||
|
||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||
|
||||
export type CreateWebhookDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<TCreateWebhookFormSchema>({
|
||||
resolver: zodResolver(ZCreateWebhookFormSchema),
|
||||
values: {
|
||||
webhookUrl: '',
|
||||
eventTriggers: [],
|
||||
secret: '',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
|
||||
|
||||
const onSubmit = async ({
|
||||
enabled,
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
}: TCreateWebhookFormSchema) => {
|
||||
try {
|
||||
await createWebhook({
|
||||
enabled,
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook created`),
|
||||
description: _(msg`The webhook was successfully created.`),
|
||||
});
|
||||
|
||||
form.reset();
|
||||
|
||||
// router.refresh(); // Todo
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while creating the webhook. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0">
|
||||
<Trans>Create Webhook</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-lg" position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Create webhook</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>On this page, you can create a new webhook.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel required>
|
||||
<Trans>Webhook URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The URL for Documenso to send webhook events to.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eventTriggers"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel required>
|
||||
<Trans>Triggers</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TriggerMultiSelectCombobox
|
||||
listValues={value}
|
||||
onChange={(values: string[]) => {
|
||||
onChange(values);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Secret</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
className="bg-background"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
A secret that will be sent to your URL so you can verify that the request
|
||||
has been sent by Documenso
|
||||
</Trans>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,179 @@
|
||||
'use effect';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Webhook } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DeleteWebhookDialogProps = {
|
||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||
onDelete?: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const deleteMessage = _(msg`delete ${webhook.webhookUrl}`);
|
||||
|
||||
const ZDeleteWebhookFormSchema = z.object({
|
||||
webhookUrl: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||
}),
|
||||
});
|
||||
|
||||
type TDeleteWebhookFormSchema = z.infer<typeof ZDeleteWebhookFormSchema>;
|
||||
|
||||
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
|
||||
|
||||
const form = useForm<TDeleteWebhookFormSchema>({
|
||||
resolver: zodResolver(ZDeleteWebhookFormSchema),
|
||||
values: {
|
||||
webhookUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await deleteWebhook({ id: webhook.id, teamId: team?.id });
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook deleted`),
|
||||
description: _(msg`The webhook has been successfully deleted.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
// router.refresh(); // Todo
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete it. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? (
|
||||
<Button className="mr-4" variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Delete Webhook</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Please note that this action is irreversible. Once confirmed, your webhook will be
|
||||
permanently deleted.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing:{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>I'm sure! Delete it</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/macro';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
type TriggerMultiSelectComboboxProps = {
|
||||
listValues: string[];
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
export const TriggerMultiSelectCombobox = ({
|
||||
listValues,
|
||||
onChange,
|
||||
}: TriggerMultiSelectComboboxProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
const triggerEvents = Object.values(WebhookTriggerEvents);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValues(listValues);
|
||||
}, [listValues]);
|
||||
|
||||
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
|
||||
|
||||
const handleSelect = (currentValue: string) => {
|
||||
let newSelectedValues;
|
||||
|
||||
if (selectedValues.includes(currentValue)) {
|
||||
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||
} else {
|
||||
newSelectedValues = [...selectedValues, currentValue];
|
||||
}
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
onChange(newSelectedValues);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
<Plural value={selectedValues.length} zero="Select values" other="# selected..." />
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={truncateTitle(
|
||||
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
|
||||
15,
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
<Trans>No value found.</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allEvents.map((value: string, i: number) => (
|
||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{toFriendlyWebhookEventName(value)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user