diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 000000000..61a306ff0 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,48 @@ +Code Style and Structure: +- Write concise, technical TypeScript code with accurate examples +- Use functional and declarative programming patterns; avoid classes +- Prefer iteration and modularization over code duplication +- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError) +- Structure files: exported component, subcomponents, helpers, static content, types + +Naming Conventions: +- Use lowercase with dashes for directories (e.g., components/auth-wizard) +- Favor named exports for components + +TypeScript Usage: +- Use TypeScript for all code; prefer interfaces over types +- Avoid enums; use maps instead +- Use functional components with TypeScript interfaces + +Syntax and Formatting: +- Use the "function" keyword for pure functions +- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements +- Use declarative JSX + +Error Handling and Validation: +- Prioritize error handling: handle errors and edge cases early +- Use early returns and guard clauses +- Implement proper error logging and user-friendly messages +- Use Zod for form validation +- Model expected errors as return values in Server Actions +- Use error boundaries for unexpected errors + +UI and Styling: +- Use Shadcn UI, Radix, and Tailwind Aria for components and styling +- Implement responsive design with Tailwind CSS; use a mobile-first approach + +Performance Optimization: +- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC) +- Wrap client components in Suspense with fallback +- Use dynamic loading for non-critical components +- Optimize images: use WebP format, include size data, implement lazy loading + +Key Conventions: +- Use 'nuqs' for URL search parameter state management +- Optimize Web Vitals (LCP, CLS, FID) +- Limit 'use client': + - Favor server components and Next.js SSR + - Use only for Web API access in small components + - Avoid for data fetching or state management + +Follow Next.js docs for Data Fetching, Rendering, and Routing \ No newline at end of file diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index a3df5af29..35e1ccb7f 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -168,6 +168,7 @@ export const SinglePlayerClient = () => { sendStatus: 'NOT_SENT', role: 'SIGNER', authOptions: null, + signingOrder: null, }; const onFileDrop = async (file: File) => { diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 3976ae4c1..c5b22dcb3 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -85,6 +85,20 @@ export const EditDocumentForm = ({ }, }); + const { mutateAsync: setSigningOrderForDocument } = + trpc.document.setSigningOrderForDocument.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }), + ); + }, + }); + const { mutateAsync: addFields } = trpc.field.addFields.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newFields) => { @@ -204,15 +218,22 @@ export const EditDocumentForm = ({ const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { try { - await addSigners({ - documentId: document.id, - teamId: team?.id, - signers: data.signers.map((signer) => ({ - ...signer, - // Explicitly set to null to indicate we want to remove auth if required. - actionAuth: signer.actionAuth || null, - })), - }); + await Promise.all([ + setSigningOrderForDocument({ + documentId: document.id, + signingOrder: data.signingOrder, + }), + + addSigners({ + documentId: document.id, + teamId: team?.id, + signers: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth || null, + })), + }), + ]); // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); @@ -350,6 +371,7 @@ export const EditDocumentForm = ({ key={recipients.length} documentFlow={documentFlow.signers} recipients={recipients} + signingOrder={document.documentMeta?.signingOrder} fields={fields} isDocumentEnterprise={isDocumentEnterprise} onSubmit={onAddSignersFormSubmit} diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx index 19cab8e5d..9efd6e3b9 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -103,6 +103,19 @@ export const EditTemplateForm = ({ }, }); + const { mutateAsync: setSigningOrderForTemplate } = + trpc.template.setSigningOrderForTemplate.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.template.getTemplateWithDetailsById.setData( + { + id: initialTemplate.id, + }, + (oldData) => ({ ...(oldData || initialTemplate), ...newData }), + ); + }, + }); + const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { @@ -160,11 +173,19 @@ export const EditTemplateForm = ({ data: TAddTemplatePlacholderRecipientsFormSchema, ) => { try { - await addTemplateSigners({ - templateId: template.id, - teamId: team?.id, - signers: data.signers, - }); + await Promise.all([ + setSigningOrderForTemplate({ + templateId: template.id, + teamId: team?.id, + signingOrder: data.signingOrder, + }), + + addTemplateSigners({ + templateId: template.id, + teamId: team?.id, + signers: data.signers, + }), + ]); // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); @@ -262,6 +283,7 @@ export const EditTemplateForm = ({ documentFlow={documentFlow.signers} recipients={recipients} fields={fields} + signingOrder={template.templateMeta?.signingOrder} templateDirectLink={template.directLink} onSubmit={onAddTemplatePlaceholderFormSubmit} isEnterprise={isEnterprise} diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index ec6aa6de9..51918cec8 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -29,9 +29,16 @@ export type SigningFormProps = { recipient: Recipient; fields: Field[]; redirectUrl?: string | null; + isRecipientsTurn: boolean; }; -export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => { +export const SigningForm = ({ + document, + recipient, + fields, + redirectUrl, + isRecipientsTurn, +}: SigningFormProps) => { const router = useRouter(); const analytics = useAnalytics(); const { data: session } = useSession(); @@ -150,6 +157,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin fields={fields} fieldsValidated={fieldsValidated} role={recipient.role} + disabled={!isRecipientsTurn} /> @@ -213,6 +221,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin fields={fields} fieldsValidated={fieldsValidated} role={recipient.role} + disabled={!isRecipientsTurn} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index a5b70a29c..214b013ce 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -9,6 +9,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; @@ -42,6 +43,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); + const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); + + if (!isRecipientsTurn) { + return redirect(`/sign/${token}/waiting`); + } + const [document, fields, recipient, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, @@ -146,6 +153,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp document={document} fields={fields} completedFields={completedFields} + isRecipientsTurn={isRecipientsTurn} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 318409f88..09e97b7c4 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -23,6 +23,7 @@ export type SignDialogProps = { fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; role: RecipientRole; + disabled?: boolean; }; export const SignDialog = ({ @@ -32,6 +33,7 @@ export const SignDialog = ({ fieldsValidated, onSignatureComplete, role, + disabled = false, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); const truncatedTitle = truncateTitle(documentTitle); @@ -54,6 +56,7 @@ export const SignDialog = ({ size="lg" onClick={fieldsValidated} loading={isSubmitting} + disabled={disabled} > {isComplete ? Complete : Next field} diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index 50be5451a..ac380794f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -39,6 +39,7 @@ export type SigningPageViewProps = { recipient: Recipient; fields: Field[]; completedFields: CompletedField[]; + isRecipientsTurn: boolean; }; export const SigningPageView = ({ @@ -46,6 +47,7 @@ export const SigningPageView = ({ recipient, fields, completedFields, + isRecipientsTurn, }: SigningPageViewProps) => { const { documentData, documentMeta } = document; @@ -99,6 +101,7 @@ export const SigningPageView = ({ recipient={recipient} fields={fields} redirectUrl={documentMeta?.redirectUrl} + isRecipientsTurn={isRecipientsTurn} /> diff --git a/apps/web/src/app/(signing)/sign/[token]/waiting/page.tsx b/apps/web/src/app/(signing)/sign/[token]/waiting/page.tsx new file mode 100644 index 000000000..bf5466215 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/waiting/page.tsx @@ -0,0 +1,100 @@ +import Link from 'next/link'; +import { notFound, redirect } from 'next/navigation'; + +import { Trans } from '@lingui/macro'; + +import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Team } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type WaitingForTurnToSignPageProps = { + params: { token?: string }; +}; + +export default async function WaitingForTurnToSignPage({ + params: { token }, +}: WaitingForTurnToSignPageProps) { + setupI18nSSR(); + + if (!token) { + return notFound(); + } + + const { user } = await getServerComponentSession(); + + const [document, recipient] = await Promise.all([ + getDocumentAndSenderByToken({ token }).catch(() => null), + getRecipientByToken({ token }).catch(() => null), + ]); + + if (!document || !recipient) { + return notFound(); + } + + if (document.status === DocumentStatus.COMPLETED) { + return redirect(`/sign/${token}/complete`); + } + + let isOwnerOrTeamMember = false; + + let team: Team | null = null; + + if (user) { + isOwnerOrTeamMember = await getDocumentById({ + id: document.id, + userId: user.id, + teamId: document.teamId ?? undefined, + }) + .then((document) => !!document) + .catch(() => false); + + if (document.teamId) { + team = await getTeamById({ + userId: user.id, + teamId: document.teamId, + }); + } + } + + return ( +
+
+

+ Waiting for Your Turn +

+ +

+ + It's currently not your turn to sign. You will receive an email with instructions once + it's your turn to sign the document. + +

+ +

+ Please check your email for updates. +

+ +
+ {isOwnerOrTeamMember ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/embed/base-schema.ts b/apps/web/src/app/embed/base-schema.ts index fd44b4847..542e70724 100644 --- a/apps/web/src/app/embed/base-schema.ts +++ b/apps/web/src/app/embed/base-schema.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; export const ZBaseEmbedDataSchema = z.object({ - css: z.string().optional().transform(value => value || undefined), + css: z + .string() + .optional() + .transform((value) => value || undefined), }); diff --git a/apps/web/src/app/embed/completed.tsx b/apps/web/src/app/embed/completed.tsx index 3a4b62e66..50b74513a 100644 --- a/apps/web/src/app/embed/completed.tsx +++ b/apps/web/src/app/embed/completed.tsx @@ -18,15 +18,18 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
-

- The document is now completed, please follow any instructions provided within the parent application. +

+ + The document is now completed, please follow any instructions provided within the parent + application. +

); diff --git a/apps/web/src/app/embed/direct/[[...url]]/client.tsx b/apps/web/src/app/embed/direct/[[...url]]/client.tsx index 7f6378659..189e9f1ca 100644 --- a/apps/web/src/app/embed/direct/[[...url]]/client.tsx +++ b/apps/web/src/app/embed/direct/[[...url]]/client.tsx @@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { DateTime } from 'luxon'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; @@ -14,7 +15,7 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client'; -import { FieldType, type DocumentData, type Field } from '@documenso/prisma/client'; +import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, @@ -34,7 +35,6 @@ import type { DirectTemplateLocalField } from '~/app/(recipient)/d/[token]/sign- import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { Logo } from '~/components/branding/logo'; -import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { EmbedClientLoading } from '../../client-loading'; import { EmbedDocumentCompleted } from '../../completed'; import { EmbedDocumentFields } from '../../document-fields'; @@ -307,7 +307,7 @@ export const EmbedDirectTemplateClientPage = ({
{(!hasFinishedInit || !hasDocumentLoaded) && } -
+
{/* Viewer */}
-
+
{/* Header */}
-

+

Sign document

{/* Form */} -
+
-
+
-
+
{pendingFields.length > 0 ? (
{/* Form */} -
+
-
+
-
+
{pendingFields.length > 0 ? ( - - ))} -
+ Enable signing order + + + )} + /> + { + $sensorApi.current = api; + }, + ]} + > + + {(provided) => ( +
+ {signers.map((signer, index) => ( + + {(provided, snapshot) => ( +
+ + {isSigningOrderSequential && ( + ( + + + + { + field.onChange(e); + handleSigningOrderChange(index, e.target.value); + }} + onBlur={(e) => { + field.onBlur(); + handleSigningOrderChange(index, e.target.value); + }} + disabled={ + snapshot.isDragging || + isSubmitting || + hasBeenSentToRecipientId(signer.nativeId) + } + /> + + + + )} + /> + )} + + ( + + {!showAdvancedSettings && ( + + Email + + )} + + + + + + + + )} + /> + + ( + + {!showAdvancedSettings && ( + + Name + + )} + + + + + + + + )} + /> + + {showAdvancedSettings && isDocumentEnterprise && ( + ( + + + + + + + + )} + /> + )} + +
+ ( + + + + + + + + )} + /> + + +
+
+
+ )} +
+ ))} + + {provided.placeholder} +
+ )} +
+
{ diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index 676d807b4..b9b8dd4a8 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -1,12 +1,14 @@ 'use client'; -import React, { useEffect, useId, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { DropResult, SensorAPI } from '@hello-pangea/dnd'; +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { motion } from 'framer-motion'; -import { Link2Icon, Plus, Trash } from 'lucide-react'; +import { GripVerticalIcon, Link2Icon, Plus, Trash } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { useFieldArray, useForm } from 'react-hook-form'; @@ -14,7 +16,12 @@ import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth' import { nanoid } from '@documenso/lib/universal/id'; import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; import type { TemplateDirectLink } from '@documenso/prisma/client'; -import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; +import { + DocumentSigningOrder, + type Field, + type Recipient, + RecipientRole, +} from '@documenso/prisma/client'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; @@ -43,6 +50,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; + signingOrder?: DocumentSigningOrder | null; templateDirectLink: TemplateDirectLink | null; isEnterprise: boolean; isDocumentPdfLoaded: boolean; @@ -55,10 +63,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ recipients, templateDirectLink, fields, + signingOrder, isDocumentPdfLoaded, onSubmit, }: AddTemplatePlaceholderRecipientsFormProps) => { const initialId = useId(); + const $sensorApi = useRef(null); const { _ } = useLingui(); const { data: session } = useSession(); @@ -79,17 +89,19 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ role: RecipientRole.SIGNER, actionAuth: undefined, ...generateRecipientPlaceholder(1), + signingOrder: 1, }, ]; } - return recipients.map((recipient) => ({ + return recipients.map((recipient, index) => ({ nativeId: recipient.id, formId: String(recipient.id), name: recipient.name, email: recipient.email, role: recipient.role, actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, + signingOrder: recipient.signingOrder ?? index + 1, })); }; @@ -97,12 +109,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), defaultValues: { signers: generateDefaultFormSigners(), + signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, }, }); useEffect(() => { form.reset({ signers: generateDefaultFormSigners(), + signingOrder: signingOrder || DocumentSigningOrder.PARALLEL, }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -126,8 +140,18 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const { formState: { errors, isSubmitting }, control, + watch, } = form; + const watchedSigners = watch('signers'); + const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; + + const normalizeSigningOrders = (signers: typeof watchedSigners) => { + return signers + .sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0)) + .map((signer, index) => ({ ...signer, signingOrder: index + 1 })); + }; + const onFormSubmit = form.handleSubmit(onSubmit); const { @@ -145,6 +169,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ name: user?.name ?? '', email: user?.email ?? '', role: RecipientRole.SIGNER, + signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, }); }; @@ -153,6 +178,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ formId: nanoid(12), role: RecipientRole.SIGNER, ...generateRecipientPlaceholder(placeholderRecipientCount), + signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, }); setPlaceholderRecipientCount((count) => count + 1); @@ -160,6 +186,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const onRemoveSigner = (index: number) => { removeSigner(index); + const updatedSigners = signers.filter((_, idx) => idx !== index); + form.setValue('signers', normalizeSigningOrders(updatedSigners)); }; const isSignerDirectRecipient = ( @@ -171,6 +199,127 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ ); }; + const onDragEnd = useCallback( + async (result: DropResult) => { + if (!result.destination) return; + + const items = Array.from(watchedSigners); + const [reorderedSigner] = items.splice(result.source.index, 1); + + const insertIndex = result.destination.index; + + items.splice(insertIndex, 0, reorderedSigner); + + const updatedSigners = items.map((item, index) => ({ + ...item, + signingOrder: index + 1, + })); + + updatedSigners.forEach((item, index) => { + const keys: (keyof typeof item)[] = [ + 'formId', + 'nativeId', + 'email', + 'name', + 'role', + 'signingOrder', + 'actionAuth', + ]; + keys.forEach((key) => { + form.setValue(`signers.${index}.${key}` as const, item[key]); + }); + }); + + const currentLength = form.getValues('signers').length; + if (currentLength > updatedSigners.length) { + for (let i = updatedSigners.length; i < currentLength; i++) { + form.unregister(`signers.${i}`); + } + } + + await form.trigger('signers'); + }, + [form, watchedSigners], + ); + + const triggerDragAndDrop = useCallback( + (fromIndex: number, toIndex: number) => { + if (!$sensorApi.current) { + return; + } + + const draggableId = signers[fromIndex].id; + + const preDrag = $sensorApi.current.tryGetLock(draggableId); + + if (!preDrag) { + return; + } + + const drag = preDrag.snapLift(); + + setTimeout(() => { + // Move directly to the target index + if (fromIndex < toIndex) { + for (let i = fromIndex; i < toIndex; i++) { + drag.moveDown(); + } + } else { + for (let i = fromIndex; i > toIndex; i--) { + drag.moveUp(); + } + } + + setTimeout(() => { + drag.drop(); + }, 500); + }, 0); + }, + [signers], + ); + + const updateSigningOrders = useCallback( + (newIndex: number, oldIndex: number) => { + const updatedSigners = form.getValues('signers').map((signer, index) => { + if (index === oldIndex) { + return { ...signer, signingOrder: newIndex + 1 }; + } else if (index >= newIndex && index < oldIndex) { + return { ...signer, signingOrder: (signer.signingOrder ?? index + 1) + 1 }; + } else if (index <= newIndex && index > oldIndex) { + return { ...signer, signingOrder: Math.max(1, (signer.signingOrder ?? index + 1) - 1) }; + } + return signer; + }); + + updatedSigners.forEach((signer, index) => { + form.setValue(`signers.${index}.signingOrder`, signer.signingOrder); + }); + }, + [form], + ); + + const handleSigningOrderChange = useCallback( + (index: number, newOrderString: string) => { + const newOrder = parseInt(newOrderString, 10); + + if (!newOrderString.trim()) { + return; + } + + if (Number.isNaN(newOrder)) { + form.setValue(`signers.${index}.signingOrder`, index + 1); + return; + } + + const newIndex = newOrder - 1; + if (index !== newIndex) { + updateSigningOrders(newIndex, index); + triggerDragAndDrop(index, newIndex); + } + }, + [form, triggerDragAndDrop, updateSigningOrders], + ); + return ( <>
-
- {signers.map((signer, index) => ( - - ( - - {!showAdvancedSettings && index === 0 && ( - - Email - - )} - - - - - - - - )} - /> - - ( - - {!showAdvancedSettings && index === 0 && ( - - Name - - )} - - - - - - - - )} - /> - - {showAdvancedSettings && isEnterprise && ( - ( - - - - - - - - )} + {/* Enable sequential signing checkbox */} + ( + + + + field.onChange( + checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, + ) + } + disabled={isSubmitting} /> - )} + - ( - - - - + + Enable signing order + + + )} + /> - - - )} - /> + {/* Drag and drop context */} + { + $sensorApi.current = api; + }, + ]} + > + + {(provided) => ( +
+ {signers.map((signer, index) => ( + + {(provided, snapshot) => ( +
+ + {isSigningOrderSequential && ( + ( + + + + { + field.onChange(e); + handleSigningOrderChange(index, e.target.value); + }} + onBlur={(e) => { + field.onBlur(); + handleSigningOrderChange(index, e.target.value); + }} + disabled={ + snapshot.isDragging || + isSubmitting || + isSignerDirectRecipient(signer) + } + /> + + + + )} + /> + )} - {isSignerDirectRecipient(signer) ? ( - - - - - -

- Direct link receiver -

-

- - This field cannot be modified or deleted. When you share this template's - direct link or add it to your public profile, anyone who accesses it can - input their name and email, and fill in the fields assigned to them. - -

-
-
- ) : ( - - )} -
- ))} -
+ ( + + {!showAdvancedSettings && index === 0 && ( + + Email + + )} + + + + + + + + )} + /> + + ( + + {!showAdvancedSettings && index === 0 && ( + + Name + + )} + + + + + + + + )} + /> + + {showAdvancedSettings && isEnterprise && ( + ( + + + + + + + + )} + /> + )} + +
+ ( + + + + + + + + )} + /> + + {isSignerDirectRecipient(signer) ? ( + + + + + +

+ Direct link receiver +

+

+ + This field cannot be modified or deleted. When you share + this template's direct link or add it to your public + profile, anyone who accesses it can input their name and + email, and fill in the fields assigned to them. + +

+
+
+ ) : ( + + )} +
+ +
+ )} + + ))} + + {provided.placeholder} +
+ )} + + { diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index d6d55f6c2..b5142b60c 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -23,6 +23,7 @@ --field-card-foreground: 222.2 47.4% 11.2%; --widget: 0 0% 97%; + --widget-foreground: 0 0% 95%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; @@ -135,7 +136,6 @@ /* Surface */ --new-surface-black: 0, 0%, 0%; --new-surface-white: 0, 0%, 91%; - } .dark { @@ -154,6 +154,7 @@ --card-foreground: 0 0% 95%; --widget: 0 0% 14.9%; + --widget-foreground: 0 0% 18%; --border: 0 0% 27.9%; --input: 0 0% 27.9%;