Compare commits

..

1 Commits

Author SHA1 Message Date
Lucas Smith 607f22513a feat: allow adding audit logs to compeleted document 2025-05-19 09:10:08 +10:00
20 changed files with 476 additions and 664 deletions
@@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
@@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
includeAuditLog: settings?.includeAuditLog ?? false,
signatureTypes: extractTeamSignatureSettings(settings),
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
@@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
Controls whether the signing certificate will be included with the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
@@ -316,6 +320,36 @@ export const TeamDocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Log in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the audit log will be included with the document when it is
downloaded. The audit log can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -166,7 +166,7 @@ export const DocumentSigningFieldContainer = ({
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
@@ -12,7 +12,14 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
SplitButton,
SplitButtonAction,
SplitButtonDropdown,
SplitButtonDropdownItem,
} from '@documenso/ui/primitives/split-button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
@@ -42,6 +49,12 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
: `${documentsPath}/${document.id}/edit`;
const { mutateAsync: downloadCertificate, isPending: isDownloadingCertificate } =
trpc.document.downloadCertificate.useMutation();
const { mutateAsync: downloadAuditLogs, isPending: isDownloadingAuditLogs } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
@@ -71,6 +84,125 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
}
};
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
{
documentId: document.id,
},
{
context: {
teamId: document.team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadCertificateClick = async () => {
try {
const { url } = await downloadCertificate({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onDownloadAuditLogClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
@@ -110,10 +242,26 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
<SplitButton className="w-full">
<SplitButtonAction onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</SplitButtonAction>
<SplitButtonDropdown>
<SplitButtonDropdownItem onClick={onDownloadOriginalClick}>
<Trans>Download Original Document</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadCertificateClick}>
<Trans>Download Document Certificate</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadAuditLogClick}>
<Trans>Download Audit Log</Trans>
</SplitButtonDropdownItem>
</SplitButtonDropdown>
</SplitButton>
))
.otherwise(() => null);
};
@@ -1,47 +1,96 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/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 { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
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 '~/utils/truncate-title';
type WebhookMultiSelectComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const triggerEvents = Object.values(WebhookTriggerEvents).map((event) => ({
value: event,
label: toFriendlyWebhookEventName(event),
}));
export const WebhookMultiSelectCombobox = ({
listValues,
onChange,
}: WebhookMultiSelectComboboxProps) => {
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const value = listValues.map((event) => ({
value: event,
label: toFriendlyWebhookEventName(event),
}));
const triggerEvents = Object.values(WebhookTriggerEvents);
const onMutliSelectChange = (options: Option[]) => {
onChange(options.map((option) => option.value));
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 (
<MultiSelect
commandProps={{
label: _(msg`Select triggers`),
}}
defaultOptions={triggerEvents}
value={value}
onChange={onMutliSelectChange}
placeholder={_(msg`Select triggers`)}
hideClearAllButton
hidePlaceholderWhenSelected
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
/>
<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>
);
};
@@ -17,7 +17,6 @@ import {
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getTranslations } from '@documenso/lib/utils/i18n';
@@ -63,10 +62,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/');
}
const team = document.teamId
? await getTeamById({ teamId: document.teamId, userId: document.userId })
: null;
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
@@ -79,7 +74,6 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
document,
team,
documentLanguage,
isPlatformDocument,
auditLogs,
@@ -97,7 +91,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration.
*/
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, team, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui();
@@ -349,7 +343,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent>
</Card>
{!isPlatformDocument && !team?.teamGlobalSettings?.brandingHidePoweredBy && (
{isPlatformDocument && (
<div className="my-8 flex-row-reverse space-y-4">
<div className="flex items-end justify-end gap-x-4">
<div
+1 -1
View File
@@ -100,5 +100,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.11.0"
"version": "1.10.3"
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.11.0",
"version": "1.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.11.0",
"version": "1.10.3",
"workspaces": [
"apps/*",
"packages/*"
@@ -95,7 +95,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.11.0",
"version": "1.10.3",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.11.0",
"version": "1.10.3",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -16,6 +16,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
documentLanguage: z.string(),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
brandingEnabled: z.boolean(),
brandingLogo: z.string(),
brandingUrl: z.string(),
@@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogPdf } from '../../../server-only/htmltopdf/get-audit-log-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@@ -52,6 +53,7 @@ export const run = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditLog: true,
},
},
},
@@ -152,6 +154,13 @@ export const run = async ({
}).catch(() => null)
: null;
const auditLogData =
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
? await getAuditLogPdf({
documentId,
}).catch(() => null)
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
@@ -178,6 +187,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion
@@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogPdf } from '../htmltopdf/get-audit-log-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@@ -53,6 +54,7 @@ export const sealDocument = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditLog: true,
},
},
},
@@ -124,6 +126,13 @@ export const sealDocument = async ({
}).catch(() => null)
: null;
const auditLogData =
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
? await getAuditLogPdf({
documentId,
}).catch(() => null)
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@@ -146,6 +155,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
@@ -0,0 +1,69 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogPdf = async ({ documentId, language }: GetAuditLogPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
});
await browserContext.close();
void browser.close();
return result;
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
+7 -5
View File
@@ -582,11 +582,13 @@ enum TeamMemberInviteStatus {
}
model TeamGlobalSettings {
teamId Int @unique
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
teamId Int @unique
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
includeAuditLog Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
@@ -23,6 +23,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -54,6 +55,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -63,6 +65,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -14,6 +14,7 @@ export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
includeSenderDetails: z.boolean().optional().default(false),
includeSigningCertificate: z.boolean().optional().default(true),
includeAuditLog: z.boolean().optional().default(false),
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
+1 -13
View File
@@ -38,14 +38,6 @@ interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
const coords = useFieldPageCoords(field);
const onTooltipContentClick = () => {
const $fieldEl = document.querySelector<HTMLButtonElement>(`#field-${field.id} > button`);
if ($fieldEl) {
$fieldEl.click();
}
};
return createPortal(
<div
className={cn('pointer-events-none absolute')}
@@ -60,11 +52,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
<Tooltip delayDuration={0} open={!field.inserted || !field.fieldMeta}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent
className={tooltipVariants({ color, className })}
sideOffset={2}
onClick={onTooltipContentClick}
>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children}
<TooltipArrow />
</TooltipContent>
-15
View File
@@ -1,15 +0,0 @@
import React, { useEffect } from 'react';
export const useDebounce = <T>(value: T, delay?: number): T => {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};
-585
View File
@@ -1,585 +0,0 @@
'use client';
import * as React from 'react';
import { useEffect } from 'react';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { XIcon } from 'lucide-react';
import { useDebounce } from '../lib/use-debounce';
import { cn } from '../lib/utils';
import { Command, CommandGroup, CommandItem, CommandList } from './command';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultiSelectProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}
export interface MultiSelectRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
const CommandEmpty = ({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
className={cn('px-2 py-4 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
};
CommandEmpty.displayName = 'CommandEmpty';
const MultiSelect = ({
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
// eslint-disable-next-line @typescript-eslint/require-await
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don&lsquo;t have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 relative min-h-[38px] rounded-md border text-sm outline-none transition-[color,box-shadow] focus-within:ring-[3px]',
{
'p-1': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
!hideClearAllButton && 'pe-9',
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<div
key={option.value}
className={cn(
'animate-fadeIn bg-background text-secondary-foreground hover:bg-background data-fixed:pe-2 relative inline-flex h-7 cursor-default items-center rounded-md border pe-7 pl-2 ps-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
aria-label="Remove"
>
<XIcon size={14} aria-hidden="true" />
</button>
</div>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus) {
void onSearch?.(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-none disabled:cursor-not-allowed',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute end-0 top-0 flex size-9 items-center justify-center rounded-md border border-transparent outline-none transition-[color,box-shadow] focus-visible:ring-[3px]',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
aria-label="Clear all"
>
<XIcon size={16} aria-hidden="true" />
</button>
</div>
</div>
<div className="relative">
<div
className={cn(
'border-input absolute top-2 z-10 w-full overflow-hidden rounded-md border',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
!open && 'hidden',
)}
data-state={open ? 'open' : 'closed'}
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg outline-none"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</div>
</Command>
);
};
MultiSelect.displayName = 'MultiSelect';
export { MultiSelect };
+83
View File
@@ -0,0 +1,83 @@
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './dropdown-menu';
const SplitButtonContext = React.createContext<{
variant?: React.ComponentProps<typeof Button>['variant'];
size?: React.ComponentProps<typeof Button>['size'];
}>({});
const SplitButton = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
variant?: React.ComponentProps<typeof Button>['variant'];
size?: React.ComponentProps<typeof Button>['size'];
}
>(({ className, children, variant = 'default', size = 'default', ...props }, ref) => {
return (
<SplitButtonContext.Provider value={{ variant, size }}>
<div ref={ref} className={cn('inline-flex', className)} {...props}>
{children}
</div>
</SplitButtonContext.Provider>
);
});
SplitButton.displayName = 'SplitButton';
const SplitButtonAction = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => {
const { variant, size } = React.useContext(SplitButtonContext);
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn('flex-1 rounded-r-none border-r-0', className)}
{...props}
>
{children}
</Button>
);
});
SplitButtonAction.displayName = 'SplitButtonAction';
const SplitButtonDropdown = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, ...props }, ref) => {
const { variant, size } = React.useContext(SplitButtonContext);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={size}
className="rounded-l-none px-2 focus-visible:ring-offset-0"
>
<ChevronDown className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" {...props} ref={ref}>
{children}
</DropdownMenuContent>
</DropdownMenu>
);
},
);
SplitButtonDropdown.displayName = 'SplitButtonDropdown';
const SplitButtonDropdownItem = DropdownMenuItem;
export { SplitButton, SplitButtonAction, SplitButtonDropdown, SplitButtonDropdownItem };