mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 02:01:33 +10:00
feat: assistant role (#1588)
## Description Introduces the ability for users with the **Assistant** role to prefill fields on behalf of other signers. Assistants can fill in various field types such as text, checkboxes, dates, and more, streamlining the document preparation process before it reaches the final signers. https://github.com/user-attachments/assets/c1321578-47ec-405b-a70a-7d9578385895
This commit is contained in:
@ -540,12 +540,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
if (i > 1) {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
}
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Email')
|
||||
.getByLabel('Email')
|
||||
.nth(i - 1)
|
||||
.focus();
|
||||
|
||||
await page
|
||||
.getByLabel('Email')
|
||||
.nth(i - 1)
|
||||
.fill(`user${i}@example.com`);
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Name')
|
||||
.getByLabel('Name')
|
||||
.nth(i - 1)
|
||||
.fill(`User ${i}`);
|
||||
}
|
||||
|
||||
@ -84,6 +84,9 @@ export const TemplateDocumentInvite = ({
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Continue by assisting with the document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
|
||||
@ -104,6 +107,7 @@ export const TemplateDocumentInvite = ({
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
|
||||
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
|
||||
description: 'Approval request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: {
|
||||
description: 'Assisting request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.CC]: {
|
||||
description: 'CC',
|
||||
},
|
||||
|
||||
@ -32,12 +32,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||
roleName: msg`Viewer`,
|
||||
roleNamePlural: msg`Viewers`,
|
||||
},
|
||||
[RecipientRole.ASSISTANT]: {
|
||||
actionVerb: msg`Assist`,
|
||||
actioned: msg`Assisted`,
|
||||
progressiveVerb: msg`Assisting`,
|
||||
roleName: msg`Assistant`,
|
||||
roleNamePlural: msg`Assistants`,
|
||||
},
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_DISPLAY_TYPE = {
|
||||
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
|
||||
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
|
||||
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
|
||||
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
|
||||
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
|
||||
[RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`,
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
@ -45,4 +59,5 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
[RecipientRole.APPROVER]: msg`I am an approver of this document`,
|
||||
[RecipientRole.CC]: msg`I am required to receive a copy of this document`,
|
||||
[RecipientRole.VIEWER]: msg`I am a viewer of this document`,
|
||||
[RecipientRole.ASSISTANT]: msg`I am an assistant of this document`,
|
||||
} satisfies Record<keyof typeof RecipientRole, MessageDescriptor>;
|
||||
|
||||
@ -14,8 +14,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
@ -1,15 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type GetFieldsForTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
type: {
|
||||
not: FieldType.SIGNATURE,
|
||||
},
|
||||
recipient: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
},
|
||||
documentId: recipient.documentId,
|
||||
},
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
recipient: {
|
||||
token,
|
||||
},
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
|
||||
@ -4,7 +4,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
token: string;
|
||||
@ -17,11 +17,28 @@ export const removeSignedFieldWithToken = async ({
|
||||
fieldId,
|
||||
requestMetadata,
|
||||
}: RemovedSignedFieldWithTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
token,
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@ -30,7 +47,7 @@ export const removeSignedFieldWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const { document, recipient } = field;
|
||||
const { document } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
@ -40,7 +57,10 @@ export const removeSignedFieldWithToken = async ({
|
||||
throw new Error(`Document ${document.id} must be pending`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
if (
|
||||
recipient?.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@ -66,20 +86,22 @@ export const removeSignedFieldWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient?.name,
|
||||
email: recipient?.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -10,7 +10,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
@ -56,20 +56,41 @@ export const signFieldWithToken = async ({
|
||||
authOptions,
|
||||
requestMetadata,
|
||||
}: SignFieldWithTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
token,
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
document: true,
|
||||
document: {
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
},
|
||||
recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { document, recipient } = field;
|
||||
const { document } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
@ -87,7 +108,10 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Document ${document.id} must be pending for signing`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
if (
|
||||
recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@ -183,6 +207,8 @@ export const signFieldWithToken = async ({
|
||||
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
||||
}
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedField = await tx.field.update({
|
||||
where: {
|
||||
@ -219,11 +245,14 @@ export const signFieldWithToken = async ({
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
type:
|
||||
assistant && field.recipientId !== assistant.id
|
||||
? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
|
||||
: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
email: assistant?.email ?? recipient.email,
|
||||
name: assistant?.name ?? recipient.name,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
|
||||
@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface GetRecipientsForAssistantOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssistantOptions) => {
|
||||
const assistant = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assistant) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Assistant not found',
|
||||
});
|
||||
}
|
||||
|
||||
let recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: assistant.documentId,
|
||||
signingOrder: {
|
||||
gte: assistant.signingOrder ?? 0,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
recipientId: assistant.id,
|
||||
},
|
||||
{
|
||||
type: {
|
||||
not: FieldType.SIGNATURE,
|
||||
},
|
||||
documentId: assistant.documentId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Omit the token for recipients other than the assistant so
|
||||
// it doesn't get sent to the client.
|
||||
recipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
token: recipient.id === assistant.id ? token : '',
|
||||
}));
|
||||
|
||||
return recipients;
|
||||
};
|
||||
@ -28,6 +28,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
|
||||
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated
|
||||
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
|
||||
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
|
||||
@ -45,6 +46,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
'SIGNING_REQUEST',
|
||||
'VIEW_REQUEST',
|
||||
'APPROVE_REQUEST',
|
||||
'ASSISTING_REQUEST',
|
||||
'CC',
|
||||
'DOCUMENT_COMPLETED',
|
||||
]);
|
||||
@ -313,6 +315,83 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field prefilled by assistant.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
fieldId: z.string(),
|
||||
|
||||
// Organised into union to allow us to extend each field if required.
|
||||
field: z.union([
|
||||
z.object({
|
||||
type: z.literal(FieldType.INITIALS),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.EMAIL),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DATE),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NAME),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.TEXT),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.RADIO),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.CHECKBOX),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DROPDOWN),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NUMBER),
|
||||
data: z.string(),
|
||||
}),
|
||||
]),
|
||||
fieldSecurity: z.preprocess(
|
||||
(input) => {
|
||||
const legacyNoneSecurityType = JSON.stringify({
|
||||
type: 'NONE',
|
||||
});
|
||||
|
||||
// Replace legacy 'NONE' field security type with undefined.
|
||||
if (
|
||||
typeof input === 'object' &&
|
||||
input !== null &&
|
||||
JSON.stringify(input) === legacyNoneSecurityType
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return input;
|
||||
},
|
||||
z
|
||||
.object({
|
||||
type: ZRecipientActionAuthTypesSchema,
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED),
|
||||
data: ZGenericFromToSchema,
|
||||
@ -493,6 +572,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
|
||||
ZDocumentAuditLogEventDocumentVisibilitySchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
|
||||
|
||||
@ -314,6 +314,10 @@ export const formatDocumentAuditLogAction = (
|
||||
anonymous: msg`Field unsigned`,
|
||||
identified: msg`${prefix} unsigned a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
|
||||
anonymous: msg`Field prefilled by assistant`,
|
||||
identified: msg`${prefix} prefilled a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
|
||||
anonymous: msg`Document visibility updated`,
|
||||
identified: msg`${prefix} updated the document visibility`,
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';
|
||||
@ -417,6 +417,7 @@ enum RecipientRole {
|
||||
SIGNER
|
||||
VIEWER
|
||||
APPROVER
|
||||
ASSISTANT
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
|
||||
5
packages/prisma/types/recipient-with-fields.ts
Normal file
5
packages/prisma/types/recipient-with-fields.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
|
||||
export type RecipientWithFields = Recipient & {
|
||||
fields: Field[];
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import type { SelectProps } from '@radix-ui/react-select';
|
||||
@ -11,12 +11,15 @@ import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type RecipientRoleSelectProps = SelectProps & {
|
||||
hideCCRecipients?: boolean;
|
||||
isAssistantEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
|
||||
({ hideCCRecipients, ...props }, ref) => (
|
||||
({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
|
||||
<Select {...props}>
|
||||
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||
@ -110,6 +113,42 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
|
||||
<SelectItem
|
||||
value={RecipientRole.ASSISTANT}
|
||||
disabled={!isAssistantEnabled}
|
||||
className={cn(
|
||||
!isAssistantEnabled &&
|
||||
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex w-[150px] items-center">
|
||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
|
||||
<Trans>Can prepare</Trans>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<p>
|
||||
{isAssistantEnabled ? (
|
||||
<Trans>
|
||||
The recipient can prepare the document for later signers by pre-filling
|
||||
suggest values.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Assistant role is only available when the document is in sequential signing
|
||||
mode.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
|
||||
@ -508,7 +508,15 @@ export const AddFieldsFormPartial = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
|
||||
const recipientsByRoleToDisplay = recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
);
|
||||
|
||||
setSelectedSigner(
|
||||
recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
|
||||
recipientsByRoleToDisplay[0],
|
||||
);
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRole = useMemo(() => {
|
||||
@ -517,6 +525,7 @@ export const AddFieldsFormPartial = ({
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
ASSISTANT: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
@ -529,7 +538,12 @@ 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)
|
||||
.filter(
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
role !== RecipientRole.ASSISTANT,
|
||||
)
|
||||
.map(
|
||||
([role, roleRecipients]) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
@ -544,12 +558,6 @@ export const AddFieldsFormPartial = ({
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
const isTypedSignatureEnabled = form.watch('typedSignatureEnabled');
|
||||
|
||||
const handleTypedSignatureChange = (value: boolean) => {
|
||||
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const handleAdvancedSettings = () => {
|
||||
setShowAdvancedSettings((prev) => !prev);
|
||||
};
|
||||
@ -687,9 +695,7 @@ export const AddFieldsFormPartial = ({
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="gradie flex-1 truncate text-left">
|
||||
{selectedSigner?.email}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import { ShowFieldItem } from './show-field-item';
|
||||
import { SigningOrderConfirmation } from './signing-order-confirmation';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
@ -123,6 +124,7 @@ export const AddSignersFormPartial = ({
|
||||
}, [recipients, form]);
|
||||
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||
|
||||
const {
|
||||
setValue,
|
||||
@ -134,6 +136,10 @@ export const AddSignersFormPartial = ({
|
||||
const watchedSigners = watch('signers');
|
||||
const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
|
||||
|
||||
const hasAssistantRole = useMemo(() => {
|
||||
return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT);
|
||||
}, [watchedSigners]);
|
||||
|
||||
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
|
||||
return signers
|
||||
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
|
||||
@ -233,6 +239,7 @@ export const AddSignersFormPartial = ({
|
||||
const items = Array.from(watchedSigners);
|
||||
const [reorderedSigner] = items.splice(result.source.index, 1);
|
||||
|
||||
// Find next valid position
|
||||
let insertIndex = result.destination.index;
|
||||
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) {
|
||||
insertIndex++;
|
||||
@ -240,126 +247,116 @@ export const AddSignersFormPartial = ({
|
||||
|
||||
items.splice(insertIndex, 0, reorderedSigner);
|
||||
|
||||
const updatedSigners = items.map((item, index) => ({
|
||||
...item,
|
||||
signingOrder: !canRecipientBeModified(item.nativeId) ? item.signingOrder : index + 1,
|
||||
const updatedSigners = items.map((signer, index) => ({
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.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]);
|
||||
});
|
||||
});
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
const currentLength = form.getValues('signers').length;
|
||||
if (currentLength > updatedSigners.length) {
|
||||
for (let i = updatedSigners.length; i < currentLength; i++) {
|
||||
form.unregister(`signers.${i}`);
|
||||
}
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await form.trigger('signers');
|
||||
},
|
||||
[form, canRecipientBeModified, watchedSigners],
|
||||
[form, canRecipientBeModified, watchedSigners, toast],
|
||||
);
|
||||
|
||||
const triggerDragAndDrop = useCallback(
|
||||
(fromIndex: number, toIndex: number) => {
|
||||
if (!$sensorApi.current) {
|
||||
const handleRoleChange = useCallback(
|
||||
(index: number, role: RecipientRole) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signingOrder = form.getValues('signingOrder');
|
||||
|
||||
// Handle parallel to sequential conversion for assistants
|
||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
||||
toast({
|
||||
title: _(msg`Signing order is enabled.`),
|
||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draggableId = signers[fromIndex].id;
|
||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
const preDrag = $sensorApi.current.tryGetLock(draggableId);
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
if (!preDrag) {
|
||||
return;
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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: !canRecipientBeModified(signer.nativeId)
|
||||
? signer.signingOrder
|
||||
: (signer.signingOrder ?? index + 1) + 1,
|
||||
};
|
||||
} else if (index <= newIndex && index > oldIndex) {
|
||||
return {
|
||||
...signer,
|
||||
signingOrder: !canRecipientBeModified(signer.nativeId)
|
||||
? signer.signingOrder
|
||||
: Math.max(1, (signer.signingOrder ?? index + 1) - 1),
|
||||
};
|
||||
}
|
||||
return signer;
|
||||
});
|
||||
|
||||
updatedSigners.forEach((signer, index) => {
|
||||
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
|
||||
});
|
||||
},
|
||||
[form, canRecipientBeModified],
|
||||
[form, toast, canRecipientBeModified],
|
||||
);
|
||||
|
||||
const handleSigningOrderChange = useCallback(
|
||||
(index: number, newOrderString: string) => {
|
||||
const newOrder = parseInt(newOrderString, 10);
|
||||
|
||||
if (!newOrderString.trim()) {
|
||||
const trimmedOrderString = newOrderString.trim();
|
||||
if (!trimmedOrderString) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(newOrder)) {
|
||||
form.setValue(`signers.${index}.signingOrder`, index + 1);
|
||||
const newOrder = Number(trimmedOrderString);
|
||||
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = newOrder - 1;
|
||||
if (index !== newIndex) {
|
||||
updateSigningOrders(newIndex, index);
|
||||
triggerDragAndDrop(index, newIndex);
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
|
||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
||||
...s,
|
||||
signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, triggerDragAndDrop, updateSigningOrders],
|
||||
[form, canRecipientBeModified, toast],
|
||||
);
|
||||
|
||||
const handleSigningOrderDisable = useCallback(() => {
|
||||
setShowSigningOrderConfirmation(false);
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
@ -384,11 +381,16 @@ export const AddSignersFormPartial = ({
|
||||
{...field}
|
||||
id="signingOrder"
|
||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||
onCheckedChange={(checked) =>
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked && hasAssistantRole) {
|
||||
setShowSigningOrderConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(
|
||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting || hasDocumentBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -613,7 +615,11 @@ export const AddSignersFormPartial = ({
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole)
|
||||
}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
@ -710,6 +716,12 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
</Form>
|
||||
</AnimateGenericFadeInOut>
|
||||
|
||||
<SigningOrderConfirmation
|
||||
open={showSigningOrderConfirmation}
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@documenso/ui/primitives/alert-dialog';
|
||||
|
||||
export type SigningOrderConfirmationProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function SigningOrderConfirmation({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: SigningOrderConfirmationProps) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Warning</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have an assistant role on the signers list, removing the signing order will change
|
||||
the assistant role to signer.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Proceed</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@ -19,18 +19,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, children: _children, ...props }, ref) => {
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input ring-offset-background focus:ring-ring h-4 w-4 rounded-full border focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border shadow focus:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="fill-primary text-primary h-2.5 w-2.5" />
|
||||
<Circle className="fill-primary h-2.5 w-2.5" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react';
|
||||
import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
|
||||
|
||||
import type { RecipientRole } from '.prisma/client';
|
||||
|
||||
@ -7,4 +7,5 @@ export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||
APPROVER: <BadgeCheck className="h-4 w-4" />,
|
||||
CC: <Copy className="h-4 w-4" />,
|
||||
VIEWER: <Eye className="h-4 w-4" />,
|
||||
ASSISTANT: <User className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
@ -32,7 +32,7 @@ import {
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -438,6 +438,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
ASSISTANT: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
@ -447,10 +448,25 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
return recipientsByRole;
|
||||
}, [recipients]);
|
||||
|
||||
useEffect(() => {
|
||||
const recipientsByRoleToDisplay = recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
);
|
||||
|
||||
setSelectedSigner(
|
||||
recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
|
||||
recipientsByRoleToDisplay[0],
|
||||
);
|
||||
}, [recipients]);
|
||||
|
||||
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,
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
role !== RecipientRole.ASSISTANT,
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { toast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { Checkbox } from '../checkbox';
|
||||
import {
|
||||
@ -39,6 +40,7 @@ import {
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '../document-flow/document-flow-root';
|
||||
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||
import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation';
|
||||
import type { DocumentFlowStep } from '../document-flow/types';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||
import { useStep } from '../stepper';
|
||||
@ -213,41 +215,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
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,
|
||||
const updatedSigners = items.map((signer, index) => ({
|
||||
...signer,
|
||||
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]);
|
||||
});
|
||||
});
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
const currentLength = form.getValues('signers').length;
|
||||
if (currentLength > updatedSigners.length) {
|
||||
for (let i = updatedSigners.length; i < currentLength; i++) {
|
||||
form.unregister(`signers.${i}`);
|
||||
}
|
||||
const lastSigner = updatedSigners[updatedSigners.length - 1];
|
||||
if (lastSigner.role === RecipientRole.ASSISTANT) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
await form.trigger('signers');
|
||||
},
|
||||
[form, watchedSigners],
|
||||
[form, watchedSigners, toast],
|
||||
);
|
||||
|
||||
const triggerDragAndDrop = useCallback(
|
||||
@ -308,26 +299,94 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
const handleSigningOrderChange = useCallback(
|
||||
(index: number, newOrderString: string) => {
|
||||
const newOrder = parseInt(newOrderString, 10);
|
||||
|
||||
if (!newOrderString.trim()) {
|
||||
const trimmedOrderString = newOrderString.trim();
|
||||
if (!trimmedOrderString) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isNaN(newOrder)) {
|
||||
form.setValue(`signers.${index}.signingOrder`, index + 1);
|
||||
const newOrder = Number(trimmedOrderString);
|
||||
if (!Number.isInteger(newOrder) || newOrder < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = newOrder - 1;
|
||||
if (index !== newIndex) {
|
||||
updateSigningOrders(newIndex, index);
|
||||
triggerDragAndDrop(index, newIndex);
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
|
||||
const updatedSigners = remainingSigners.map((s, idx) => ({
|
||||
...s,
|
||||
signingOrder: idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, triggerDragAndDrop, updateSigningOrders],
|
||||
[form, toast],
|
||||
);
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
(index: number, role: RecipientRole) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
const signingOrder = form.getValues('signingOrder');
|
||||
|
||||
// Handle parallel to sequential conversion for assistants
|
||||
if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
|
||||
form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
|
||||
toast({
|
||||
title: _(msg`Signing order is enabled.`),
|
||||
description: _(msg`You cannot add assistants when signing order is disabled.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSigners = currentSigners.map((signer, idx) => ({
|
||||
...signer,
|
||||
role: idx === index ? role : signer.role,
|
||||
signingOrder: idx + 1,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
|
||||
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||
toast({
|
||||
title: _(msg`Warning: Assistant as last signer`),
|
||||
description: _(
|
||||
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, toast],
|
||||
);
|
||||
|
||||
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
|
||||
|
||||
const handleSigningOrderDisable = useCallback(() => {
|
||||
setShowSigningOrderConfirmation(false);
|
||||
|
||||
const currentSigners = form.getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer) => ({
|
||||
...signer,
|
||||
role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
|
||||
}));
|
||||
|
||||
form.setValue('signers', updatedSigners);
|
||||
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
@ -353,11 +412,19 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
{...field}
|
||||
id="signingOrder"
|
||||
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
|
||||
onCheckedChange={(checked) =>
|
||||
onCheckedChange={(checked) => {
|
||||
if (
|
||||
!checked &&
|
||||
watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT)
|
||||
) {
|
||||
setShowSigningOrderConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
field.onChange(
|
||||
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -556,7 +623,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
hideCCRecipients={isSignerDirectRecipient(signer)}
|
||||
/>
|
||||
@ -677,6 +747,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
<SigningOrderConfirmation
|
||||
open={showSigningOrderConfirmation}
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user