feat: signing order (#1290)

Adds the ability to specify an optional signing order for documents.
When specified a document will be considered sequential with recipients
only being allowed to sign in the order that they were specified in.
This commit is contained in:
Ephraim Duncan
2024-09-16 12:36:45 +00:00
committed by GitHub
parent 357bdd374f
commit 3d644db286
66 changed files with 1999 additions and 606 deletions

View File

@ -18,7 +18,7 @@ export type RecipientRoleSelectProps = SelectProps & {
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
({ hideCCRecipients, ...props }, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[60px]">
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>

View File

@ -27,6 +27,7 @@
},
"dependencies": {
"@documenso/lib": "*",
"@hello-pangea/dnd": "^16.6.0",
"@hookform/resolvers": "^3.3.0",
"@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3",
@ -73,6 +74,7 @@
"react-hook-form": "^7.45.4",
"react-pdf": "7.7.3",
"react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",

View File

@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
@ -478,9 +479,20 @@ export const AddFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
);
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
.filter(([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER)
.map(
([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[prop('id'), 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {

View File

@ -1,20 +1,23 @@
'use client';
import React, { useId, useMemo, useState } from 'react';
import React, { useCallback, 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 { Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole, SendStatus } from '@documenso/prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } 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 +46,7 @@ export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
@ -52,6 +56,7 @@ export const AddSignersFormPartial = ({
documentFlow,
recipients,
fields,
signingOrder,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
@ -64,32 +69,42 @@ export const AddSignersFormPartial = ({
const user = session?.user;
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
const { currentStep, totalSteps, previousStep } = useStep();
const defaultRecipients = [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: undefined,
},
];
const form = useForm<TAddSignersFormSchema>({
resolver: zodResolver(ZAddSignersFormSchema),
defaultValues: {
signers:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}))
: [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
},
],
? sortBy(
recipients.map((recipient, index) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})),
[prop('signingOrder'), 'asc'],
[prop('nativeId'), 'asc'],
)
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
},
});
@ -116,6 +131,13 @@ export const AddSignersFormPartial = ({
} = 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);
@ -133,18 +155,25 @@ export const AddSignersFormPartial = ({
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
const hasBeenSentToRecipientId = (id?: number) => {
if (!id) {
return false;
}
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
return recipients.some(
(recipient) =>
recipient.id === id &&
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC,
);
};
const hasBeenSentToRecipientId = useCallback(
(id?: number) => {
if (!id) {
return false;
}
return recipients.some(
(recipient) =>
recipient.id === id &&
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC,
);
},
[recipients],
);
const onAddSigner = () => {
appendSigner({
@ -153,6 +182,7 @@ export const AddSignersFormPartial = ({
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
};
@ -170,6 +200,9 @@ export const AddSignersFormPartial = ({
}
removeSigner(index);
const updatedSigners = signers.filter((_, idx) => idx !== index);
form.setValue('signers', normalizeSigningOrders(updatedSigners));
};
const onAddSelfSigner = () => {
@ -183,6 +216,7 @@ export const AddSignersFormPartial = ({
email: user?.email ?? '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
});
}
};
@ -193,6 +227,130 @@ export const AddSignersFormPartial = ({
}
};
const onDragEnd = useCallback(
async (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
let insertIndex = result.destination.index;
while (insertIndex < items.length && hasBeenSentToRecipientId(items[insertIndex].nativeId)) {
insertIndex++;
}
items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = items.map((item, index) => ({
...item,
signingOrder: hasBeenSentToRecipientId(item.nativeId) ? 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, hasBeenSentToRecipientId, 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 (
<>
<DocumentFlowFormContainerHeader
@ -207,125 +365,261 @@ export const AddSignersFormPartial = ({
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => (
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="email"
placeholder={_(msg`Email`)}
{...field}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
<FormControl>
<Input
placeholder={_(msg`Name`)}
{...field}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings && isDocumentEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
<FormField
control={form.control}
name="signingOrder"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checkClassName="text-white"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
disabled={isSubmitting || hasDocumentBeenSent}
/>
)}
</FormControl>
<FormField
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trash className="h-5 w-5" />
</button>
</motion.fieldset>
))}
</div>
<Trans>Enable signing order</Trans>
</FormLabel>
</FormItem>
)}
/>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.id}-${signer.signingOrder}`}
draggableId={signer.id}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.nativeId}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
'border-b pt-2': showAdvancedSettings,
'grid-cols-12 pr-3': isSigningOrderSequential,
})}
>
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem className="col-span-2 mt-auto flex items-center gap-x-1 space-y-0">
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
className={cn(
'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="email"
placeholder="Email"
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId)
}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<Input
placeholder={_(msg`Name`)}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId)
}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings && isDocumentEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="mt-auto">
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={
snapshot.isDragging ||
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-4 w-4" />
</button>
</div>
</motion.fieldset>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage
className="mt-2"

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { RecipientRole } from '.prisma/client';
import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z
.object({
@ -14,11 +14,13 @@ export const ZAddSignersFormSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.refine(
(schema) => {

View File

@ -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<SensorAPI | null>(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 (
<>
<DocumentFlowFormContainerHeader
@ -185,154 +334,273 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => (
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting}
className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="email"
placeholder={_(msg`Email`)}
{...field}
disabled={
field.disabled ||
isSubmitting ||
signers[index].email === user?.email ||
isSignerDirectRecipient(signer)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<Input
placeholder={_(msg`Name`)}
{...field}
disabled={
field.disabled ||
isSubmitting ||
signers[index].email === user?.email ||
isSignerDirectRecipient(signer)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings && isEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
{/* Enable sequential signing checkbox */}
<FormField
control={form.control}
name="signingOrder"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="signingOrder"
checkClassName="text-white"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
}
disabled={isSubmitting}
/>
)}
</FormControl>
<FormField
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
/>
</FormControl>
<FormLabel
htmlFor="signingOrder"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Enable signing order</Trans>
</FormLabel>
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
{/* Drag and drop context */}
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.id}-${signer.signingOrder}`}
draggableId={signer.id}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
isSignerDirectRecipient(signer) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.nativeId}
disabled={isSubmitting || isSignerDirectRecipient(signer)}
className={cn('grid grid-cols-10 items-end gap-2 pb-2', {
'border-b pt-2': showAdvancedSettings,
'grid-cols-12 pr-3': isSigningOrderSequential,
})}
>
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem className="col-span-2 mt-auto flex items-center gap-x-1 space-y-0">
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
className={cn(
'w-full text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
isSignerDirectRecipient(signer)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{isSignerDirectRecipient(signer) ? (
<Tooltip>
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
<Link2Icon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<h3 className="text-foreground text-lg font-semibold">
<Trans>Direct link receiver</Trans>
</h3>
<p className="text-muted-foreground mt-1">
<Trans>
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.
</Trans>
</p>
</TooltipContent>
</Tooltip>
) : (
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
)}
</motion.fieldset>
))}
</div>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="email"
placeholder={_(msg`Email`)}
{...field}
disabled={
field.disabled ||
isSubmitting ||
signers[index].email === user?.email ||
isSignerDirectRecipient(signer)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-4': !showAdvancedSettings,
'col-span-5': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<Input
placeholder={_(msg`Name`)}
{...field}
disabled={
field.disabled ||
isSubmitting ||
signers[index].email === user?.email ||
isSignerDirectRecipient(signer)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings && isEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('col-span-8', {
'col-span-10': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2 flex gap-x-2">
<FormField
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isSignerDirectRecipient(signer) ? (
<Tooltip>
<TooltipTrigger className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80">
<Link2Icon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<h3 className="text-foreground text-lg font-semibold">
<Trans>Direct link receiver</Trans>
</h3>
<p className="text-muted-foreground mt-1">
<Trans>
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.
</Trans>
</p>
</TooltipContent>
</Tooltip>
) : (
<button
type="button"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
)}
</div>
</motion.fieldset>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage
className="mt-2"

View File

@ -1,9 +1,9 @@
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { DocumentSigningOrder, RecipientRole } from '@documenso/prisma/client';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { RecipientRole } from '.prisma/client';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({
@ -14,11 +14,13 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
email: z.string().min(1).email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
})
.refine(
(schema) => {

View File

@ -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%;