mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 07:43:16 +10:00
Add ability to enable or disable allowed signature types: - Drawn - Typed - Uploaded **Tabbed style signature dialog**  **Document settings**  **Team preferences**  - Add multiselect to select allowed signatures in document and templates settings tab - Add multiselect to select allowed signatures in teams preferences - Removed "Enable typed signatures" from document/template edit page - Refactored signature pad to use tabs instead of an all in one signature pad Added E2E tests to check settings are applied correctly for documents and templates
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { Trans } from '@lingui/react/macro';
|
|
import type { Field, Recipient, Signature } from '@prisma/client';
|
|
import { FieldType } from '@prisma/client';
|
|
import { DateTime } from 'luxon';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
import {
|
|
ZCheckboxFieldMeta,
|
|
ZDropdownFieldMeta,
|
|
ZNumberFieldMeta,
|
|
ZRadioFieldMeta,
|
|
ZTextFieldMeta,
|
|
} from '@documenso/lib/types/field-meta';
|
|
import type { TTemplate } from '@documenso/lib/types/template';
|
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
import type {
|
|
TRemovedSignedFieldWithTokenMutationSchema,
|
|
TSignFieldWithTokenMutationSchema,
|
|
} from '@documenso/trpc/server/field-router/schema';
|
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import {
|
|
DocumentFlowFormContainerContent,
|
|
DocumentFlowFormContainerFooter,
|
|
DocumentFlowFormContainerHeader,
|
|
DocumentFlowFormContainerStep,
|
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
import { Input } from '@documenso/ui/primitives/input';
|
|
import { Label } from '@documenso/ui/primitives/label';
|
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
import { useStep } from '@documenso/ui/primitives/stepper';
|
|
|
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
|
import { DocumentSigningCompleteDialog } from '~/components/general/document-signing/document-signing-complete-dialog';
|
|
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
|
import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
|
|
import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
|
|
import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
|
|
import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
|
|
import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
|
|
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
|
|
import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
|
|
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
|
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
|
|
|
import { DocumentSigningRecipientProvider } from '../document-signing/document-signing-recipient-provider';
|
|
|
|
export type DirectTemplateSigningFormProps = {
|
|
flowStep: DocumentFlowStep;
|
|
directRecipient: Recipient;
|
|
directRecipientFields: Field[];
|
|
template: Omit<TTemplate, 'user'>;
|
|
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
|
};
|
|
|
|
export type DirectTemplateLocalField = Field & {
|
|
signedValue?: TSignFieldWithTokenMutationSchema;
|
|
signature?: Signature;
|
|
};
|
|
|
|
export const DirectTemplateSigningForm = ({
|
|
flowStep,
|
|
directRecipient,
|
|
directRecipientFields,
|
|
template,
|
|
onSubmit,
|
|
}: DirectTemplateSigningFormProps) => {
|
|
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
|
|
|
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const { currentStep, totalSteps, previousStep } = useStep();
|
|
|
|
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
|
|
setLocalFields(
|
|
localFields.map((field) => {
|
|
if (field.id !== value.fieldId) {
|
|
return field;
|
|
}
|
|
|
|
const tempField: DirectTemplateLocalField = {
|
|
...field,
|
|
customText: value.value ?? '',
|
|
inserted: true,
|
|
signedValue: value,
|
|
};
|
|
|
|
if (field.type === FieldType.SIGNATURE) {
|
|
tempField.signature = {
|
|
id: 1,
|
|
created: new Date(),
|
|
recipientId: 1,
|
|
fieldId: 1,
|
|
signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null,
|
|
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
|
|
} satisfies Signature;
|
|
}
|
|
|
|
if (field.type === FieldType.DATE) {
|
|
tempField.customText = DateTime.now()
|
|
.setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
|
.toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
|
}
|
|
return tempField;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => {
|
|
setLocalFields(
|
|
localFields.map((field) => {
|
|
if (field.id !== value.fieldId) {
|
|
return field;
|
|
}
|
|
|
|
return {
|
|
...field,
|
|
customText: '',
|
|
inserted: false,
|
|
signedValue: undefined,
|
|
signature: undefined,
|
|
};
|
|
}),
|
|
);
|
|
};
|
|
|
|
const uninsertedFields = useMemo(() => {
|
|
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
|
}, [localFields]);
|
|
|
|
const fieldsValidated = () => {
|
|
setValidateUninsertedFields(true);
|
|
validateFieldsInserted(localFields);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
setValidateUninsertedFields(true);
|
|
|
|
const isFieldsValid = validateFieldsInserted(localFields);
|
|
|
|
if (!isFieldsValid) {
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
await onSubmit(localFields);
|
|
} catch {
|
|
setIsSubmitting(false);
|
|
}
|
|
|
|
// Do not reset to false since we do a redirect.
|
|
};
|
|
|
|
useEffect(() => {
|
|
const updatedFields = [...localFields];
|
|
|
|
localFields.forEach((field) => {
|
|
const index = updatedFields.findIndex((f) => f.id === field.id);
|
|
let value = '';
|
|
|
|
match(field.type)
|
|
.with(FieldType.TEXT, () => {
|
|
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
|
|
|
|
if (meta?.success) {
|
|
value = meta.data.text ?? '';
|
|
}
|
|
})
|
|
.with(FieldType.NUMBER, () => {
|
|
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
|
|
|
|
if (meta?.success) {
|
|
value = meta.data.value ?? '';
|
|
}
|
|
})
|
|
.with(FieldType.DROPDOWN, () => {
|
|
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
|
|
|
|
if (meta?.success) {
|
|
value = meta.data.defaultValue ?? '';
|
|
}
|
|
});
|
|
|
|
if (value) {
|
|
const signedValue = {
|
|
token: directRecipient.token,
|
|
fieldId: field.id,
|
|
value,
|
|
};
|
|
|
|
updatedFields[index] = {
|
|
...field,
|
|
customText: value,
|
|
inserted: true,
|
|
signedValue,
|
|
};
|
|
}
|
|
});
|
|
|
|
setLocalFields(updatedFields);
|
|
}, []);
|
|
|
|
return (
|
|
<DocumentSigningRecipientProvider recipient={directRecipient}>
|
|
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
|
|
|
<DocumentFlowFormContainerContent>
|
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
{validateUninsertedFields && uninsertedFields[0] && (
|
|
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
|
<Trans>Click to insert field</Trans>
|
|
</FieldToolTip>
|
|
)}
|
|
|
|
{localFields.map((field) =>
|
|
match(field.type)
|
|
.with(FieldType.SIGNATURE, () => (
|
|
<DocumentSigningSignatureField
|
|
key={field.id}
|
|
field={field}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
/>
|
|
))
|
|
.with(FieldType.INITIALS, () => (
|
|
<DocumentSigningInitialsField
|
|
key={field.id}
|
|
field={field}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
))
|
|
.with(FieldType.NAME, () => (
|
|
<DocumentSigningNameField
|
|
key={field.id}
|
|
field={field}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
))
|
|
.with(FieldType.DATE, () => (
|
|
<DocumentSigningDateField
|
|
key={field.id}
|
|
field={field}
|
|
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
))
|
|
.with(FieldType.EMAIL, () => (
|
|
<DocumentSigningEmailField
|
|
key={field.id}
|
|
field={field}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
))
|
|
.with(FieldType.TEXT, () => {
|
|
const parsedFieldMeta = field.fieldMeta
|
|
? ZTextFieldMeta.parse(field.fieldMeta)
|
|
: null;
|
|
|
|
return (
|
|
<DocumentSigningTextField
|
|
key={field.id}
|
|
field={{
|
|
...field,
|
|
fieldMeta: parsedFieldMeta,
|
|
}}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
);
|
|
})
|
|
.with(FieldType.NUMBER, () => {
|
|
const parsedFieldMeta = field.fieldMeta
|
|
? ZNumberFieldMeta.parse(field.fieldMeta)
|
|
: null;
|
|
|
|
return (
|
|
<DocumentSigningNumberField
|
|
key={field.id}
|
|
field={{
|
|
...field,
|
|
fieldMeta: parsedFieldMeta,
|
|
}}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
);
|
|
})
|
|
.with(FieldType.DROPDOWN, () => {
|
|
const parsedFieldMeta = field.fieldMeta
|
|
? ZDropdownFieldMeta.parse(field.fieldMeta)
|
|
: null;
|
|
|
|
return (
|
|
<DocumentSigningDropdownField
|
|
key={field.id}
|
|
field={{
|
|
...field,
|
|
fieldMeta: parsedFieldMeta,
|
|
}}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
);
|
|
})
|
|
.with(FieldType.RADIO, () => {
|
|
const parsedFieldMeta = field.fieldMeta
|
|
? ZRadioFieldMeta.parse(field.fieldMeta)
|
|
: null;
|
|
|
|
return (
|
|
<DocumentSigningRadioField
|
|
key={field.id}
|
|
field={{
|
|
...field,
|
|
fieldMeta: parsedFieldMeta,
|
|
}}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
);
|
|
})
|
|
.with(FieldType.CHECKBOX, () => {
|
|
const parsedFieldMeta = field.fieldMeta
|
|
? ZCheckboxFieldMeta.parse(field.fieldMeta)
|
|
: null;
|
|
|
|
return (
|
|
<DocumentSigningCheckboxField
|
|
key={field.id}
|
|
field={{
|
|
...field,
|
|
fieldMeta: parsedFieldMeta,
|
|
}}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
);
|
|
})
|
|
.otherwise(() => null),
|
|
)}
|
|
</ElementVisible>
|
|
|
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
|
<div className="flex flex-1 flex-col gap-y-4">
|
|
<div>
|
|
<Label htmlFor="full-name">
|
|
<Trans>Full Name</Trans>
|
|
</Label>
|
|
|
|
<Input
|
|
id="full-name"
|
|
value={fullName}
|
|
onChange={(e) => setFullName(e.target.value.trimStart())}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="Signature">
|
|
<Trans>Signature</Trans>
|
|
</Label>
|
|
|
|
<SignaturePadDialog
|
|
className="mt-2"
|
|
disabled={isSubmitting}
|
|
value={signature ?? ''}
|
|
onChange={(value) => setSignature(value)}
|
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DocumentFlowFormContainerContent>
|
|
|
|
<DocumentFlowFormContainerFooter>
|
|
<DocumentFlowFormContainerStep step={currentStep} maxStep={totalSteps} />
|
|
|
|
<div className="mt-4 flex gap-x-4">
|
|
<Button
|
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
size="lg"
|
|
variant="secondary"
|
|
disabled={isSubmitting}
|
|
onClick={previousStep}
|
|
>
|
|
<Trans>Back</Trans>
|
|
</Button>
|
|
|
|
<DocumentSigningCompleteDialog
|
|
isSubmitting={isSubmitting}
|
|
onSignatureComplete={handleSubmit}
|
|
documentTitle={template.title}
|
|
fields={localFields}
|
|
fieldsValidated={fieldsValidated}
|
|
role={directRecipient.role}
|
|
/>
|
|
</div>
|
|
</DocumentFlowFormContainerFooter>
|
|
</DocumentSigningRecipientProvider>
|
|
);
|
|
};
|