mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
17 Commits
fix/templa
...
v1.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8540f24de0 | |||
| 67203d4bd7 | |||
| 9d1e638f0f | |||
| bd64ad9fef | |||
| 99b0ad574e | |||
| 9594e1fee8 | |||
| 5e3a2b8f76 | |||
| f928503a33 | |||
| c389670785 | |||
| 99ad2eb645 | |||
| 2f48679b0b | |||
| e40c5d9d24 | |||
| ab323f149f | |||
| bf1c1ff9dc | |||
| 516e237966 | |||
| ac7d24eb12 | |||
| 0931c472a7 |
@ -173,34 +173,59 @@ export const ConfigureFieldsView = ({
|
||||
});
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||
const { duplicate = false } = options ?? {};
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
||||
if (lastActiveField) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!duplicate) {
|
||||
setFieldClipboard(lastActiveField);
|
||||
if (duplicate) {
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
append(newField);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
append(newField);
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
||||
@ -533,6 +558,7 @@ export const ConfigureFieldsView = ({
|
||||
onMove={(node) => onFieldMove(node, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onFocus={() => setLastActiveField(field)}
|
||||
onBlur={() => setLastActiveField(null)}
|
||||
onAdvancedSettings={() => {
|
||||
|
||||
@ -166,7 +166,7 @@ export const DocumentSigningFieldContainer = ({
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
@ -1,96 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 '~/utils/truncate-title';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
|
||||
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 [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const { _ } = useLingui();
|
||||
|
||||
const triggerEvents = Object.values(WebhookTriggerEvents);
|
||||
const value = listValues.map((event) => ({
|
||||
value: event,
|
||||
label: toFriendlyWebhookEventName(event),
|
||||
}));
|
||||
|
||||
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);
|
||||
const onMutliSelectChange = (options: Option[]) => {
|
||||
onChange(options.map((option) => option.value));
|
||||
};
|
||||
|
||||
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>
|
||||
<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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
FileDown,
|
||||
FolderInput,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
MoveRight,
|
||||
@ -182,7 +184,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -201,7 +203,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
|
||||
{onMoveDocument && (
|
||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<FolderInput className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@ -69,8 +69,6 @@ export const TemplatesTableActionDropdown = ({
|
||||
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||
: `${templateRootPath}/${row.id}/edit`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||
|
||||
@ -17,6 +17,7 @@ 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';
|
||||
@ -62,6 +63,10 @@ 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);
|
||||
@ -74,6 +79,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
return {
|
||||
document,
|
||||
team,
|
||||
documentLanguage,
|
||||
isPlatformDocument,
|
||||
auditLogs,
|
||||
@ -91,7 +97,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, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
||||
const { document, team, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
||||
|
||||
const { i18n, _ } = useLingui();
|
||||
|
||||
@ -343,7 +349,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isPlatformDocument && (
|
||||
{!isPlatformDocument && !team?.teamGlobalSettings?.brandingHidePoweredBy && (
|
||||
<div className="my-8 flex-row-reverse space-y-4">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<div
|
||||
|
||||
@ -119,7 +119,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { createCookie } from 'react-router';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export const langCookie = createCookie('lang', {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365 * 2,
|
||||
httpOnly: true,
|
||||
secure: env('NODE_ENV') === 'production',
|
||||
});
|
||||
|
||||
@ -100,5 +100,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.10.0"
|
||||
"version": "1.11.0"
|
||||
}
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -95,7 +95,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
ZDeleteDocumentMutationSchema,
|
||||
ZDeleteFieldMutationSchema,
|
||||
ZDeleteRecipientMutationSchema,
|
||||
ZDownloadDocumentQuerySchema,
|
||||
ZDownloadDocumentSuccessfulSchema,
|
||||
ZFindTeamMembersResponseSchema,
|
||||
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||
@ -71,6 +72,7 @@ export const ApiContractV1 = c.router(
|
||||
downloadSignedDocument: {
|
||||
method: 'GET',
|
||||
path: '/api/v1/documents/:id/download',
|
||||
query: ZDownloadDocumentQuerySchema,
|
||||
responses: {
|
||||
200: ZDownloadDocumentSuccessfulSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
|
||||
@ -142,6 +142,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
|
||||
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { downloadOriginalDocument } = args.query;
|
||||
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
@ -177,7 +178,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDocumentCompleted(document.status)) {
|
||||
if (!downloadOriginalDocument && !isDocumentCompleted(document.status)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
@ -186,7 +187,9 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const { url } = await getPresignGetUrl(document.documentData.data);
|
||||
const { url } = await getPresignGetUrl(
|
||||
downloadOriginalDocument ? document.documentData.data : document.documentData.initialData,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
|
||||
@ -119,6 +119,15 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentQuerySchema = z.object({
|
||||
downloadOriginalDocument: z
|
||||
.preprocess((val) => String(val) === 'true' || String(val) === '1', z.boolean())
|
||||
.optional()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export type TDownloadDocumentQuerySchema = z.infer<typeof ZDownloadDocumentQuerySchema>;
|
||||
|
||||
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||
downloadUrl: z.string(),
|
||||
});
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { DocumentSource, type Prisma } from '@prisma/client';
|
||||
import { DocumentSource, type Prisma, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface DuplicateDocumentOptions {
|
||||
@ -86,7 +91,24 @@ export const duplicateDocument = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const createdDocument = await prisma.document.create(createDocumentArguments);
|
||||
const createdDocument = await prisma.document.create({
|
||||
...createDocumentArguments,
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse({
|
||||
...mapDocumentToWebhookDocumentPayload(createdDocument),
|
||||
recipients: createdDocument.recipients,
|
||||
documentMeta: createdDocument.documentMeta,
|
||||
}),
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: createdDocument.id,
|
||||
|
||||
@ -9,11 +9,13 @@ import {
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
@ -508,10 +510,8 @@ export const createDocumentFromTemplate = async ({
|
||||
fieldsToCreate = fieldsToCreate.concat(
|
||||
fields.map((field) => {
|
||||
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
||||
// Use type assertion to help TypeScript understand the structure
|
||||
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
|
||||
|
||||
return {
|
||||
const payload = {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
type: field.type,
|
||||
@ -522,8 +522,38 @@ export const createDocumentFromTemplate = async ({
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: updatedFieldMeta,
|
||||
fieldMeta: field.fieldMeta,
|
||||
};
|
||||
|
||||
if (prefillField) {
|
||||
match(prefillField)
|
||||
.with({ type: 'date' }, (selector) => {
|
||||
if (!selector.value) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Date value is required for field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const date = new Date(selector.value);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid date value for field ${field.id}: ${selector.value}`,
|
||||
});
|
||||
}
|
||||
|
||||
payload.customText = DateTime.fromJSDate(date).toFormat(
|
||||
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
);
|
||||
|
||||
payload.inserted = true;
|
||||
})
|
||||
.otherwise((selector) => {
|
||||
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -44,28 +44,36 @@ export const createTemplate = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const folder = await prisma.folder.findFirstOrThrow({
|
||||
where: {
|
||||
id: folderId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (teamId && !team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
@ -75,7 +83,7 @@ export const createTemplate = async ({
|
||||
userId,
|
||||
templateDocumentDataId,
|
||||
teamId,
|
||||
folderId: folder.id,
|
||||
folderId: folderId,
|
||||
templateMeta: {
|
||||
create: {
|
||||
language: team?.teamGlobalSettings?.documentLanguage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -155,6 +155,10 @@ export const ZFieldMetaPrefillFieldsSchema = z
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('date'),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@ -38,6 +38,14 @@ 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')}
|
||||
@ -52,7 +60,11 @@ 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}>
|
||||
<TooltipContent
|
||||
className={tooltipVariants({ color, className })}
|
||||
sideOffset={2}
|
||||
onClick={onTooltipContentClick}
|
||||
>
|
||||
{children}
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
|
||||
@ -24,7 +24,7 @@ export const SigningCard = ({
|
||||
signingCelebrationImage,
|
||||
}: SigningCardProps) => {
|
||||
return (
|
||||
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
|
||||
<div className={cn('relative w-full max-w-sm md:max-w-md', className)}>
|
||||
<SigningCardContent name={name} signature={signature} />
|
||||
|
||||
{signingCelebrationImage && (
|
||||
@ -48,7 +48,7 @@ export const SigningCard3D = ({
|
||||
|
||||
const [trackMouse, setTrackMouse] = useState(false);
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const timeoutRef = useRef<number | undefined>();
|
||||
|
||||
const cardX = useMotionValue(0);
|
||||
const cardY = useMotionValue(0);
|
||||
@ -103,7 +103,7 @@ export const SigningCard3D = ({
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
// Revert the card back to the center position after the mouse stops moving.
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
|
||||
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
||||
|
||||
@ -120,12 +120,15 @@ export const SigningCard3D = ({
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [onMouseMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
|
||||
className={cn('relative w-full max-w-sm md:max-w-md', className)}
|
||||
style={{ perspective: 800 }}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
15
packages/ui/lib/use-debounce.ts
Normal file
15
packages/ui/lib/use-debounce.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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;
|
||||
};
|
||||
@ -400,35 +400,60 @@ export const AddFieldsFormPartial = ({
|
||||
);
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||
const { duplicate = false } = options ?? {};
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
||||
if (lastActiveField) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!duplicate) {
|
||||
setFieldClipboard(lastActiveField);
|
||||
if (duplicate) {
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
append(newField);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
append(newField);
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedSigner?.email, toast],
|
||||
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@ -641,6 +666,7 @@ export const AddFieldsFormPartial = ({
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
|
||||
@ -311,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
@ -323,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
</div>
|
||||
)}
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
||||
<DocumentFlowFormContainerActions
|
||||
goNextLabel={msg`Save`}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { CopyPlus, Settings2, Trash } from 'lucide-react';
|
||||
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
@ -29,6 +31,7 @@ export type FieldItemProps = {
|
||||
onMove?: (_node: HTMLElement) => void;
|
||||
onRemove?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAllPages?: () => void;
|
||||
onAdvancedSettings?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
@ -55,15 +58,18 @@ export const FieldItem = ({
|
||||
onMove,
|
||||
onRemove,
|
||||
onDuplicate,
|
||||
onDuplicateAllPages,
|
||||
onAdvancedSettings,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onAdvancedSettings,
|
||||
recipientIndex = 0,
|
||||
hasErrors,
|
||||
active,
|
||||
onFieldActivate,
|
||||
onFieldDeactivate,
|
||||
}: FieldItemProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [coords, setCoords] = useState({
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
@ -304,6 +310,7 @@ export const FieldItem = ({
|
||||
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
||||
{advancedField && (
|
||||
<button
|
||||
title={_(msg`Advanced settings`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onAdvancedSettings}
|
||||
onTouchEnd={onAdvancedSettings}
|
||||
@ -313,6 +320,7 @@ export const FieldItem = ({
|
||||
)}
|
||||
|
||||
<button
|
||||
title={_(msg`Duplicate`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onDuplicate}
|
||||
onTouchEnd={onDuplicate}
|
||||
@ -321,6 +329,16 @@ export const FieldItem = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_(msg`Duplicate on all pages`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onDuplicateAllPages}
|
||||
onTouchEnd={onDuplicateAllPages}
|
||||
>
|
||||
<SquareStack className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_(msg`Remove`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onRemove}
|
||||
onTouchEnd={onRemove}
|
||||
|
||||
585
packages/ui/primitives/multiselect.tsx
Normal file
585
packages/ui/primitives/multiselect.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
'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‘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‘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 };
|
||||
@ -139,44 +139,64 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
);
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||
const { duplicate = false } = options ?? {};
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
||||
if (lastActiveField) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!duplicate) {
|
||||
setFieldClipboard(lastActiveField);
|
||||
if (duplicate) {
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
append(newField);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
append(newField);
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
append,
|
||||
lastActiveField,
|
||||
selectedSigner?.email,
|
||||
selectedSigner?.id,
|
||||
selectedSigner?.token,
|
||||
toast,
|
||||
],
|
||||
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
|
||||
Reference in New Issue
Block a user