mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 20:42:34 +10:00
Compare commits
10 Commits
v1.6.0-rc.
...
v1.6.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 414b1b7287 | |||
| 7ac4cd25e7 | |||
| 40428f40e1 | |||
| 3a57f97218 | |||
| 6208a9754f | |||
| 7b5c57e8af | |||
| a3ee732a9b | |||
| c3035dbd15 | |||
| 7f5b27372f | |||
| b0c081683f |
@ -78,6 +78,8 @@ NEXT_PRIVATE_SMTP_APIKEY_USER=
|
||||
NEXT_PRIVATE_SMTP_APIKEY=
|
||||
# OPTIONAL: Defines whether to force the use of TLS.
|
||||
NEXT_PRIVATE_SMTP_SECURE=
|
||||
# OPTIONAL: if this is true and NEXT_PRIVATE_SMTP_SECURE is false then TLS is not used even if the server supports STARTTLS extension
|
||||
NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=
|
||||
# REQUIRED: Defines the sender name to use for the from address.
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||
# REQUIRED: Defines the email address to use as the from address.
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
---
|
||||
title: 21 CFR Part 11 is here, and so is Vial.com
|
||||
description: We launched Vial.com with a 21 CFR Part 11 compliant setup. Reach out if 21 CFR Part 11 compliance is among your needs.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-07-18
|
||||
tags:
|
||||
- Vial.com
|
||||
- 21 CFR Part 11
|
||||
- Compliance
|
||||
---
|
||||
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="/blog/vial.webm"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
></video>
|
||||
<figcaption className="text-center">
|
||||
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
|
||||
</figcaption>
|
||||
|
||||
> TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business.
|
||||
|
||||
# What is 21 CFR
|
||||
You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures.
|
||||
|
||||
Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities.
|
||||
|
||||
# Vial.com
|
||||
Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us).
|
||||
|
||||
[Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vial’s mission by fulfilling our own: bringing open signing and all its innovation to where it's needed.
|
||||
|
||||
# 21 CFR Part 11 on Documenso Highlights
|
||||
21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights.
|
||||
|
||||
## The Full Experience
|
||||
We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance.
|
||||
|
||||
## Action Reauth Using Passkeys
|
||||
<video
|
||||
id="vid"
|
||||
width="100%"
|
||||
src="/blog/vial2.webm"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
<figcaption className="text-center">
|
||||
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
|
||||
</figcaption>
|
||||
|
||||
|
||||
One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider.
|
||||
|
||||
## Direct Links
|
||||
We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements.
|
||||
|
||||
# Documenso Enterprise Plan
|
||||
With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid.
|
||||
|
||||
If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage.
|
||||
|
||||
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
BIN
apps/marketing/public/blog/vial.webm
Normal file
BIN
apps/marketing/public/blog/vial.webm
Normal file
Binary file not shown.
BIN
apps/marketing/public/blog/vial2.webm
Normal file
BIN
apps/marketing/public/blog/vial2.webm
Normal file
Binary file not shown.
@ -98,6 +98,7 @@ export const SinglePlayerClient = () => {
|
||||
height: new Prisma.Decimal(field.pageHeight),
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta ?? {},
|
||||
})),
|
||||
);
|
||||
|
||||
@ -131,7 +132,9 @@ export const SinglePlayerClient = () => {
|
||||
positionY: field.positionY.toNumber(),
|
||||
width: field.width.toNumber(),
|
||||
height: field.height.toNumber(),
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
fieldMeta: { type: undefined },
|
||||
});
|
||||
|
||||
analytics.capture('Marketing: SPM - Document signed', {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@ -172,6 +172,7 @@ export const EditDocumentForm = ({
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
},
|
||||
@ -231,6 +232,14 @@ export const EditDocumentForm = ({
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('field_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
@ -240,7 +249,7 @@ export const EditDocumentForm = ({
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while adding signers.',
|
||||
description: 'An error occurred while adding the fields.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -350,6 +359,7 @@ export const EditDocumentForm = ({
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
|
||||
<AddSubjectFormPartial
|
||||
|
||||
@ -150,7 +150,7 @@ export const ResendDocumentActionItem = ({
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black"
|
||||
checkClassName="text-white"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
|
||||
@ -12,10 +12,7 @@ import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
DocumentFlowFormContainer,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
@ -132,6 +129,7 @@ export const EditTemplateForm = ({
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
},
|
||||
@ -183,6 +181,14 @@ export const EditTemplateForm = ({
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
// Clear all field data from localStorage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('field_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Template saved',
|
||||
description: 'Your templates has been saved successfully.',
|
||||
@ -231,11 +237,6 @@ export const EditTemplateForm = ({
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={currentDocumentFlow.title}
|
||||
description={currentDocumentFlow.description}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||
@ -268,6 +269,7 @@ export const EditTemplateForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
teamId={team?.id}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
|
||||
@ -6,6 +6,13 @@ 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 { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
@ -30,10 +37,14 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
|
||||
import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field';
|
||||
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
|
||||
import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field';
|
||||
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
|
||||
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
||||
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
|
||||
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
|
||||
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
@ -200,15 +211,96 @@ export const SignDirectTemplateForm = ({
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.TEXT, () => (
|
||||
<TextField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.TEXT, () => {
|
||||
const parsedFieldMeta = field.fieldMeta
|
||||
? ZTextFieldMeta.parse(field.fieldMeta)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
key={field.id}
|
||||
field={{
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const parsedFieldMeta = field.fieldMeta
|
||||
? ZNumberFieldMeta.parse(field.fieldMeta)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<NumberField
|
||||
key={field.id}
|
||||
field={{
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const parsedFieldMeta = field.fieldMeta
|
||||
? ZDropdownFieldMeta.parse(field.fieldMeta)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DropdownField
|
||||
key={field.id}
|
||||
field={{
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const parsedFieldMeta = field.fieldMeta
|
||||
? ZRadioFieldMeta.parse(field.fieldMeta)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<RadioField
|
||||
key={field.id}
|
||||
field={{
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const parsedFieldMeta = field.fieldMeta
|
||||
? ZCheckboxFieldMeta.parse(field.fieldMeta)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<CheckboxField
|
||||
key={field.id}
|
||||
field={{
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
292
apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx
Normal file
292
apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type CheckboxFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const CheckboxField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: CheckboxFieldProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
const [checkedValues, setCheckedValues] = useState(
|
||||
values
|
||||
?.map((item) =>
|
||||
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
|
||||
)
|
||||
.filter(Boolean) || [],
|
||||
);
|
||||
|
||||
const isReadOnly = parsedFieldMeta.readOnly;
|
||||
|
||||
const checkboxValidationRule = parsedFieldMeta.validationRule;
|
||||
const checkboxValidationLength = parsedFieldMeta.validationLength;
|
||||
const validationSign = checkboxValidationSigns.find(
|
||||
(sign) => sign.label === checkboxValidationRule,
|
||||
);
|
||||
|
||||
const isLengthConditionMet = useMemo(() => {
|
||||
if (!validationSign) return true;
|
||||
return (
|
||||
(validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) ||
|
||||
(validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) ||
|
||||
(validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0))
|
||||
);
|
||||
}, [checkedValues, validationSign, checkboxValidationLength]);
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && checkedValues.length > 0 && isLengthConditionMet) ||
|
||||
(!field.inserted && isReadOnly && isLengthConditionMet);
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: checkedValues.join(','),
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
} else {
|
||||
await signFieldWithToken(payload);
|
||||
}
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (fieldType?: string) => {
|
||||
try {
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
} else {
|
||||
await removeSignedFieldWithToken(payload);
|
||||
}
|
||||
|
||||
if (fieldType === 'Checkbox') {
|
||||
setCheckedValues([]);
|
||||
}
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (value: string, itemId: number) => {
|
||||
const updatedValue = value || `empty-value-${itemId}`;
|
||||
const updatedValues = checkedValues.includes(updatedValue)
|
||||
? checkedValues.filter((v) => v !== updatedValue)
|
||||
: [...checkedValues, updatedValue];
|
||||
|
||||
setCheckedValues(updatedValues);
|
||||
};
|
||||
|
||||
const handleCheckboxOptionClick = async (item: {
|
||||
id: number;
|
||||
checked: boolean;
|
||||
value: string;
|
||||
}) => {
|
||||
let updatedValues: string[] = [];
|
||||
|
||||
try {
|
||||
const isChecked = checkedValues.includes(
|
||||
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
);
|
||||
|
||||
if (!isChecked) {
|
||||
updatedValues = [
|
||||
...checkedValues,
|
||||
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
];
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
if (isLengthConditionMet) {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: updatedValues.join(','),
|
||||
isBase64: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updatedValues = checkedValues.filter(
|
||||
(v) => v !== item.value && v !== `empty-value-${item.id}`,
|
||||
);
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while updating the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCheckedValues(updatedValues);
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, [checkedValues, isLengthConditionMet, field.inserted]);
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Checkbox">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<>
|
||||
{!isLengthConditionMet && (
|
||||
<FieldToolTip key={field.id} field={field} color="warning" className="">
|
||||
{validationSign?.label} {checkboxValidationLength}
|
||||
</FieldToolTip>
|
||||
)}
|
||||
<div className="z-50 flex flex-col gap-y-2">
|
||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||
const itemValue = item.value || `empty-value-${item.id}`;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-1.5">
|
||||
<Checkbox
|
||||
className="h-4 w-4"
|
||||
checkClassName="text-white"
|
||||
id={`checkbox-${index}`}
|
||||
checked={checkedValues.includes(itemValue)}
|
||||
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
||||
/>
|
||||
<Label htmlFor={`checkbox-${index}`}>
|
||||
{item.value.includes('empty-value-') ? '' : item.value}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||
const itemValue = item.value || `empty-value-${item.id}`;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-1.5">
|
||||
<Checkbox
|
||||
className="h-4 w-4"
|
||||
checkClassName="text-white"
|
||||
id={`checkbox-${index}`}
|
||||
checked={field.customText
|
||||
.split(',')
|
||||
.some((customValue) => customValue === itemValue)}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
||||
/>
|
||||
<Label htmlFor={`checkbox-${index}`}>
|
||||
{item.value.includes('empty-value-') ? '' : item.value}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
@ -139,11 +139,15 @@ export const DateField = ({
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Date</p>
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
Date
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
|
||||
<p className="text-muted-foreground dark:text-background/80 text-sm duration-200">
|
||||
{localDateString}
|
||||
</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
|
||||
209
apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx
Normal file
209
apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type DropdownFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const DropdownField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: DropdownFieldProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const isReadOnly = parsedFieldMeta?.readOnly;
|
||||
const defaultValue = parsedFieldMeta?.defaultValue;
|
||||
const [localChoice, setLocalChoice] = useState(parsedFieldMeta.defaultValue ?? '');
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue);
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
if (!localChoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: localChoice,
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
} else {
|
||||
await signFieldWithToken(payload);
|
||||
}
|
||||
|
||||
setLocalChoice('');
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPreSign = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
} else {
|
||||
await removeSignedFieldWithToken(payload);
|
||||
}
|
||||
|
||||
setLocalChoice(parsedFieldMeta.defaultValue ?? '');
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectItem = (val: string) => {
|
||||
setLocalChoice(val);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!field.inserted && localChoice) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, [localChoice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
onPreSign={onPreSign}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Dropdown"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
|
||||
<Select value={parsedFieldMeta.defaultValue} onValueChange={handleSelectItem}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'text-muted-foreground z-10 h-full w-full border-none ring-0 focus:ring-0',
|
||||
{
|
||||
'hover:text-red-300': parsedFieldMeta.required,
|
||||
'hover:text-yellow-300': !parsedFieldMeta.required,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={'-- Select --'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-full ring-0 focus:ring-0" position="popper">
|
||||
{parsedFieldMeta?.values?.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value} className="ring-0 focus:ring-0">
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -119,10 +119,16 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Email</p>
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
Email
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -163,10 +163,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Name</p>
|
||||
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
|
||||
Name
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 truncate duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||
<DialogContent>
|
||||
|
||||
337
apps/web/src/app/(signing)/sign/[token]/number-field.tsx
Normal file
337
apps/web/src/app/(signing)/sign/[token]/number-field.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Hash, Loader } from 'lucide-react';
|
||||
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type ValidationErrors = {
|
||||
isNumber: string[];
|
||||
required: string[];
|
||||
minValue: string[];
|
||||
maxValue: string[];
|
||||
numberFormat: string[];
|
||||
};
|
||||
|
||||
export type NumberFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [showRadioModal, setShowRadioModal] = useState(false);
|
||||
|
||||
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
|
||||
const isReadOnly = parsedFieldMeta?.readOnly;
|
||||
const defaultValue = parsedFieldMeta?.value;
|
||||
const [localNumber, setLocalNumber] = useState(
|
||||
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
|
||||
);
|
||||
|
||||
const initialErrors: ValidationErrors = {
|
||||
isNumber: [],
|
||||
required: [],
|
||||
minValue: [],
|
||||
maxValue: [],
|
||||
numberFormat: [],
|
||||
};
|
||||
|
||||
const [errors, setErrors] = useState(initialErrors);
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
setLocalNumber(text);
|
||||
|
||||
if (parsedFieldMeta) {
|
||||
const validationErrors = validateNumberField(text, parsedFieldMeta, true);
|
||||
setErrors({
|
||||
isNumber: validationErrors.filter((error) => error.includes('valid number')),
|
||||
required: validationErrors.filter((error) => error.includes('required')),
|
||||
minValue: validationErrors.filter((error) => error.includes('minimum value')),
|
||||
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
|
||||
numberFormat: validationErrors.filter((error) => error.includes('number format')),
|
||||
});
|
||||
} else {
|
||||
const validationErrors = validateNumberField(text);
|
||||
setErrors((prevErrors) => ({
|
||||
...prevErrors,
|
||||
isNumber: validationErrors.filter((error) => error.includes('valid number')),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogSignClick = () => {
|
||||
setShowRadioModal(false);
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
if (!localNumber || Object.values(errors).some((error) => error.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: localNumber,
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await signFieldWithToken(payload);
|
||||
|
||||
setLocalNumber('');
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPreSign = () => {
|
||||
setShowRadioModal(true);
|
||||
|
||||
if (localNumber && parsedFieldMeta) {
|
||||
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
|
||||
setErrors({
|
||||
isNumber: validationErrors.filter((error) => error.includes('valid number')),
|
||||
required: validationErrors.filter((error) => error.includes('required')),
|
||||
minValue: validationErrors.filter((error) => error.includes('minimum value')),
|
||||
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
|
||||
numberFormat: validationErrors.filter((error) => error.includes('number format')),
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : '');
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRadioModal) {
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showRadioModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(!field.inserted && defaultValue && localNumber) ||
|
||||
(!field.inserted && isReadOnly && defaultValue)
|
||||
) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
let fieldDisplayName = 'Number';
|
||||
|
||||
if (parsedFieldMeta?.label) {
|
||||
fieldDisplayName = parsedFieldMeta.label.length > 10 ? parsedFieldMeta.label.substring(0, 10) + '...' : parsedFieldMeta.label;
|
||||
}
|
||||
|
||||
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
|
||||
|
||||
return (
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
onPreSign={onPreSign}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Signature"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p
|
||||
className={cn(
|
||||
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
|
||||
{
|
||||
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
|
||||
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-x-1 text-sm">
|
||||
<Hash className='h-4 w-4' /> {fieldDisplayName}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add number'}
|
||||
</DialogTitle>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={parsedFieldMeta?.placeholder ?? ''}
|
||||
className={cn('mt-2 w-full rounded-md', {
|
||||
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||
userInputHasErrors,
|
||||
})}
|
||||
value={localNumber}
|
||||
onChange={handleNumberChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{userInputHasErrors && (
|
||||
<div>
|
||||
{errors.isNumber?.map((error, index) => (
|
||||
<p key={index} className="mt-2 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{errors.required?.map((error, index) => (
|
||||
<p key={index} className="mt-2 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{errors.minValue?.map((error, index) => (
|
||||
<p key={index} className="mt-2 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{errors.maxValue?.map((error, index) => (
|
||||
<p key={index} className="mt-2 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{errors.numberFormat?.map((error, index) => (
|
||||
<p key={index} className="mt-2 text-sm text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowRadioModal(false);
|
||||
setLocalNumber('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!localNumber || userInputHasErrors}
|
||||
onClick={() => onDialogSignClick()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
190
apps/web/src/app/(signing)/sign/[token]/radio-field.tsx
Normal file
190
apps/web/src/app/(signing)/sign/[token]/radio-field.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type RadioFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
}));
|
||||
const checkedItem = values?.find((item) => item.checked);
|
||||
const defaultValue = !field.inserted && !!checkedItem ? checkedItem.value : '';
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState(defaultValue);
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && selectedOption) ||
|
||||
(!field.inserted && defaultValue) ||
|
||||
(!field.inserted && parsedFieldMeta.readOnly && defaultValue);
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TSignFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: selectedOption,
|
||||
isBase64: true,
|
||||
authOptions,
|
||||
};
|
||||
|
||||
if (onSignField) {
|
||||
await onSignField(payload);
|
||||
} else {
|
||||
await signFieldWithToken(payload);
|
||||
}
|
||||
|
||||
setSelectedOption('');
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while signing the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
try {
|
||||
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
};
|
||||
|
||||
if (onUnsignField) {
|
||||
await onUnsignField(payload);
|
||||
} else {
|
||||
await removeSignedFieldWithToken(payload);
|
||||
}
|
||||
|
||||
setSelectedOption('');
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while removing the signature.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectItem = (selectedOption: string) => {
|
||||
setSelectedOption(selectedOption);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, [selectedOption, field]);
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Radio">
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 z-20 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<RadioGroup onValueChange={(value) => handleSelectItem(value)} className="z-10">
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-x-1.5">
|
||||
<RadioGroupItem
|
||||
className="h-4 w-4 shrink-0"
|
||||
value={item.value}
|
||||
id={`option-${index}`}
|
||||
checked={item.checked}
|
||||
/>
|
||||
|
||||
<Label htmlFor={`option-${index}`}>
|
||||
{item.value.includes('empty-value-') ? '' : item.value}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<RadioGroup>
|
||||
{values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-x-1.5">
|
||||
<RadioGroupItem
|
||||
className=""
|
||||
value={item.value}
|
||||
id={`option-${index}`}
|
||||
checked={item.value === field.customText}
|
||||
/>
|
||||
<Label htmlFor={`option-${index}`}>
|
||||
{item.value.includes('empty-value-') ? '' : item.value}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
};
|
||||
@ -190,7 +190,7 @@ export const SignatureField = ({
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground duration-200 group-hover:text-yellow-300 text-xl">
|
||||
Signature
|
||||
</p>
|
||||
)}
|
||||
@ -199,12 +199,12 @@ export const SignatureField = ({
|
||||
<img
|
||||
src={signature.signatureImageAsBase64}
|
||||
alt={`Signature for ${recipient.name}`}
|
||||
className="h-full w-full object-contain dark:invert"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'signed-text' && (
|
||||
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||
{signature?.typedSignature}
|
||||
</p>
|
||||
|
||||
@ -2,10 +2,14 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
@ -34,8 +38,8 @@ export type SignatureFieldProps = {
|
||||
* The auth values will be passed in if available.
|
||||
*/
|
||||
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||
onRemove?: () => Promise<void> | void;
|
||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||
onRemove?: (fieldType?: string) => Promise<void> | void;
|
||||
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
|
||||
tooltipText?: string | null;
|
||||
};
|
||||
|
||||
@ -51,6 +55,9 @@ export const SigningFieldContainer = ({
|
||||
}: SignatureFieldProps) => {
|
||||
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
|
||||
|
||||
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
|
||||
const readOnlyField = parsedFieldMeta?.readOnly || false;
|
||||
|
||||
const handleInsertField = async () => {
|
||||
if (field.inserted || !onSign) {
|
||||
return;
|
||||
@ -102,41 +109,70 @@ export const SigningFieldContainer = ({
|
||||
await onRemove?.();
|
||||
};
|
||||
|
||||
const onClearCheckBoxValues = async (fieldType?: string) => {
|
||||
if (!field.inserted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onRemove?.(fieldType);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldRootContainer field={field}>
|
||||
{!field.inserted && !loading && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(type === 'Checkbox' ? 'group' : '')}>
|
||||
<FieldRootContainer field={field}>
|
||||
{!field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-md border"
|
||||
onClick={async () => handleInsertField()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === 'Date' && field.inserted && !loading && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{readOnlyField && (
|
||||
<button className="bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100">
|
||||
<span className="bg-foreground/50 dark:bg-background/50 text-background dark:text-foreground rounded-xl p-2">
|
||||
Read only field
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
)}
|
||||
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
{type !== 'Date' && field.inserted && !loading && (
|
||||
<button
|
||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="dark:bg-background absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
|
||||
onClick={() => void onClearCheckBoxValues(type)}
|
||||
>
|
||||
<span className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||
<button
|
||||
className="text-destructive bg-background/50 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 duration-200 group-hover:opacity-100"
|
||||
onClick={onRemoveSignedFieldClick}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,9 +4,17 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
@ -14,10 +22,14 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { CheckboxField } from './checkbox-field';
|
||||
import { DateField } from './date-field';
|
||||
import { DropdownField } from './dropdown-field';
|
||||
import { EmailField } from './email-field';
|
||||
import { SigningForm } from './form';
|
||||
import { NameField } from './name-field';
|
||||
import { NumberField } from './number-field';
|
||||
import { RadioField } from './radio-field';
|
||||
import { SignatureField } from './signature-field';
|
||||
import { TextField } from './text-field';
|
||||
|
||||
@ -101,9 +113,41 @@ export const SigningPageView = ({
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.TEXT, () => (
|
||||
<TextField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.TEXT, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
@ -4,29 +4,31 @@ import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Loader, Type } from 'lucide-react';
|
||||
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type TextFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@ -34,9 +36,16 @@ export type TextFieldProps = {
|
||||
|
||||
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const initialErrors: Record<string, string[]> = {
|
||||
required: [],
|
||||
characterLimit: [],
|
||||
};
|
||||
|
||||
const [errors, setErrors] = useState(initialErrors);
|
||||
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@ -49,21 +58,52 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
const shouldAutoSignField =
|
||||
(!field.inserted && parsedFieldMeta?.text) ||
|
||||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
|
||||
|
||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||
const [localText, setLocalCustomText] = useState('');
|
||||
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCustomTextModal) {
|
||||
setLocalCustomText('');
|
||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showCustomTextModal]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value;
|
||||
setLocalCustomText(text);
|
||||
|
||||
if (parsedFieldMeta) {
|
||||
const validationErrors = validateTextField(text, parsedFieldMeta, true);
|
||||
setErrors({
|
||||
required: validationErrors.filter((error) => error.includes('required')),
|
||||
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When the user clicks the sign button in the dialog where they enter the text field.
|
||||
*/
|
||||
const onDialogSignClick = () => {
|
||||
if (parsedFieldMeta) {
|
||||
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors({
|
||||
required: validationErrors.filter((error) => error.includes('required')),
|
||||
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowCustomTextModal(false);
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
@ -73,17 +113,22 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
};
|
||||
|
||||
const onPreSign = () => {
|
||||
if (!localText) {
|
||||
setShowCustomTextModal(true);
|
||||
return false;
|
||||
setShowCustomTextModal(true);
|
||||
|
||||
if (localText && parsedFieldMeta) {
|
||||
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
|
||||
setErrors({
|
||||
required: validationErrors.filter((error) => error.includes('required')),
|
||||
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
try {
|
||||
if (!localText) {
|
||||
if (!localText || userInputHasErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -136,6 +181,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
|
||||
await removeSignedFieldWithToken(payload);
|
||||
|
||||
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -148,6 +195,34 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoSignField) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||
|
||||
const labelDisplay =
|
||||
parsedField?.label && parsedField.label.length < 20
|
||||
? parsedField.label
|
||||
: parsedField?.label
|
||||
? parsedField?.label.substring(0, 20) + '...'
|
||||
: undefined;
|
||||
|
||||
const textDisplay =
|
||||
parsedField?.text && parsedField.text.length < 20
|
||||
? parsedField.text
|
||||
: parsedField?.text
|
||||
? parsedField?.text.substring(0, 20) + '...'
|
||||
: undefined;
|
||||
|
||||
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay ? textDisplay : 'Add text';
|
||||
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
|
||||
|
||||
return (
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
@ -163,27 +238,69 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
)}
|
||||
|
||||
{!field.inserted && (
|
||||
<p className="group-hover:text-primary text-muted-foreground text-lg duration-200">Text</p>
|
||||
<p
|
||||
className={cn(
|
||||
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
|
||||
{
|
||||
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
|
||||
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-x-1">
|
||||
<Type />
|
||||
{fieldDisplayName}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{field.inserted && <p className="text-muted-foreground duration-200">{field.customText}</p>}
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 15) + '...'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
|
||||
</DialogTitle>
|
||||
<DialogTitle>{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add Text'}</DialogTitle>
|
||||
|
||||
<div className="">
|
||||
<Label htmlFor="custom-text">Custom Text</Label>
|
||||
|
||||
<Input
|
||||
<div>
|
||||
<Textarea
|
||||
id="custom-text"
|
||||
className="border-border mt-2 w-full rounded-md border"
|
||||
onChange={(e) => setLocalCustomText(e.target.value)}
|
||||
placeholder={parsedFieldMeta?.placeholder ?? 'Enter your text here'}
|
||||
className={cn('mt-2 w-full rounded-md', {
|
||||
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||
userInputHasErrors,
|
||||
})}
|
||||
value={localText}
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{parsedFieldMeta?.characterLimit !== undefined && parsedFieldMeta?.characterLimit > 0 && !userInputHasErrors && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{charactersRemaining} characters remaining
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userInputHasErrors && (
|
||||
<div className="text-sm">
|
||||
{errors.required.map((error, index) => (
|
||||
<p key={index} className="text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
{errors.characterLimit.map((error, index) => (
|
||||
<p key={index} className="text-red-500">
|
||||
{error}{' '}
|
||||
{charactersRemaining < 0 && `(${Math.abs(charactersRemaining)} characters over)`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
||||
<Button
|
||||
@ -201,10 +318,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!localText}
|
||||
disabled={!localText || userInputHasErrors}
|
||||
onClick={() => onDialogSignClick()}
|
||||
>
|
||||
Save Text
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@ -271,6 +271,23 @@ export const DocumentHistorySheet = ({
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: data.from,
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: data.to,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
|
||||
@ -42,12 +42,12 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
||||
<FieldRootContainer
|
||||
field={field}
|
||||
key={field.id}
|
||||
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
|
||||
cardClassName="border-gray-300/50 !shadow-none backdrop-blur-[1px] bg-gray-50 ring-0 ring-offset-0"
|
||||
>
|
||||
<div className="absolute -right-3 -top-3">
|
||||
<PopoverHover
|
||||
trigger={
|
||||
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
||||
<Avatar className="dark:border-foreground h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
||||
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
||||
{extractInitials(field.Recipient.name || field.Recipient.email)}
|
||||
</AvatarFallback>
|
||||
@ -78,7 +78,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
||||
</PopoverHover>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground break-all text-sm">
|
||||
<div className="text-muted-foreground dark:text-background/70 break-all text-sm">
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED &&
|
||||
match(field)
|
||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||
@ -95,9 +95,19 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
|
||||
{
|
||||
type: P.union(
|
||||
FieldType.NAME,
|
||||
FieldType.EMAIL,
|
||||
FieldType.NUMBER,
|
||||
FieldType.RADIO,
|
||||
FieldType.CHECKBOX,
|
||||
FieldType.DROPDOWN,
|
||||
),
|
||||
},
|
||||
() => field.customText,
|
||||
)
|
||||
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
|
||||
.with({ type: FieldType.DATE }, () =>
|
||||
convertToLocalSystemFormat(
|
||||
field.customText,
|
||||
|
||||
@ -128,6 +128,7 @@ Here's a markdown table documenting all the provided environment variables:
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -39,7 +39,7 @@
|
||||
},
|
||||
"apps/marketing": {
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
@ -100,7 +100,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@documenso/web",
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.6.0-rc.1",
|
||||
"version": "1.6.0-rc.3",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
@ -27,7 +27,8 @@
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||
"precommit": "npm install && git add package.json package-lock.json",
|
||||
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
|
||||
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs"
|
||||
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
|
||||
"make:version": " npm version --workspace @documenso/web --workspace @documenso/marketing --include-workspace-root --no-git-tag-version -m \"v%s\""
|
||||
},
|
||||
"packageManager": "npm@10.7.0",
|
||||
"engines": {
|
||||
@ -72,4 +73,4 @@
|
||||
"trigger.dev": {
|
||||
"endpointId": "documenso-app"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
ZGetDocumentsQuerySchema,
|
||||
ZGetTemplatesQuerySchema,
|
||||
ZNoBodyMutationSchema,
|
||||
ZResendDocumentForSigningMutationSchema,
|
||||
ZSendDocumentForSigningMutationSchema,
|
||||
ZSuccessfulDeleteTemplateResponseSchema,
|
||||
ZSuccessfulDocumentResponseSchema,
|
||||
@ -25,6 +26,7 @@ import {
|
||||
ZSuccessfulGetTemplateResponseSchema,
|
||||
ZSuccessfulGetTemplatesResponseSchema,
|
||||
ZSuccessfulRecipientResponseSchema,
|
||||
ZSuccessfulResendDocumentResponseSchema,
|
||||
ZSuccessfulResponseSchema,
|
||||
ZSuccessfulSigningResponseSchema,
|
||||
ZUnsuccessfulResponseSchema,
|
||||
@ -161,6 +163,20 @@ export const ApiContractV1 = c.router(
|
||||
summary: 'Send a document for signing',
|
||||
},
|
||||
|
||||
resendDocument: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents/:id/resend',
|
||||
body: ZResendDocumentForSigningMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulResendDocumentResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Re-send a document for signing',
|
||||
},
|
||||
|
||||
deleteDocument: {
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/documents/:id',
|
||||
|
||||
@ -9,6 +9,7 @@ import { createDocument } from '@documenso/lib/server-only/document/create-docum
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { createField } from '@documenso/lib/server-only/field/create-field';
|
||||
@ -232,6 +233,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
|
||||
const document = await createDocument({
|
||||
title: body.title,
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
formValues: body.formValues,
|
||||
@ -397,6 +399,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: fileName,
|
||||
externalId: body.externalId || null,
|
||||
formValues: body.formValues,
|
||||
documentData: {
|
||||
connect: {
|
||||
@ -453,6 +456,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
try {
|
||||
document = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: body.recipients,
|
||||
@ -600,6 +604,35 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
}
|
||||
}),
|
||||
|
||||
resendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { recipients } = args.body;
|
||||
|
||||
try {
|
||||
await resendDocument({
|
||||
userId: user.id,
|
||||
documentId: Number(documentId),
|
||||
recipients,
|
||||
teamId: team?.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
message: 'Document resend successfully initiated',
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'An error has occured while resending the document',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
createRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { name, email, role } = args.body;
|
||||
@ -1001,6 +1034,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
}
|
||||
|
||||
const field = await getFieldById({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
fieldId: Number(fieldId),
|
||||
documentId: Number(documentId),
|
||||
}).catch(() => null);
|
||||
|
||||
@ -29,6 +29,7 @@ export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema
|
||||
|
||||
export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullish(),
|
||||
title: z.string(),
|
||||
@ -57,6 +58,20 @@ export const ZSendDocumentForSigningMutationSchema = z
|
||||
|
||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||
|
||||
export const ZResendDocumentForSigningMutationSchema = z.object({
|
||||
recipients: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type TResendDocumentForSigningMutationSchema = z.infer<
|
||||
typeof ZResendDocumentForSigningMutationSchema
|
||||
>;
|
||||
|
||||
export const ZSuccessfulResendDocumentResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type TResendDocumentResponseSchema = z.infer<typeof ZSuccessfulResendDocumentResponseSchema>;
|
||||
|
||||
export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
url: z.string(),
|
||||
key: z.string(),
|
||||
@ -70,6 +85,7 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
|
||||
|
||||
export const ZCreateDocumentMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
@ -94,6 +110,7 @@ export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutati
|
||||
export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
uploadUrl: z.string().min(1),
|
||||
documentId: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
@ -113,6 +130,7 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
@ -139,6 +157,7 @@ export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
@ -158,6 +177,7 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
@ -194,6 +214,7 @@ export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
@ -332,6 +353,7 @@ export const ZTemplateMetaSchema = z.object({
|
||||
|
||||
export const ZTemplateSchema = z.object({
|
||||
id: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
type: z.nativeEnum(TemplateType),
|
||||
title: z.string(),
|
||||
userId: z.number(),
|
||||
|
||||
@ -49,8 +49,8 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.getByLabel('Custom Text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
@ -109,8 +109,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.getByLabel('Custom Text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
@ -287,8 +287,8 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.getByLabel('Custom Text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
|
||||
@ -398,8 +398,8 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
if (field.type === FieldType.TEXT) {
|
||||
await page.getByLabel('Custom Text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||
await page.locator('#custom-text').fill('TEXT');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
|
||||
|
||||
@ -81,7 +81,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
@ -89,7 +89,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
@ -147,7 +147,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
@ -155,7 +155,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
@ -166,7 +166,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.getByText('User 1 (user1@example.com)').click();
|
||||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'User 2 Signature' }).click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
@ -174,7 +174,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
@ -243,7 +243,10 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.locator('button[role="combobox"]').nth(0).click();
|
||||
await page.getByTitle('User 1 (user1@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
@ -251,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
@ -259,10 +262,10 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText('User 1 (user1@example.com)').click();
|
||||
await page.getByText('User 3 (user3@example.com)').click();
|
||||
await page.locator('button[role="combobox"]').nth(0).click();
|
||||
await page.getByTitle('User 3 (user3@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'User 3 Signature' }).click();
|
||||
await page.getByRole('button', { name: 'Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
@ -270,7 +273,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.getByRole('button', { name: 'Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
@ -465,7 +468,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a');
|
||||
const customDate = DateTime.utc().toFormat('yyyy-MM-dd hh:mm a');
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
|
||||
@ -46,10 +46,13 @@ const getTransport = () => {
|
||||
host: process.env.NEXT_PRIVATE_SMTP_HOST ?? 'localhost:2500',
|
||||
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||
},
|
||||
ignoreTLS: process.env.NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS === 'true',
|
||||
auth: process.env.NEXT_PRIVATE_SMTP_USERNAME
|
||||
? {
|
||||
user: process.env.NEXT_PRIVATE_SMTP_USERNAME,
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
82
packages/lib/advanced-fields-validation/validate-checkbox.ts
Normal file
82
packages/lib/advanced-fields-validation/validate-checkbox.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
interface CheckboxFieldMeta {
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
validationRule?: string;
|
||||
validationLength?: number;
|
||||
}
|
||||
|
||||
export const validateCheckboxField = (
|
||||
values: string[],
|
||||
fieldMeta: CheckboxFieldMeta,
|
||||
isSigningPage: boolean = false,
|
||||
): string[] => {
|
||||
const errors = [];
|
||||
|
||||
const { readOnly, required, validationRule, validationLength } = fieldMeta;
|
||||
|
||||
if (readOnly && required) {
|
||||
errors.push('A field cannot be both read-only and required');
|
||||
}
|
||||
|
||||
if (values.length === 0) {
|
||||
errors.push('At least one option must be added');
|
||||
}
|
||||
|
||||
if (readOnly && values.length === 0) {
|
||||
errors.push('A read-only field must have at least one value');
|
||||
}
|
||||
|
||||
if (isSigningPage && required && values.length === 0) {
|
||||
errors.push('Selecting an option is required');
|
||||
}
|
||||
|
||||
if (validationRule && !validationLength) {
|
||||
errors.push('You need to specify the number of options for validation');
|
||||
}
|
||||
|
||||
if (validationLength && !validationRule) {
|
||||
errors.push('You need to specify the validation rule');
|
||||
}
|
||||
|
||||
if (validationRule && validationLength) {
|
||||
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
|
||||
|
||||
if (validation) {
|
||||
let lengthCondition = false;
|
||||
|
||||
switch (validation.value) {
|
||||
case '=':
|
||||
lengthCondition = isSigningPage
|
||||
? values.length !== validationLength
|
||||
: values.length < validationLength;
|
||||
break;
|
||||
case '>=':
|
||||
lengthCondition = values.length < validationLength;
|
||||
break;
|
||||
case '<=':
|
||||
lengthCondition = isSigningPage
|
||||
? values.length > validationLength
|
||||
: values.length < validationLength;
|
||||
break;
|
||||
}
|
||||
|
||||
if (lengthCondition) {
|
||||
let errorMessage;
|
||||
if (isSigningPage) {
|
||||
errorMessage = `You need to ${validationRule.toLowerCase()} ${validationLength} options`;
|
||||
} else {
|
||||
errorMessage =
|
||||
validation.value === '<='
|
||||
? `You need to select at least ${validationLength} options`
|
||||
: `You need to add at least ${validationLength} options`;
|
||||
}
|
||||
|
||||
errors.push(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
54
packages/lib/advanced-fields-validation/validate-dropdown.ts
Normal file
54
packages/lib/advanced-fields-validation/validate-dropdown.ts
Normal file
@ -0,0 +1,54 @@
|
||||
interface DropdownFieldMeta {
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
values?: { value: string }[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export const validateDropdownField = (
|
||||
value: string | undefined,
|
||||
fieldMeta: DropdownFieldMeta,
|
||||
isSigningPage: boolean = false,
|
||||
): string[] => {
|
||||
const errors = [];
|
||||
|
||||
const { readOnly, required, values, defaultValue } = fieldMeta;
|
||||
|
||||
if (readOnly && required) {
|
||||
errors.push('A field cannot be both read-only and required');
|
||||
}
|
||||
|
||||
if (readOnly && (!values || values.length === 0)) {
|
||||
errors.push('A read-only field must have at least one value');
|
||||
}
|
||||
|
||||
if (isSigningPage && required && !value) {
|
||||
errors.push('Choosing an option is required');
|
||||
}
|
||||
|
||||
if (values && values.length === 0) {
|
||||
errors.push('Select field must have at least one option');
|
||||
}
|
||||
|
||||
if (values && values.length === 0 && defaultValue) {
|
||||
errors.push('Default value must be one of the available options');
|
||||
}
|
||||
|
||||
if (value && values && !values.find((item) => item.value === value)) {
|
||||
errors.push('Selected value must be one of the available options');
|
||||
}
|
||||
|
||||
if (values && defaultValue && !values.find((item) => item.value === defaultValue)) {
|
||||
errors.push('Default value must be one of the available options');
|
||||
}
|
||||
|
||||
if (values && values.some((item) => item.value.length < 1)) {
|
||||
errors.push('Option value cannot be empty');
|
||||
}
|
||||
|
||||
if (values && new Set(values.map((item) => item.value)).size !== values.length) {
|
||||
errors.push('Duplicate values are not allowed');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
67
packages/lib/advanced-fields-validation/validate-number.ts
Normal file
67
packages/lib/advanced-fields-validation/validate-number.ts
Normal file
@ -0,0 +1,67 @@
|
||||
// import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
interface NumberFieldMeta {
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
numberFormat?: string;
|
||||
}
|
||||
|
||||
export const validateNumberField = (
|
||||
value: string,
|
||||
fieldMeta?: NumberFieldMeta,
|
||||
isSigningPage: boolean = false,
|
||||
): string[] => {
|
||||
const errors = [];
|
||||
|
||||
const { minValue, maxValue, readOnly, required, numberFormat } = fieldMeta || {};
|
||||
|
||||
const formatRegex: { [key: string]: RegExp } = {
|
||||
'123,456,789.00': /^(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d{1,2})?$/,
|
||||
'123.456.789,00': /^(?:\d{1,3}(?:\.\d{3})*|\d+)(?:,\d{1,2})?$/,
|
||||
'123456,789.00': /^(?:\d+)(?:,\d{1,3}(?:\.\d{1,2})?)?$/,
|
||||
};
|
||||
|
||||
const isValidFormat = numberFormat ? formatRegex[numberFormat].test(value) : true;
|
||||
|
||||
if (!isValidFormat) {
|
||||
errors.push(`Value ${value} does not match the number format - ${numberFormat}`);
|
||||
}
|
||||
|
||||
const numberValue = parseFloat(value);
|
||||
|
||||
if (isSigningPage && required && !value) {
|
||||
errors.push('Value is required');
|
||||
}
|
||||
|
||||
if (!/^[0-9,.]+$/.test(value.trim())) {
|
||||
errors.push(`Value is not a valid number`);
|
||||
}
|
||||
|
||||
if (minValue !== undefined && minValue > 0 && numberValue < minValue) {
|
||||
errors.push(`Value ${value} is less than the minimum value of ${minValue}`);
|
||||
}
|
||||
|
||||
if (maxValue !== undefined && maxValue > 0 && numberValue > maxValue) {
|
||||
errors.push(`Value ${value} is greater than the maximum value of ${maxValue}`);
|
||||
}
|
||||
|
||||
if (minValue !== undefined && maxValue !== undefined && minValue > maxValue) {
|
||||
errors.push('Minimum value cannot be greater than maximum value');
|
||||
}
|
||||
|
||||
if (maxValue !== undefined && minValue !== undefined && maxValue < minValue) {
|
||||
errors.push('Maximum value cannot be less than minimum value');
|
||||
}
|
||||
|
||||
if (readOnly && numberValue < 1) {
|
||||
errors.push('A read-only field must have a value greater than 0');
|
||||
}
|
||||
|
||||
if (readOnly && required) {
|
||||
errors.push('A field cannot be both read-only and required');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
41
packages/lib/advanced-fields-validation/validate-radio.ts
Normal file
41
packages/lib/advanced-fields-validation/validate-radio.ts
Normal file
@ -0,0 +1,41 @@
|
||||
interface RadioFieldMeta {
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
values?: { checked: boolean; value: string }[];
|
||||
}
|
||||
|
||||
export const validateRadioField = (
|
||||
value: string | undefined,
|
||||
fieldMeta: RadioFieldMeta,
|
||||
isSigningPage: boolean = false,
|
||||
): string[] => {
|
||||
const errors = [];
|
||||
|
||||
const { readOnly, required, values } = fieldMeta;
|
||||
|
||||
if (readOnly && required) {
|
||||
errors.push('A field cannot be both read-only and required');
|
||||
}
|
||||
|
||||
if (readOnly && (!values || values.length === 0)) {
|
||||
errors.push('A read-only field must have at least one value');
|
||||
}
|
||||
|
||||
if (isSigningPage && required && !value) {
|
||||
errors.push('Choosing an option is required');
|
||||
}
|
||||
|
||||
if (values) {
|
||||
const checkedRadioFieldValues = values.filter((option) => option.checked);
|
||||
|
||||
if (values.length === 0) {
|
||||
errors.push('Radio field must have at least one option');
|
||||
}
|
||||
|
||||
if (checkedRadioFieldValues.length > 1) {
|
||||
errors.push('There cannot be more than one checked option');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
33
packages/lib/advanced-fields-validation/validate-text.ts
Normal file
33
packages/lib/advanced-fields-validation/validate-text.ts
Normal file
@ -0,0 +1,33 @@
|
||||
interface TextFieldMeta {
|
||||
characterLimit?: number;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const validateTextField = (
|
||||
value: string,
|
||||
fieldMeta: TextFieldMeta,
|
||||
isSigningPage: boolean = false,
|
||||
): string[] => {
|
||||
const errors = [];
|
||||
|
||||
const { characterLimit, readOnly, required } = fieldMeta;
|
||||
|
||||
if (required && !value && isSigningPage) {
|
||||
errors.push('Value is required');
|
||||
}
|
||||
|
||||
if (characterLimit !== undefined && characterLimit > 0 && value.length > characterLimit) {
|
||||
errors.push(`Value length (${value.length}) exceeds the character limit (${characterLimit})`);
|
||||
}
|
||||
|
||||
if (readOnly && value.length < 1) {
|
||||
errors.push('A read-only field must have text');
|
||||
}
|
||||
|
||||
if (readOnly && required) {
|
||||
errors.push('A field cannot be both read-only and required');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
25
packages/lib/client-only/hooks/use-field-item-styles.ts
Normal file
25
packages/lib/client-only/hooks/use-field-item-styles.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { CombinedStylesKey } from '../../../ui/primitives/document-flow/add-fields';
|
||||
import { combinedStyles } from '../../../ui/primitives/document-flow/field-item';
|
||||
|
||||
const defaultFieldItemStyles = {
|
||||
borderClass: 'border-field-card-border',
|
||||
activeBorderClass: 'border-field-card-border/80',
|
||||
initialsBGClass: 'text-field-card-foreground/50 bg-slate-900/10',
|
||||
fieldBackground: 'bg-field-card-background',
|
||||
};
|
||||
|
||||
export const useFieldItemStyles = (color: CombinedStylesKey | null) => {
|
||||
return useMemo(() => {
|
||||
if (!color) return defaultFieldItemStyles;
|
||||
|
||||
const selectedColorVariant = combinedStyles[color];
|
||||
return {
|
||||
activeBorderClass: selectedColorVariant?.borderActive,
|
||||
borderClass: selectedColorVariant?.border,
|
||||
initialsBGClass: selectedColorVariant?.initialsBG,
|
||||
fieldBackground: selectedColorVariant?.fieldBackground,
|
||||
};
|
||||
}, [color]);
|
||||
};
|
||||
@ -38,7 +38,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
@ -52,7 +52,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
},
|
||||
from: {
|
||||
name: senderName,
|
||||
address: senderAdress,
|
||||
address: senderAddress,
|
||||
},
|
||||
subject: 'Please confirm your email',
|
||||
html: render(confirmationTemplate),
|
||||
|
||||
@ -11,6 +11,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
@ -21,6 +22,7 @@ export type CreateDocumentOptions = {
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
externalId,
|
||||
documentDataId,
|
||||
teamId,
|
||||
formValues,
|
||||
@ -50,6 +52,7 @@ export const createDocument = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
|
||||
@ -7,7 +7,7 @@ import { render } from '@documenso/email/render';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
@ -178,6 +178,10 @@ const handleDocumentOwnerDelete = async ({
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
|
||||
@ -117,6 +117,9 @@ export const sealDocument = async ({
|
||||
await insertFieldInPDF(doc, field);
|
||||
}
|
||||
|
||||
// Re-flatten post-insertion to handle fields that create arcoFields
|
||||
flattenForm(doc);
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
@ -161,14 +160,7 @@ export const sendDocument = async ({
|
||||
);
|
||||
|
||||
if (allRecipientsHaveNoActionToTake) {
|
||||
const updatedDocument = await updateDocument({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
|
||||
await sealDocument({ documentId, requestMetadata });
|
||||
|
||||
// Keep the return type the same for the `sendDocument` method
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
|
||||
@ -6,7 +6,7 @@ import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
@ -41,6 +41,10 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
|
||||
@ -18,6 +18,7 @@ export type UpdateDocumentSettingsOptions = {
|
||||
documentId: number;
|
||||
data: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
};
|
||||
@ -91,6 +92,7 @@ export const updateDocumentSettings = async ({
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === document.externalId;
|
||||
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
|
||||
|
||||
@ -118,6 +120,21 @@ export const updateDocumentSettings = async ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isExternalIdSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.externalId,
|
||||
to: data.externalId || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalAccessSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
@ -165,6 +182,7 @@ export const updateDocumentSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,15 +1,47 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetFieldByIdOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
documentId?: number;
|
||||
templateId?: number;
|
||||
};
|
||||
|
||||
export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => {
|
||||
export const getFieldById = async ({
|
||||
userId,
|
||||
teamId,
|
||||
fieldId,
|
||||
documentId,
|
||||
templateId,
|
||||
}: GetFieldByIdOptions) => {
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
documentId,
|
||||
templateId,
|
||||
Document: {
|
||||
OR:
|
||||
teamId === undefined
|
||||
? [
|
||||
{
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
teamId,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import {
|
||||
createDocumentAuditLogData,
|
||||
diffFieldChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Field, FieldType } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
userId: number;
|
||||
@ -20,6 +34,7 @@ export interface SetFieldsForDocumentOptions {
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
}[];
|
||||
requestMetadata?: RequestMetadata;
|
||||
}
|
||||
@ -103,6 +118,83 @@ export const setFieldsForDocument = async ({
|
||||
linkedFields.map(async (field) => {
|
||||
const fieldSignerEmail = field.signerEmail.toLowerCase();
|
||||
|
||||
const parsedFieldMeta = field.fieldMeta
|
||||
? ZFieldMetaSchema.parse(field.fieldMeta)
|
||||
: undefined;
|
||||
|
||||
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
||||
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
numberFieldParsedMeta,
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX) {
|
||||
if (field.fieldMeta) {
|
||||
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateCheckboxField(
|
||||
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
|
||||
checkboxFieldParsedMeta,
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'To proceed further, please set at least one value for the Checkbox field',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.RADIO) {
|
||||
if (field.fieldMeta) {
|
||||
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
|
||||
(option) => option.checked,
|
||||
)?.value;
|
||||
|
||||
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('. '));
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'To proceed further, please set at least one value for the Radio field',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN) {
|
||||
if (field.fieldMeta) {
|
||||
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('. '));
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'To proceed further, please set at least one value for the Dropdown field',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const upsertedField = await tx.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
@ -114,6 +206,7 @@ export const setFieldsForDocument = async ({
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
},
|
||||
create: {
|
||||
type: field.type,
|
||||
@ -124,6 +217,7 @@ export const setFieldsForDocument = async ({
|
||||
height: field.pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
Document: {
|
||||
connect: {
|
||||
id: documentId,
|
||||
|
||||
@ -1,5 +1,19 @@
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export type SetFieldsForTemplateOptions = {
|
||||
userId: number;
|
||||
@ -13,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -70,8 +85,60 @@ export const setFieldsForTemplate = async ({
|
||||
const persistedFields = await prisma.$transaction(
|
||||
// Disabling as wrapping promises here causes type issues
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
linkedFields.map((field) =>
|
||||
prisma.field.upsert({
|
||||
linkedFields.map((field) => {
|
||||
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
|
||||
|
||||
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
||||
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateTextField(textFieldParsedMeta.text || '', textFieldParsedMeta);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(
|
||||
String(numberFieldParsedMeta.value),
|
||||
numberFieldParsedMeta,
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
|
||||
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateCheckboxField(
|
||||
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
|
||||
checkboxFieldParsedMeta,
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.RADIO && field.fieldMeta) {
|
||||
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
|
||||
(option) => option.checked,
|
||||
)?.value;
|
||||
const errors = validateRadioField(checkedRadioFieldValue, radioFieldParsedMeta);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('. '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
|
||||
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('. '));
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with upsert operation
|
||||
return prisma.field.upsert({
|
||||
where: {
|
||||
id: field._persisted?.id ?? -1,
|
||||
templateId,
|
||||
@ -82,6 +149,7 @@ export const setFieldsForTemplate = async ({
|
||||
positionY: field.pageY,
|
||||
width: field.pageWidth,
|
||||
height: field.pageHeight,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
},
|
||||
create: {
|
||||
type: field.type,
|
||||
@ -92,6 +160,7 @@ export const setFieldsForTemplate = async ({
|
||||
height: field.pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
Template: {
|
||||
connect: {
|
||||
id: templateId,
|
||||
@ -106,8 +175,8 @@ export const setFieldsForTemplate = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (removedFields.length > 0) {
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
@ -10,6 +15,13 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
@ -87,6 +99,52 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||
}
|
||||
|
||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateNumberField(value, numberFieldParsedMeta, true);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.TEXT && field.fieldMeta) {
|
||||
const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateTextField(value, textFieldParsedMeta, true);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
|
||||
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
const checkboxFieldValues = value.split(',');
|
||||
const errors = validateCheckboxField(checkboxFieldValues, checkboxFieldParsedMeta, true);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.RADIO && field.fieldMeta) {
|
||||
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateRadioField(value, radioFieldParsedMeta, true);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
|
||||
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
|
||||
const errors = validateDropdownField(value, dropdownFieldParsedMeta, true);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
@ -177,6 +235,16 @@ export const signFieldWithToken = async ({
|
||||
type,
|
||||
data: updatedField.customText,
|
||||
}))
|
||||
.with(
|
||||
FieldType.NUMBER,
|
||||
FieldType.RADIO,
|
||||
FieldType.CHECKBOX,
|
||||
FieldType.DROPDOWN,
|
||||
(type) => ({
|
||||
type,
|
||||
data: updatedField.customText,
|
||||
}),
|
||||
)
|
||||
.exhaustive(),
|
||||
fieldSecurity: derivedRecipientActionAuth
|
||||
? {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType, Team } from '@documenso/prisma/client';
|
||||
|
||||
@ -18,6 +19,7 @@ export type UpdateFieldOptions = {
|
||||
pageWidth?: number;
|
||||
pageHeight?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
|
||||
export const updateField = async ({
|
||||
@ -33,6 +35,7 @@ export const updateField = async ({
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
requestMetadata,
|
||||
fieldMeta,
|
||||
}: UpdateFieldOptions) => {
|
||||
const oldField = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
@ -71,6 +74,7 @@ export const updateField = async ({
|
||||
positionY: pageY,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
fieldMeta,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
||||
import { match } from 'ts-pattern';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||
@ -13,6 +13,8 @@ import { FieldType } from '@documenso/prisma/client';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
|
||||
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
@ -77,86 +79,163 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
await pdf.embedFont(fontCaveat);
|
||||
}
|
||||
|
||||
const isInsertingImage =
|
||||
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
|
||||
await match(field)
|
||||
.with(
|
||||
{
|
||||
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
|
||||
Signature: { signatureImageAsBase64: P.string },
|
||||
},
|
||||
async (field) => {
|
||||
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||
|
||||
if (isSignatureField && isInsertingImage) {
|
||||
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
|
||||
let imageWidth = image.width;
|
||||
let imageHeight = image.height;
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
|
||||
imageWidth = imageWidth * scalingFactor;
|
||||
imageHeight = imageHeight * scalingFactor;
|
||||
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||
|
||||
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
||||
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
imageY = pageHeight - imageY - imageHeight;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
imageY = pageHeight - imageY - imageHeight;
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
imageX,
|
||||
imageY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
imageX,
|
||||
imageY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
imageX = adjustedPosition.xPos;
|
||||
imageY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
imageX = adjustedPosition.xPos;
|
||||
imageY = adjustedPosition.yPos;
|
||||
}
|
||||
page.drawImage(image, {
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
},
|
||||
)
|
||||
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
|
||||
|
||||
page.drawImage(image, {
|
||||
x: imageX,
|
||||
y: imageY,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
if (!meta.success) {
|
||||
console.error(meta.error);
|
||||
|
||||
throw new Error('Invalid checkbox field meta');
|
||||
}
|
||||
|
||||
const selected = field.customText.split(',');
|
||||
|
||||
for (const [index, item] of (meta.data.values ?? []).entries()) {
|
||||
const offsetY = index * 16;
|
||||
|
||||
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
checkbox.check();
|
||||
}
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + 16,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
checkbox.addToPage(page, {
|
||||
x: fieldX,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
}
|
||||
})
|
||||
.with({ type: FieldType.RADIO }, (field) => {
|
||||
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
|
||||
|
||||
if (!meta.success) {
|
||||
console.error(meta.error);
|
||||
|
||||
throw new Error('Invalid radio field meta');
|
||||
}
|
||||
|
||||
const selected = field.customText.split(',');
|
||||
|
||||
for (const [index, item] of (meta.data.values ?? []).entries()) {
|
||||
const offsetY = index * 16;
|
||||
|
||||
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
||||
|
||||
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
||||
x: fieldX + 16,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
size: 12,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
|
||||
radio.addOptionToPage(item.value, page, {
|
||||
x: fieldX,
|
||||
y: pageHeight - (fieldY + offsetY),
|
||||
height: 8,
|
||||
width: 8,
|
||||
});
|
||||
|
||||
if (selected.includes(item.value)) {
|
||||
radio.select(item.value);
|
||||
}
|
||||
}
|
||||
})
|
||||
.otherwise((field) => {
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawText(field.customText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
textY = pageHeight - textY - textHeight;
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
textX,
|
||||
textY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
textX = adjustedPosition.xPos;
|
||||
textY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawText(field.customText, {
|
||||
x: textX,
|
||||
y: textY,
|
||||
size: fontSize,
|
||||
font,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
}
|
||||
|
||||
return pdf;
|
||||
};
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
Prisma,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@ -26,6 +27,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
@ -296,12 +298,16 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
await tx.field.createMany({
|
||||
data: nonDirectRecipientFieldsToCreate,
|
||||
data: nonDirectRecipientFieldsToCreate.map((field) => ({
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
// Create the direct recipient and their non signature fields.
|
||||
@ -331,6 +337,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
height: templateField.height,
|
||||
customText,
|
||||
inserted: true,
|
||||
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@ -361,6 +368,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
height: templateField.height,
|
||||
customText: '',
|
||||
inserted: true,
|
||||
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||
Signature: {
|
||||
create: {
|
||||
recipientId: createdDirectRecipient.id,
|
||||
@ -454,10 +462,20 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
data:
|
||||
field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '',
|
||||
}))
|
||||
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
|
||||
type,
|
||||
data: field.customText,
|
||||
}))
|
||||
.with(
|
||||
FieldType.DATE,
|
||||
FieldType.EMAIL,
|
||||
FieldType.NAME,
|
||||
FieldType.TEXT,
|
||||
FieldType.NUMBER,
|
||||
FieldType.CHECKBOX,
|
||||
FieldType.DROPDOWN,
|
||||
FieldType.RADIO,
|
||||
(type) => ({
|
||||
type,
|
||||
data: field.customText,
|
||||
}),
|
||||
)
|
||||
.exhaustive(),
|
||||
fieldSecurity: derivedRecipientActionAuth
|
||||
? {
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import {
|
||||
@ -33,6 +34,7 @@ export type CreateDocumentFromTemplateResponse = Awaited<
|
||||
|
||||
export type CreateDocumentFromTemplateOptions = {
|
||||
templateId: number;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
recipients: {
|
||||
@ -58,6 +60,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
|
||||
export const createDocumentFromTemplate = async ({
|
||||
templateId,
|
||||
externalId,
|
||||
userId,
|
||||
teamId,
|
||||
recipients,
|
||||
@ -147,6 +150,7 @@ export const createDocumentFromTemplate = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
source: DocumentSource.TEMPLATE,
|
||||
externalId,
|
||||
templateId: template.id,
|
||||
userId,
|
||||
teamId: template.teamId,
|
||||
@ -222,12 +226,16 @@ export const createDocumentFromTemplate = async ({
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
await tx.field.createMany({
|
||||
data: fieldsToCreate,
|
||||
data: fieldsToCreate.map((field) => ({
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
|
||||
@ -15,6 +15,7 @@ export type UpdateTemplateSettingsOptions = {
|
||||
templateId: number;
|
||||
data: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
publicTitle?: string;
|
||||
@ -99,6 +100,7 @@ export const updateTemplateSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
type: data.type,
|
||||
publicDescription: data.publicDescription,
|
||||
publicTitle: data.publicTitle,
|
||||
|
||||
@ -35,6 +35,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
||||
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||
]);
|
||||
|
||||
@ -252,6 +253,22 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = 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) => {
|
||||
@ -355,6 +372,17 @@ export const ZDocumentAuditLogEventDocumentTitleUpdatedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document external ID updated.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED),
|
||||
data: z.object({
|
||||
from: z.string().nullish(),
|
||||
to: z.string().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Field created.
|
||||
*/
|
||||
@ -450,6 +478,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||
ZDocumentAuditLogEventDocumentSentSchema,
|
||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||
ZDocumentAuditLogEventFieldUpdatedSchema,
|
||||
|
||||
80
packages/lib/types/field-meta.ts
Normal file
80
packages/lib/types/field-meta.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZBaseFieldMeta = z.object({
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
readOnly: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
||||
|
||||
export const ZTextFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('text').default('text'),
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
|
||||
|
||||
export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('number').default('number'),
|
||||
numberFormat: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
minValue: z.number().optional(),
|
||||
maxValue: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;
|
||||
|
||||
export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('radio').default('radio'),
|
||||
values: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
checked: z.boolean(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
||||
|
||||
export const ZCheckboxFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('checkbox').default('checkbox'),
|
||||
values: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
checked: z.boolean(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
validationRule: z.string().optional(),
|
||||
validationLength: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TCheckboxFieldMeta = z.infer<typeof ZCheckboxFieldMeta>;
|
||||
|
||||
export const ZDropdownFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('dropdown').default('dropdown'),
|
||||
values: z.array(z.object({ value: z.string() })).optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
|
||||
|
||||
export const ZFieldMetaSchema = z
|
||||
.union([
|
||||
ZTextFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
])
|
||||
.optional();
|
||||
|
||||
export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;
|
||||
@ -332,6 +332,10 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
|
||||
anonymous: 'Document title updated',
|
||||
identified: 'updated the document title',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
|
||||
anonymous: 'Document external ID updated',
|
||||
identified: 'updated the document external ID',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
|
||||
anonymous: 'Document sent',
|
||||
identified: 'sent the document',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Field } from '@documenso/prisma/client';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
|
||||
/**
|
||||
* Sort the fields by the Y position on the document.
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "FieldType" ADD VALUE 'NUMBER';
|
||||
ALTER TYPE "FieldType" ADD VALUE 'RADIO';
|
||||
ALTER TYPE "FieldType" ADD VALUE 'CHECKBOX';
|
||||
ALTER TYPE "FieldType" ADD VALUE 'DROPDOWN';
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" ADD COLUMN "fieldMeta" JSONB;
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "externalId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "externalId" TEXT;
|
||||
@ -283,6 +283,7 @@ enum DocumentSource {
|
||||
|
||||
model Document {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String?
|
||||
userId Int
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
authOptions Json?
|
||||
@ -412,6 +413,10 @@ enum FieldType {
|
||||
EMAIL
|
||||
DATE
|
||||
TEXT
|
||||
NUMBER
|
||||
RADIO
|
||||
CHECKBOX
|
||||
DROPDOWN
|
||||
}
|
||||
|
||||
model Field {
|
||||
@ -432,6 +437,7 @@ model Field {
|
||||
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Signature Signature?
|
||||
fieldMeta Json?
|
||||
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@ -589,6 +595,7 @@ model TemplateMeta {
|
||||
|
||||
model Template {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String?
|
||||
type TemplateType @default(PRIVATE)
|
||||
title String
|
||||
userId Int
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Field, Signature } from '@documenso/prisma/client';
|
||||
|
||||
export type FieldWithSignatureAndFieldMeta = Field & {
|
||||
Signature?: Signature | null;
|
||||
fieldMeta: FieldMeta | null;
|
||||
};
|
||||
@ -55,6 +55,7 @@ export const ZSetSettingsForDocumentMutationSchema = z.object({
|
||||
teamId: z.number().min(1).optional(),
|
||||
data: z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
externalId: z.string().nullish(),
|
||||
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
|
||||
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
|
||||
}),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
|
||||
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||
@ -11,6 +12,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZAddFieldsMutationSchema,
|
||||
ZAddTemplateFieldsMutationSchema,
|
||||
ZGetFieldQuerySchema,
|
||||
ZRemovedSignedFieldWithTokenMutationSchema,
|
||||
ZSignFieldWithTokenMutationSchema,
|
||||
} from './schema';
|
||||
@ -34,6 +36,7 @@ export const fieldRouter = router({
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
@ -65,6 +68,7 @@ export const fieldRouter = router({
|
||||
pageY: field.pageY,
|
||||
pageWidth: field.pageWidth,
|
||||
pageHeight: field.pageHeight,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
@ -116,4 +120,49 @@ export const fieldRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getField: authenticatedProcedure.input(ZGetFieldQuerySchema).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { fieldId, teamId } = input;
|
||||
|
||||
return await getFieldById({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
fieldId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to find this field. Please try again.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// This doesn't appear to be used anywhere, and it doesn't seem to support updating template fields
|
||||
// so commenting this out for now.
|
||||
// updateField: authenticatedProcedure
|
||||
// .input(ZUpdateFieldMutationSchema)
|
||||
// .mutation(async ({ input, ctx }) => {
|
||||
// try {
|
||||
// const { documentId, fieldId, fieldMeta, teamId } = input;
|
||||
|
||||
// return await updateField({
|
||||
// userId: ctx.user.id,
|
||||
// teamId,
|
||||
// fieldId,
|
||||
// documentId,
|
||||
// requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
// fieldMeta: fieldMeta,
|
||||
// });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw new TRPCError({
|
||||
// code: 'BAD_REQUEST',
|
||||
// message: 'We were unable to set this field. Please try again later.',
|
||||
// });
|
||||
// }
|
||||
// }),
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZAddFieldsMutationSchema = z.object({
|
||||
@ -16,6 +17,7 @@ export const ZAddFieldsMutationSchema = z.object({
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
@ -35,6 +37,7 @@ export const ZAddTemplateFieldsMutationSchema = z.object({
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
@ -59,3 +62,17 @@ export const ZRemovedSignedFieldWithTokenMutationSchema = z.object({
|
||||
export type TRemovedSignedFieldWithTokenMutationSchema = z.infer<
|
||||
typeof ZRemovedSignedFieldWithTokenMutationSchema
|
||||
>;
|
||||
|
||||
export const ZGetFieldQuerySchema = z.object({
|
||||
fieldId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TGetFieldQuerySchema = z.infer<typeof ZGetFieldQuerySchema>;
|
||||
|
||||
export const ZUpdateFieldMutationSchema = z.object({
|
||||
fieldId: z.number(),
|
||||
documentId: z.number(),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
@ -23,6 +23,10 @@ export const mapField = (
|
||||
.with(FieldType.EMAIL, () => signer.email)
|
||||
.with(FieldType.NAME, () => signer.name)
|
||||
.with(FieldType.TEXT, () => signer.customText)
|
||||
.with(FieldType.NUMBER, () => signer.customText)
|
||||
.with(FieldType.RADIO, () => signer.customText)
|
||||
.with(FieldType.CHECKBOX, () => signer.customText)
|
||||
.with(FieldType.DROPDOWN, () => signer.customText)
|
||||
.otherwise(() => '');
|
||||
|
||||
return {
|
||||
|
||||
@ -31,7 +31,7 @@ export const singleplayerRouter = router({
|
||||
.input(ZCreateSinglePlayerDocumentMutationSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { signer, fields, documentData, documentName } = input;
|
||||
const { signer, fields, documentData, documentName, fieldMeta } = input;
|
||||
|
||||
const document = await getFile({
|
||||
data: documentData.data,
|
||||
@ -69,6 +69,7 @@ export const singleplayerRouter = router({
|
||||
documentId: -1,
|
||||
templateId: null,
|
||||
recipientId: -1,
|
||||
fieldMeta: fieldMeta || null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
|
||||
@ -24,6 +25,7 @@ export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
|
||||
height: z.number(),
|
||||
}),
|
||||
),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
});
|
||||
|
||||
export type TCreateSinglePlayerDocumentMutationSchema = z.infer<
|
||||
|
||||
@ -11,6 +11,7 @@ import { deleteTemplate } from '@documenso/lib/server-only/template/delete-templ
|
||||
import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link';
|
||||
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { moveTemplateToTeam } from '@documenso/lib/server-only/template/move-template-to-team';
|
||||
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
|
||||
@ -99,7 +100,7 @@ export const templateRouter = router({
|
||||
try {
|
||||
const { templateId, teamId } = input;
|
||||
|
||||
const limits = await getServerLimits({ email: ctx.user.email });
|
||||
const limits = await getServerLimits({ email: ctx.user.email, teamId });
|
||||
|
||||
if (limits.remaining.documents === 0) {
|
||||
throw new Error('You have reached your document limit.');
|
||||
@ -247,7 +248,9 @@ export const templateRouter = router({
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const limits = await getServerLimits({ email: ctx.user.email });
|
||||
const template = await getTemplateById({ id: templateId, userId: ctx.user.id });
|
||||
|
||||
const limits = await getServerLimits({ email: ctx.user.email, teamId: template.teamId });
|
||||
|
||||
if (limits.remaining.directTemplates === 0) {
|
||||
throw new AppError(
|
||||
|
||||
@ -74,6 +74,7 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
|
||||
teamId: z.number().min(1).optional(),
|
||||
data: z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
externalId: z.string().nullish(),
|
||||
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
|
||||
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
|
||||
publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(),
|
||||
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -59,6 +59,7 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_SMTP_APIKEY?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_SECURE?: string;
|
||||
NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
@ -22,6 +24,51 @@ export type FieldContainerPortalProps = {
|
||||
cardClassName?: string;
|
||||
};
|
||||
|
||||
const getCardClassNames = (
|
||||
field: Field,
|
||||
parsedField: TFieldMetaSchema | null,
|
||||
isValidating: boolean,
|
||||
checkBoxOrRadio: boolean,
|
||||
cardClassName?: string,
|
||||
) => {
|
||||
const baseClasses = 'field-card-container relative z-20 h-full w-full transition-all';
|
||||
|
||||
const insertedClasses =
|
||||
'bg-documenso/20 border-documenso ring-documenso-200 ring-offset-documenso-200 ring-2 ring-offset-2 dark:shadow-none';
|
||||
const nonRequiredClasses =
|
||||
'border-yellow-300 shadow-none ring-2 ring-yellow-100 ring-offset-2 ring-offset-yellow-100 dark:border-2';
|
||||
const validatingClasses = 'border-orange-300 ring-1 ring-orange-300';
|
||||
const requiredClasses =
|
||||
'border-red-500 shadow-none ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 hover:text-red-500';
|
||||
const requiredCheckboxRadioClasses = 'border-dashed border-red-500';
|
||||
|
||||
if (checkBoxOrRadio) {
|
||||
return cn(
|
||||
{
|
||||
[insertedClasses]: field.inserted,
|
||||
'ring-offset-yellow-200 border-dashed border-yellow-300 ring-2 ring-yellow-200 ring-offset-2 dark:shadow-none':
|
||||
!field.inserted && !parsedField?.required,
|
||||
'shadow-none': !field.inserted,
|
||||
[validatingClasses]: !field.inserted && isValidating,
|
||||
[requiredCheckboxRadioClasses]: !field.inserted && parsedField?.required,
|
||||
},
|
||||
cardClassName,
|
||||
);
|
||||
}
|
||||
|
||||
return cn(
|
||||
baseClasses,
|
||||
{
|
||||
[insertedClasses]: field.inserted,
|
||||
[nonRequiredClasses]: !field.inserted && !parsedField?.required,
|
||||
'shadow-none': !field.inserted && checkBoxOrRadio,
|
||||
[validatingClasses]: !field.inserted && isValidating,
|
||||
[requiredClasses]: !field.inserted && parsedField?.required && !checkBoxOrRadio,
|
||||
},
|
||||
cardClassName,
|
||||
);
|
||||
};
|
||||
|
||||
export function FieldContainerPortal({
|
||||
field,
|
||||
children,
|
||||
@ -29,16 +76,22 @@ export function FieldContainerPortal({
|
||||
}: FieldContainerPortalProps) {
|
||||
const coords = useFieldPageCoords(field);
|
||||
|
||||
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
|
||||
const isFieldSigned = field.inserted;
|
||||
|
||||
const style = {
|
||||
top: `${coords.y}px`,
|
||||
left: `${coords.x}px`,
|
||||
// height: `${coords.height}px`,
|
||||
// width: `${coords.width}px`,
|
||||
...((!isCheckboxOrRadioField) && {
|
||||
height: `${coords.height}px`,
|
||||
width: `${coords.width}px`,
|
||||
}),
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn('absolute', className)}
|
||||
style={{
|
||||
top: `${coords.y}px`,
|
||||
left: `${coords.x}px`,
|
||||
height: `${coords.height}px`,
|
||||
width: `${coords.width}px`,
|
||||
}}
|
||||
>
|
||||
<div className={cn('absolute', className)} style={style}>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
@ -47,7 +100,6 @@ export function FieldContainerPortal({
|
||||
|
||||
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -70,19 +122,27 @@ export function FieldRootContainer({ field, children, cardClassName }: FieldCont
|
||||
};
|
||||
}, []);
|
||||
|
||||
const parsedField = useMemo(
|
||||
() => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null),
|
||||
[field.fieldMeta],
|
||||
);
|
||||
const isCheckboxOrRadio = useMemo(
|
||||
() => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
|
||||
[parsedField],
|
||||
);
|
||||
|
||||
const cardClassNames = useMemo(
|
||||
() => getCardClassNames(field, parsedField, isValidating, isCheckboxOrRadio, cardClassName),
|
||||
[field, parsedField, isValidating, isCheckboxOrRadio, cardClassName],
|
||||
);
|
||||
|
||||
return (
|
||||
<FieldContainerPortal field={field}>
|
||||
<Card
|
||||
id={`field-${field.id}`}
|
||||
className={cn(
|
||||
'field-card-container bg-background relative z-20 h-full w-full transition-all',
|
||||
{
|
||||
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
|
||||
},
|
||||
cardClassName,
|
||||
)}
|
||||
ref={ref}
|
||||
data-inserted={field.inserted ? 'true' : 'false'}
|
||||
className={cardClassNames}
|
||||
>
|
||||
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
|
||||
{children}
|
||||
|
||||
108
packages/ui/lib/signer-colors.ts
Normal file
108
packages/ui/lib/signer-colors.ts
Normal file
@ -0,0 +1,108 @@
|
||||
// !: We declare all of our classes here since TailwindCSS will remove any unused CSS classes,
|
||||
// !: therefore doing this at runtime is not possible without whitelisting a set of classnames.
|
||||
// !:
|
||||
// !: This will later be improved as we move to a CSS variable approach and rotate the lightness
|
||||
// !: values of the declared variable to do all the background, border and shadow styles.
|
||||
export const SIGNER_COLOR_STYLES = {
|
||||
green: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-green))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-green)/10%),0_0_0_2px_hsl(var(--signer-green)/60%),0_0_0_0.5px_hsl(var(--signer-green))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-green))]/10 hover:to-[hsl(var(--signer-green))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-green))]',
|
||||
comboxBoxItem:
|
||||
'hover:bg-[hsl(var(--signer-green)/15%)] active:bg-[hsl(var(--signer-green)/15%)]',
|
||||
},
|
||||
},
|
||||
|
||||
blue: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-blue))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-blue)/10%),0_0_0_2px_hsl(var(--signer-blue)/60%),0_0_0_0.5px_hsl(var(--signer-blue))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-blue))]/10 hover:to-[hsl(var(--signer-blue))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-blue))]',
|
||||
comboxBoxItem:
|
||||
'hover:bg-[hsl(var(--signer-blue)/15%)] active:bg-[hsl(var(--signer-blue)/15%)]',
|
||||
},
|
||||
},
|
||||
|
||||
purple: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-purple))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-purple)/10%),0_0_0_2px_hsl(var(--signer-purple)/60%),0_0_0_0.5px_hsl(var(--signer-purple))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-purple))]/10 hover:to-[hsl(var(--signer-purple))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-purple))]',
|
||||
comboxBoxItem:
|
||||
'hover:bg-[hsl(var(--signer-purple)/15%)] active:bg-[hsl(var(--signer-purple)/15%)]',
|
||||
},
|
||||
},
|
||||
|
||||
orange: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-orange))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-orange)/10%),0_0_0_2px_hsl(var(--signer-orange)/60%),0_0_0_0.5px_hsl(var(--signer-orange))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-orange))]/10 hover:to-[hsl(var(--signer-orange))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-orange))]',
|
||||
comboxBoxItem:
|
||||
'hover:bg-[hsl(var(--signer-orange)/15%)] active:bg-[hsl(var(--signer-orange)/15%)]',
|
||||
},
|
||||
},
|
||||
|
||||
yellow: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-yellow))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-yellow)/10%),0_0_0_2px_hsl(var(--signer-yellow)/60%),0_0_0_0.5px_hsl(var(--signer-yellow))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-yellow))]/10 hover:to-[hsl(var(--signer-yellow))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-yellow))]',
|
||||
comboxBoxItem:
|
||||
'hover:bg-[hsl(var(--signer-yellow)/15%)] active:bg-[hsl(var(--signer-yellow)/15%)]',
|
||||
},
|
||||
},
|
||||
|
||||
pink: {
|
||||
default: {
|
||||
background: 'bg-[hsl(var(--signer-pink))]',
|
||||
base: 'rounded-lg shadow-[0_0_0_5px_hsl(var(--signer-pink)/10%),0_0_0_2px_hsl(var(--signer-pink)/60%),0_0_0_0.5px_hsl(var(--signer-pink))]',
|
||||
fieldItem:
|
||||
'group/field-item p-2 border-none ring-none hover:bg-gradient-to-r hover:from-[hsl(var(--signer-pink))]/10 hover:to-[hsl(var(--signer-pink))]/10',
|
||||
fieldItemInitials:
|
||||
'opacity-0 transition duration-200 group-hover/field-item:opacity-100 group-hover/field-item:bg-[hsl(var(--signer-pink))]',
|
||||
comboxBoxItem:
|
||||
'hover:bg-[hsl(var(--signer-pink)/15%)] active:bg-[hsl(var(--signer-pink)/15%)]',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type CombinedStylesKey = keyof typeof SIGNER_COLOR_STYLES;
|
||||
|
||||
export const AVAILABLE_SIGNER_COLORS = [
|
||||
'green',
|
||||
'blue',
|
||||
'purple',
|
||||
'orange',
|
||||
'yellow',
|
||||
'pink',
|
||||
] satisfies CombinedStylesKey[];
|
||||
|
||||
export const useSignerColors = (index: number) => {
|
||||
const key = AVAILABLE_SIGNER_COLORS[index % AVAILABLE_SIGNER_COLORS.length];
|
||||
|
||||
return SIGNER_COLOR_STYLES[key];
|
||||
};
|
||||
|
||||
export const getSignerColorStyles = (index: number) => {
|
||||
// Disabling the rule since the hook doesn't do anything special and can
|
||||
// be used universally.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useSignerColors(index);
|
||||
};
|
||||
@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary data-[state=checked]:bg-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -4,18 +4,34 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Disc,
|
||||
Hash,
|
||||
Info,
|
||||
Mail,
|
||||
Type,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { FieldType, SendStatus } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../button';
|
||||
import { Card, CardContent } from '../card';
|
||||
@ -32,6 +48,7 @@ import {
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import { FieldItem } from './field-item';
|
||||
import { FieldAdvancedSettings } from './field-item-advanced-settings';
|
||||
import { MissingSignatureFieldDialog } from './missing-signature-field-dialog';
|
||||
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
|
||||
|
||||
@ -42,11 +59,21 @@ const fontCaveat = Caveat({
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
||||
const DEFAULT_WIDTH_PERCENT = 15;
|
||||
const MIN_HEIGHT_PX = 40;
|
||||
const MIN_WIDTH_PX = 140;
|
||||
|
||||
const MIN_HEIGHT_PX = 60;
|
||||
const MIN_WIDTH_PX = 200;
|
||||
export type FieldFormType = {
|
||||
nativeId?: number;
|
||||
formId: string;
|
||||
pageNumber: number;
|
||||
type: FieldType;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
signerEmail: string;
|
||||
fieldMeta?: FieldMeta;
|
||||
};
|
||||
|
||||
export type AddFieldsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
@ -56,8 +83,15 @@ export type AddFieldsFormProps = {
|
||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||
canGoBack?: boolean;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
/*
|
||||
I hate this, but due to TailwindCSS JIT, I couldnn't find a better way to do this for now.
|
||||
|
||||
TODO: Try to find a better way to do this.
|
||||
*/
|
||||
|
||||
export const AddFieldsFormPartial = ({
|
||||
documentFlow,
|
||||
hideRecipients = false,
|
||||
@ -66,6 +100,7 @@ export const AddFieldsFormPartial = ({
|
||||
onSubmit,
|
||||
canGoBack = false,
|
||||
isDocumentPdfLoaded,
|
||||
teamId,
|
||||
}: AddFieldsFormProps) => {
|
||||
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
|
||||
|
||||
@ -73,11 +108,15 @@ export const AddFieldsFormPartial = ({
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
const canRenderBackButtonAsRemove =
|
||||
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [currentField, setCurrentField] = useState<FieldFormType>();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
getValues,
|
||||
} = useForm<TAddFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
fields: fields.map((field) => ({
|
||||
@ -91,11 +130,30 @@ export const AddFieldsFormPartial = ({
|
||||
pageHeight: Number(field.height),
|
||||
signerEmail:
|
||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
const handleSavedFieldSettings = (fieldState: FieldMeta) => {
|
||||
const initialValues = getValues();
|
||||
|
||||
const updatedFields = initialValues.fields.map((field) => {
|
||||
if (field.formId === currentField?.formId) {
|
||||
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldState);
|
||||
|
||||
return {
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
};
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
setValue('fields', updatedFields);
|
||||
};
|
||||
|
||||
const {
|
||||
append,
|
||||
@ -110,9 +168,45 @@ export const AddFieldsFormPartial = ({
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
|
||||
const selectedSignerStyles = useSignerColors(
|
||||
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
|
||||
);
|
||||
|
||||
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
|
||||
|
||||
const filterFieldsWithEmptyValues = (fields: typeof localFields, fieldType: string) =>
|
||||
fields
|
||||
.filter((field) => field.type === fieldType)
|
||||
.filter((field) => {
|
||||
if (field.fieldMeta && 'values' in field.fieldMeta) {
|
||||
return field.fieldMeta.values?.length === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const emptyCheckboxFields = useMemo(
|
||||
() => filterFieldsWithEmptyValues(localFields, FieldType.CHECKBOX),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[localFields],
|
||||
);
|
||||
|
||||
const emptyRadioFields = useMemo(
|
||||
() => filterFieldsWithEmptyValues(localFields, FieldType.RADIO),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[localFields],
|
||||
);
|
||||
|
||||
const emptySelectFields = useMemo(
|
||||
() => filterFieldsWithEmptyValues(localFields, FieldType.DROPDOWN),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[localFields],
|
||||
);
|
||||
|
||||
const hasErrors =
|
||||
emptyCheckboxFields.length > 0 || emptyRadioFields.length > 0 || emptySelectFields.length > 0;
|
||||
|
||||
const isFieldsDisabled =
|
||||
!selectedSigner ||
|
||||
hasSelectedSignerBeenSent ||
|
||||
@ -195,6 +289,7 @@ export const AddFieldsFormPartial = ({
|
||||
pageWidth: fieldPageWidth,
|
||||
pageHeight: fieldPageHeight,
|
||||
signerEmail: selectedSigner.email,
|
||||
fieldMeta: undefined,
|
||||
});
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
@ -276,11 +371,9 @@ export const AddFieldsFormPartial = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
height: Math.max(MIN_HEIGHT_PX),
|
||||
width: Math.max(MIN_WIDTH_PX),
|
||||
};
|
||||
});
|
||||
|
||||
@ -320,6 +413,10 @@ export const AddFieldsFormPartial = ({
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
const handleAdvancedSettings = () => {
|
||||
setShowAdvancedSettings((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleGoNextClick = () => {
|
||||
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
||||
localFields.some(
|
||||
@ -338,297 +435,473 @@ export const AddFieldsFormPartial = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={documentFlow.title}
|
||||
description={documentFlow.description}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
{selectedField && (
|
||||
<Card
|
||||
className={cn(
|
||||
'bg-field-card/80 pointer-events-none fixed z-50 cursor-pointer border-2 backdrop-blur-[1px]',
|
||||
{
|
||||
'border-field-card-border': isFieldWithinBounds,
|
||||
'opacity-50': !isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<CardContent className="text-field-card-foreground flex h-full w-full items-center justify-center p-2">
|
||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isDocumentPdfLoaded &&
|
||||
localFields.map((field, index) => (
|
||||
<FieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
||||
minHeight={fieldBounds.current.height}
|
||||
minWidth={fieldBounds.current.width}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
|
||||
{showAdvancedSettings && currentField ? (
|
||||
<FieldAdvancedSettings
|
||||
title="Advanced settings"
|
||||
description={`Configure the ${FRIENDLY_FIELD_TYPE[currentField.type]} field`}
|
||||
field={currentField}
|
||||
fields={localFields}
|
||||
onAdvancedSettings={handleAdvancedSettings}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSave={handleSavedFieldSettings}
|
||||
teamId={teamId}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={documentFlow.title}
|
||||
description={documentFlow.description}
|
||||
/>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
|
||||
selectedSignerStyles.default.base,
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
{isDocumentPdfLoaded &&
|
||||
localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
return (
|
||||
<FieldItem
|
||||
key={index}
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
field={field}
|
||||
disabled={
|
||||
selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent
|
||||
}
|
||||
minHeight={fieldBounds.current.height}
|
||||
minWidth={fieldBounds.current.width}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
}}
|
||||
hideRecipients={hideRecipients}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedSigner?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
No recipient matching this description was found.
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||
</div>
|
||||
|
||||
{recipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
No recipients with this role
|
||||
</div>
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
|
||||
selectedSignerStyles.default.base,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
})}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
{!selectedSigner?.email && (
|
||||
<span className="gradie flex-1 truncate text-left">
|
||||
{selectedSigner?.email}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedSigner}
|
||||
className={cn('h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedSigner,
|
||||
'opacity-100': recipient === selectedSigner,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedSigner?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
This document has already been sent to this recipient. You can no
|
||||
longer edit this recipient.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
No recipient matching this description was found.
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
No recipients with this role
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleRecipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).default.comboxBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
},
|
||||
)}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedSigner}
|
||||
className={cn('h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedSigner,
|
||||
'opacity-100': recipient === selectedSigner,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
This document has already been sent to this recipient. You can
|
||||
no longer edit this recipient.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||
<fieldset disabled={isFieldsDisabled} className="my-2 grid grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
Signature
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Name
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Date
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.TEXT)}
|
||||
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
||||
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Type className="h-4 w-4" />
|
||||
Text
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.NUMBER)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NUMBER)}
|
||||
data-selected={selectedField === FieldType.NUMBER ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Hash className="h-4 w-4" />
|
||||
Number
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.RADIO)}
|
||||
onMouseDown={() => setSelectedField(FieldType.RADIO)}
|
||||
data-selected={selectedField === FieldType.RADIO ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Disc className="h-4 w-4" />
|
||||
Radio
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.CHECKBOX)}
|
||||
onMouseDown={() => setSelectedField(FieldType.CHECKBOX)}
|
||||
data-selected={selectedField === FieldType.CHECKBOX ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Checkbox
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.DROPDOWN)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DROPDOWN)}
|
||||
data-selected={selectedField === FieldType.DROPDOWN ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Dropdown
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
{hasErrors && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
<li className="text-sm text-red-500">
|
||||
To proceed further, please set at least one value for the{' '}
|
||||
{emptyCheckboxFields.length > 0
|
||||
? 'Checkbox'
|
||||
: emptyRadioFields.length > 0
|
||||
? 'Radio'
|
||||
: 'Select'}{' '}
|
||||
field.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||
<fieldset disabled={isFieldsDisabled} className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.name || 'Signature'}
|
||||
</p>
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
disableNextStep={hasErrors}
|
||||
onGoBackClick={() => {
|
||||
previousStep();
|
||||
remove();
|
||||
documentFlow.onBackStep?.();
|
||||
}}
|
||||
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
|
||||
onGoNextClick={handleGoNextClick}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Email'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Name'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Date'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.TEXT)}
|
||||
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
||||
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
)}
|
||||
>
|
||||
{'Text'}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
onGoBackClick={() => {
|
||||
previousStep();
|
||||
remove();
|
||||
documentFlow.onBackStep?.();
|
||||
}}
|
||||
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
|
||||
onGoNextClick={handleGoNextClick}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
<MissingSignatureFieldDialog
|
||||
isOpen={isMissingSignatureDialogVisible}
|
||||
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
||||
/>
|
||||
<MissingSignatureFieldDialog
|
||||
isOpen={isMissingSignatureDialogVisible}
|
||||
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZAddFieldsFormSchema = z.object({
|
||||
@ -14,6 +15,7 @@ export const ZAddFieldsFormSchema = z.object({
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@ -78,6 +78,7 @@ export const AddSettingsFormPartial = ({
|
||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||
defaultValues: {
|
||||
title: document.title,
|
||||
externalId: document.externalId || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
|
||||
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
|
||||
meta: {
|
||||
@ -183,6 +184,34 @@ export const AddSettingsFormPartial = ({
|
||||
|
||||
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
|
||||
<div className="flex flex-col space-y-6 ">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
External ID{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
Add an external ID to the document. This can be used to identify the
|
||||
document in external systems.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.dateFormat"
|
||||
|
||||
@ -21,6 +21,7 @@ export const ZMapNegativeOneToUndefinedSchema = z
|
||||
|
||||
export const ZAddSettingsFormSchema = z.object({
|
||||
title: z.string().trim().min(1, { message: "Title can't be empty" }),
|
||||
externalId: z.string().optional(),
|
||||
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||
ZDocumentAccessAuthTypesSchema.optional(),
|
||||
),
|
||||
|
||||
@ -59,7 +59,6 @@ export const AddSignatureFormPartial = ({
|
||||
requireSignature = true,
|
||||
}: AddSignatureFormProps) => {
|
||||
const { currentStep, totalSteps } = useStep();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
|
||||
// Refined schema which takes into account whether to allow an empty name or signature.
|
||||
@ -147,6 +146,26 @@ export const AddSignatureFormPartial = ({
|
||||
return !form.formState.errors.customText;
|
||||
}
|
||||
|
||||
if (fieldType === FieldType.NUMBER) {
|
||||
await form.trigger('number');
|
||||
return !form.formState.errors.number;
|
||||
}
|
||||
|
||||
if (fieldType === FieldType.RADIO) {
|
||||
await form.trigger('radio');
|
||||
return !form.formState.errors.radio;
|
||||
}
|
||||
|
||||
if (fieldType === FieldType.CHECKBOX) {
|
||||
await form.trigger('checkbox');
|
||||
return !form.formState.errors.checkbox;
|
||||
}
|
||||
|
||||
if (fieldType === FieldType.DROPDOWN) {
|
||||
await form.trigger('dropdown');
|
||||
return !form.formState.errors.dropdown;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@ -170,7 +189,7 @@ export const AddSignatureFormPartial = ({
|
||||
customText: form.getValues('name'),
|
||||
inserted: true,
|
||||
}))
|
||||
.with(FieldType.TEXT, () => ({
|
||||
.with(FieldType.TEXT, FieldType.NUMBER, FieldType.RADIO, FieldType.CHECKBOX, () => ({
|
||||
...field,
|
||||
customText: form.getValues('customText'),
|
||||
inserted: true,
|
||||
@ -374,15 +393,25 @@ export const AddSignatureFormPartial = ({
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{localFields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => {
|
||||
return (
|
||||
<SinglePlayerModeCustomTextField
|
||||
onClick={insertField(field)}
|
||||
key={field.id}
|
||||
field={field}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.with(
|
||||
FieldType.DATE,
|
||||
FieldType.TEXT,
|
||||
FieldType.EMAIL,
|
||||
FieldType.NAME,
|
||||
FieldType.NUMBER,
|
||||
FieldType.CHECKBOX,
|
||||
FieldType.RADIO,
|
||||
FieldType.DROPDOWN,
|
||||
() => {
|
||||
return (
|
||||
<SinglePlayerModeCustomTextField
|
||||
onClick={insertField(field)}
|
||||
key={field.id}
|
||||
field={field}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SinglePlayerModeSignatureField
|
||||
onClick={insertField(field)}
|
||||
|
||||
@ -7,6 +7,10 @@ export const ZAddSignatureFormSchema = z.object({
|
||||
.email({ message: 'Invalid email address' }),
|
||||
name: z.string(),
|
||||
customText: z.string(),
|
||||
number: z.number().optional(),
|
||||
radio: z.string().optional(),
|
||||
checkbox: z.boolean().optional(),
|
||||
dropdown: z.string().optional(),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import { FieldIcon } from '../field-icon';
|
||||
import type { TDocumentFlowFormSchema } from '../types';
|
||||
|
||||
type Field = TDocumentFlowFormSchema['fields'][0];
|
||||
|
||||
export type CheckboxFieldProps = {
|
||||
field: Field;
|
||||
};
|
||||
|
||||
export const CheckboxField = ({ field }: CheckboxFieldProps) => {
|
||||
let parsedFieldMeta: TCheckboxFieldMeta | undefined = undefined;
|
||||
|
||||
if (field.fieldMeta) {
|
||||
parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
}
|
||||
|
||||
if (parsedFieldMeta && (!parsedFieldMeta.values || parsedFieldMeta.values.length === 0)) {
|
||||
return (
|
||||
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
{!parsedFieldMeta?.values ? (
|
||||
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
|
||||
) : (
|
||||
parsedFieldMeta.values.map((item: { value: string; checked: boolean }, index: number) => (
|
||||
<div key={index} className='flex items-center gap-x-1.5'>
|
||||
<Checkbox
|
||||
className="h-4 w-4"
|
||||
checkClassName="text-white"
|
||||
id={`checkbox-${index}`}
|
||||
checked={item.checked}
|
||||
/>
|
||||
<Label htmlFor={`checkbox-${index}`}>{item.value}</Label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,49 @@
|
||||
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
|
||||
import { FieldIcon } from '../field-icon';
|
||||
import type { TDocumentFlowFormSchema } from '../types';
|
||||
|
||||
type Field = TDocumentFlowFormSchema['fields'][0];
|
||||
|
||||
export type RadioFieldProps = {
|
||||
field: Field;
|
||||
};
|
||||
|
||||
export const RadioField = ({ field }: RadioFieldProps) => {
|
||||
let parsedFieldMeta: TRadioFieldMeta | undefined = undefined;
|
||||
|
||||
if (field.fieldMeta) {
|
||||
parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta);
|
||||
}
|
||||
|
||||
if (parsedFieldMeta && (!parsedFieldMeta.values || parsedFieldMeta.values.length === 0)) {
|
||||
return (
|
||||
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-2'>
|
||||
{!parsedFieldMeta?.values ? (
|
||||
<FieldIcon fieldMeta={field.fieldMeta} type={field.type} signerEmail={field.signerEmail} />
|
||||
) : (
|
||||
<RadioGroup>
|
||||
{parsedFieldMeta.values?.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-x-1.5">
|
||||
<RadioGroupItem
|
||||
className="pointer-events-none"
|
||||
value={item.value}
|
||||
id={`option-${index}`}
|
||||
checked={item.checked}
|
||||
/>
|
||||
<Label htmlFor={`option-${index}`}>{item.value}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -126,6 +126,7 @@ export type DocumentFlowFormContainerActionsProps = {
|
||||
onGoNextClick?: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
disableNextStep?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentFlowFormContainerActions = ({
|
||||
@ -137,6 +138,7 @@ export const DocumentFlowFormContainerActions = ({
|
||||
onGoNextClick,
|
||||
loading,
|
||||
disabled,
|
||||
disableNextStep = false,
|
||||
}: DocumentFlowFormContainerActionsProps) => {
|
||||
return (
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
@ -155,7 +157,7 @@ export const DocumentFlowFormContainerActions = ({
|
||||
type="button"
|
||||
className="bg-documenso flex-1"
|
||||
size="lg"
|
||||
disabled={disabled || loading || !canGoNext}
|
||||
disabled={disabled || disableNextStep || loading || !canGoNext}
|
||||
loading={loading}
|
||||
onClick={onGoNextClick}
|
||||
>
|
||||
|
||||
65
packages/ui/primitives/document-flow/field-icon.tsx
Normal file
65
packages/ui/primitives/document-flow/field-icon.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
|
||||
|
||||
import type { TFieldMetaSchema as FieldMetaType } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type FieldIconProps = {
|
||||
fieldMeta: FieldMetaType;
|
||||
type: FieldType;
|
||||
signerEmail?: string;
|
||||
fontCaveatClassName?: string;
|
||||
};
|
||||
|
||||
const fieldIcons = {
|
||||
[FieldType.EMAIL]: { icon: Mail, label: 'Email' },
|
||||
[FieldType.NAME]: { icon: User, label: 'Name' },
|
||||
[FieldType.DATE]: { icon: CalendarDays, label: 'Date' },
|
||||
[FieldType.TEXT]: { icon: Type, label: 'Add text' },
|
||||
[FieldType.NUMBER]: { icon: Hash, label: 'Add number' },
|
||||
[FieldType.RADIO]: { icon: Disc, label: 'Radio' },
|
||||
[FieldType.CHECKBOX]: { icon: CheckSquare, label: 'Checkbox' },
|
||||
[FieldType.DROPDOWN]: { icon: ChevronDown, label: 'Select' },
|
||||
};
|
||||
|
||||
export const FieldIcon = ({
|
||||
fieldMeta,
|
||||
type,
|
||||
signerEmail,
|
||||
fontCaveatClassName,
|
||||
}: FieldIconProps) => {
|
||||
if (type === 'SIGNATURE' || type === 'FREE_SIGNATURE') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-field-card-foreground flex items-center justify-center gap-x-1 text-xl',
|
||||
fontCaveatClassName,
|
||||
)}
|
||||
>
|
||||
Signature
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const Icon = fieldIcons[type]?.icon;
|
||||
let label;
|
||||
|
||||
if (fieldMeta && (type === 'TEXT' || type === 'NUMBER')) {
|
||||
if (type === 'TEXT' && 'text' in fieldMeta && fieldMeta.text && !fieldMeta.label) {
|
||||
label = fieldMeta.text.length > 10 ? fieldMeta.text.substring(0, 10) + '...' : fieldMeta.text;
|
||||
} else if (fieldMeta.label) {
|
||||
label = fieldMeta.label.length > 10 ? fieldMeta.label.substring(0, 10) + '...' : fieldMeta.label;
|
||||
} else {
|
||||
label = fieldIcons[type]?.label;
|
||||
}
|
||||
} else {
|
||||
label = fieldIcons[type]?.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1.5 text-sm">
|
||||
<Icon className='h-4 w-4' /> {label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
type TBaseFieldMeta as BaseFieldMeta,
|
||||
type TCheckboxFieldMeta as CheckboxFieldMeta,
|
||||
type TDropdownFieldMeta as DropdownFieldMeta,
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
type TNumberFieldMeta as NumberFieldMeta,
|
||||
type TRadioFieldMeta as RadioFieldMeta,
|
||||
type TTextFieldMeta as TextFieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { FieldFormType } from './add-fields';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
} from './document-flow-root';
|
||||
import { FieldItem } from './field-item';
|
||||
import { CheckboxFieldAdvancedSettings } from './field-items-advanced-settings/checkbox-field';
|
||||
import { DropdownFieldAdvancedSettings } from './field-items-advanced-settings/dropdown-field';
|
||||
import { NumberFieldAdvancedSettings } from './field-items-advanced-settings/number-field';
|
||||
import { RadioFieldAdvancedSettings } from './field-items-advanced-settings/radio-field';
|
||||
import { TextFieldAdvancedSettings } from './field-items-advanced-settings/text-field';
|
||||
|
||||
export type FieldAdvancedSettingsProps = {
|
||||
teamId?: number;
|
||||
title: string;
|
||||
description: string;
|
||||
field: FieldFormType;
|
||||
fields: FieldFormType[];
|
||||
onAdvancedSettings?: () => void;
|
||||
isDocumentPdfLoaded?: boolean;
|
||||
onSave?: (fieldState: FieldMeta) => void;
|
||||
};
|
||||
|
||||
export type FieldMetaKeys =
|
||||
| keyof BaseFieldMeta
|
||||
| keyof TextFieldMeta
|
||||
| keyof NumberFieldMeta
|
||||
| keyof RadioFieldMeta
|
||||
| keyof CheckboxFieldMeta
|
||||
| keyof DropdownFieldMeta;
|
||||
|
||||
const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
||||
switch (fieldType) {
|
||||
case FieldType.TEXT:
|
||||
return {
|
||||
type: 'text',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
text: '',
|
||||
characterLimit: 0,
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
case FieldType.NUMBER:
|
||||
return {
|
||||
type: 'number',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
numberFormat: '',
|
||||
value: '0',
|
||||
minValue: 0,
|
||||
maxValue: 0,
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
case FieldType.RADIO:
|
||||
return {
|
||||
type: 'radio',
|
||||
values: [],
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
case FieldType.CHECKBOX:
|
||||
return {
|
||||
type: 'checkbox',
|
||||
values: [],
|
||||
validationRule: '',
|
||||
validationLength: 0,
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
case FieldType.DROPDOWN:
|
||||
return {
|
||||
type: 'dropdown',
|
||||
values: [],
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported field type: ${fieldType}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSettingsProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
field,
|
||||
fields,
|
||||
onAdvancedSettings,
|
||||
isDocumentPdfLoaded = true,
|
||||
onSave,
|
||||
teamId,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { toast } = useToast();
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const id = params?.id;
|
||||
const isTemplatePage = pathname?.includes('template');
|
||||
const isDocumentPage = pathname?.includes('document');
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
const { data: template } = trpc.template.getTemplateWithDetailsById.useQuery(
|
||||
{
|
||||
id: Number(id),
|
||||
},
|
||||
{
|
||||
enabled: isTemplatePage,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: document } = trpc.document.getDocumentById.useQuery(
|
||||
{
|
||||
id: Number(id),
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
enabled: isDocumentPage,
|
||||
},
|
||||
);
|
||||
|
||||
const doesFieldExist = (!!document || !!template) && field.nativeId !== undefined;
|
||||
|
||||
const { data: fieldData } = trpc.field.getField.useQuery(
|
||||
{
|
||||
fieldId: Number(field.nativeId),
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
enabled: doesFieldExist,
|
||||
},
|
||||
);
|
||||
|
||||
const fieldMeta = fieldData?.fieldMeta;
|
||||
|
||||
const localStorageKey = `field_${field.formId}_${field.type}`;
|
||||
|
||||
const defaultState: FieldMeta = getDefaultState(field.type);
|
||||
|
||||
const [fieldState, setFieldState] = useState(() => {
|
||||
const savedState = localStorage.getItem(localStorageKey);
|
||||
return savedState ? { ...defaultState, ...JSON.parse(savedState) } : defaultState;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldMeta && typeof fieldMeta === 'object') {
|
||||
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
|
||||
|
||||
setFieldState({
|
||||
...defaultState,
|
||||
...parsedFieldMeta,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fieldMeta]);
|
||||
|
||||
const handleFieldChange = (
|
||||
key: FieldMetaKeys,
|
||||
value: string | { checked: boolean; value: string }[] | { value: string }[] | boolean,
|
||||
) => {
|
||||
setFieldState((prevState: FieldMeta) => {
|
||||
if (['characterLimit', 'minValue', 'maxValue', 'validationLength'].includes(key)) {
|
||||
const parsedValue = Number(value);
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
[key]: isNaN(parsedValue) ? undefined : parsedValue,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prevState,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnGoNextClick = () => {
|
||||
try {
|
||||
if (errors.length > 0) {
|
||||
return;
|
||||
} else {
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(fieldState));
|
||||
|
||||
onSave?.(fieldState);
|
||||
onAdvancedSettings?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to save settings.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex h-full flex-col">
|
||||
<DocumentFlowFormContainerHeader title={title} description={description} />
|
||||
<DocumentFlowFormContainerContent>
|
||||
{isDocumentPdfLoaded &&
|
||||
fields.map((field, index) => (
|
||||
<span key={index} className="opacity-75 active:pointer-events-none">
|
||||
<FieldItem key={index} field={field} disabled={true} />
|
||||
</span>
|
||||
))}
|
||||
|
||||
{match(field.type)
|
||||
.with(FieldType.TEXT, () => (
|
||||
<TextFieldAdvancedSettings
|
||||
fieldState={fieldState}
|
||||
handleFieldChange={handleFieldChange}
|
||||
handleErrors={setErrors}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.NUMBER, () => (
|
||||
<NumberFieldAdvancedSettings
|
||||
fieldState={fieldState}
|
||||
handleFieldChange={handleFieldChange}
|
||||
handleErrors={setErrors}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.RADIO, () => (
|
||||
<RadioFieldAdvancedSettings
|
||||
fieldState={fieldState}
|
||||
handleFieldChange={handleFieldChange}
|
||||
handleErrors={setErrors}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.CHECKBOX, () => (
|
||||
<CheckboxFieldAdvancedSettings
|
||||
fieldState={fieldState}
|
||||
handleFieldChange={handleFieldChange}
|
||||
handleErrors={setErrors}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.DROPDOWN, () => (
|
||||
<DropdownFieldAdvancedSettings
|
||||
fieldState={fieldState}
|
||||
handleFieldChange={handleFieldChange}
|
||||
handleErrors={setErrors}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
{errors.map((error, index) => (
|
||||
<li className="text-sm text-red-500" key={index}>
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</DocumentFlowFormContainerContent>
|
||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
||||
<DocumentFlowFormContainerActions
|
||||
goNextLabel="Save"
|
||||
goBackLabel="Cancel"
|
||||
onGoBackClick={onAdvancedSettings}
|
||||
onGoNextClick={handleOnGoNextClick}
|
||||
disableNextStep={errors.length > 0}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FieldAdvancedSettings.displayName = 'FieldAdvancedSettings';
|
||||
@ -1,20 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Trash } from 'lucide-react';
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { Settings2, Trash } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Rnd } from 'react-rnd';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import { useSignerColors } from '../../lib/signer-colors';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Card, CardContent } from '../card';
|
||||
import { CheckboxField } from './advanced-fields/checkbox';
|
||||
import { RadioField } from './advanced-fields/radio';
|
||||
import { FieldIcon } from './field-icon';
|
||||
import type { TDocumentFlowFormSchema } from './types';
|
||||
import { FRIENDLY_FIELD_TYPE } from './types';
|
||||
|
||||
type Field = TDocumentFlowFormSchema['fields'][0];
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
export type FieldItemProps = {
|
||||
field: Field;
|
||||
passive?: boolean;
|
||||
@ -24,17 +38,23 @@ export type FieldItemProps = {
|
||||
onResize?: (_node: HTMLElement) => void;
|
||||
onMove?: (_node: HTMLElement) => void;
|
||||
onRemove?: () => void;
|
||||
onAdvancedSettings?: () => void;
|
||||
recipientIndex?: number;
|
||||
hideRecipients?: boolean;
|
||||
};
|
||||
|
||||
export const FieldItem = ({
|
||||
field,
|
||||
passive,
|
||||
disabled,
|
||||
minHeight: _minHeight,
|
||||
minWidth: _minWidth,
|
||||
minHeight,
|
||||
minWidth,
|
||||
onResize,
|
||||
onMove,
|
||||
onRemove,
|
||||
onAdvancedSettings,
|
||||
recipientIndex = 0,
|
||||
hideRecipients = false,
|
||||
}: FieldItemProps) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
@ -43,6 +63,12 @@ export const FieldItem = ({
|
||||
pageHeight: 0,
|
||||
pageWidth: 0,
|
||||
});
|
||||
const [settingsActive, setSettingsActive] = useState(false);
|
||||
const $el = useRef(null);
|
||||
|
||||
const signerStyles = useSignerColors(recipientIndex);
|
||||
|
||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
|
||||
|
||||
const calculateCoords = useCallback(() => {
|
||||
const $page = document.querySelector<HTMLElement>(
|
||||
@ -89,25 +115,63 @@ export const FieldItem = ({
|
||||
};
|
||||
}, [calculateCoords]);
|
||||
|
||||
const handleClickOutsideField = (event: MouseEvent) => {
|
||||
if (settingsActive && $el.current && !event.composedPath().includes($el.current)) {
|
||||
setSettingsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.body.addEventListener('click', handleClickOutsideField);
|
||||
return () => {
|
||||
document.body.removeEventListener('click', handleClickOutsideField);
|
||||
};
|
||||
}, [settingsActive]);
|
||||
|
||||
const hasFieldMetaValues = (
|
||||
fieldType: string,
|
||||
fieldMeta: TFieldMetaSchema,
|
||||
parser: typeof ZCheckboxFieldMeta | typeof ZRadioFieldMeta,
|
||||
) => {
|
||||
if (field.type !== fieldType || !fieldMeta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedMeta = parser?.parse(fieldMeta);
|
||||
return parsedMeta && parsedMeta.values && parsedMeta.values.length > 0;
|
||||
};
|
||||
|
||||
const checkBoxHasValues = useMemo(
|
||||
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
|
||||
[field.fieldMeta],
|
||||
);
|
||||
const radioHasValues = useMemo(
|
||||
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
|
||||
[field.fieldMeta],
|
||||
);
|
||||
|
||||
const fixedSize = checkBoxHasValues || radioHasValues;
|
||||
|
||||
return createPortal(
|
||||
<Rnd
|
||||
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
||||
className={cn('z-20', {
|
||||
className={cn('group z-20', {
|
||||
'pointer-events-none': passive,
|
||||
'pointer-events-none opacity-75': disabled,
|
||||
'pointer-events-none cursor-not-allowed opacity-75': disabled,
|
||||
'z-10': !active || disabled,
|
||||
})}
|
||||
// minHeight={minHeight}
|
||||
// minWidth={minWidth}
|
||||
minHeight={fixedSize ? '' : minHeight || 'auto'}
|
||||
minWidth={fixedSize ? '' : minWidth || 'auto'}
|
||||
default={{
|
||||
x: coords.pageX,
|
||||
y: coords.pageY,
|
||||
height: coords.pageHeight,
|
||||
width: coords.pageWidth,
|
||||
height: fixedSize ? '' : coords.pageHeight,
|
||||
width: fixedSize ? '' : coords.pageWidth,
|
||||
}}
|
||||
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
|
||||
onDragStart={() => setActive(true)}
|
||||
onResizeStart={() => setActive(true)}
|
||||
enableResizing={!fixedSize}
|
||||
onResizeStop={(_e, _d, ref) => {
|
||||
setActive(false);
|
||||
onResize?.(ref);
|
||||
@ -117,35 +181,69 @@ export const FieldItem = ({
|
||||
onMove?.(d.node);
|
||||
}}
|
||||
>
|
||||
{!disabled && (
|
||||
<button
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground/80 bg-background absolute -right-2 -top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full border"
|
||||
onClick={() => onRemove?.()}
|
||||
onTouchEnd={() => onRemove?.()}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={cn('bg-field-card/80 h-full w-full backdrop-blur-[1px]', {
|
||||
'border-field-card-border': !disabled,
|
||||
'border-field-card-border/80': active,
|
||||
})}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full w-full items-center justify-center bg-white',
|
||||
signerStyles.default.base,
|
||||
signerStyles.default.fieldItem,
|
||||
)}
|
||||
onClick={() => {
|
||||
setSettingsActive((prev) => !prev);
|
||||
}}
|
||||
ref={$el}
|
||||
>
|
||||
<CardContent
|
||||
className={cn(
|
||||
'text-field-card-foreground flex h-full w-full flex-col items-center justify-center p-2',
|
||||
{
|
||||
'text-field-card-foreground/50': disabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{FRIENDLY_FIELD_TYPE[field.type]}
|
||||
{match(field.type)
|
||||
.with('CHECKBOX', () => <CheckboxField field={field} />)
|
||||
.with('RADIO', () => <RadioField field={field} />)
|
||||
.otherwise(() => (
|
||||
<FieldIcon
|
||||
fieldMeta={field.fieldMeta}
|
||||
type={field.type}
|
||||
signerEmail={field.signerEmail}
|
||||
fontCaveatClassName={fontCaveat.className}
|
||||
/>
|
||||
))}
|
||||
|
||||
<p className="w-full truncate text-center text-xs">{field.signerEmail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!hideRecipients && (
|
||||
<div className="absolute -right-6 top-0 z-20 hidden h-full w-6 items-center justify-center group-hover:flex">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-6 flex-col items-center justify-center rounded-r-lg text-[0.625rem] font-bold text-white',
|
||||
signerStyles.default.fieldItemInitials,
|
||||
{
|
||||
'!opacity-50': disabled || passive,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
|
||||
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disabled && settingsActive && (
|
||||
<div className="mt-1 flex justify-center">
|
||||
<div className="dark:bg-background group flex items-center justify-evenly rounded-md border gap-x-1 bg-gray-900 p-0.5">
|
||||
{advancedField && (
|
||||
<button
|
||||
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onAdvancedSettings}
|
||||
onTouchEnd={onAdvancedSettings}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="dark:text-muted-foreground/50 dark:hover:text-muted-foreground dark:hover:bg-foreground/10 rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onRemove}
|
||||
onTouchEnd={onRemove}
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Rnd>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
||||
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { type TCheckboxFieldMeta as CheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
|
||||
import { checkboxValidationLength, checkboxValidationRules } from './constants';
|
||||
|
||||
type CheckboxFieldAdvancedSettingsProps = {
|
||||
fieldState: CheckboxFieldMeta;
|
||||
handleFieldChange: (
|
||||
key: keyof CheckboxFieldMeta,
|
||||
value: string | { checked: boolean; value: string }[] | boolean,
|
||||
) => void;
|
||||
handleErrors: (errors: string[]) => void;
|
||||
};
|
||||
|
||||
export const CheckboxFieldAdvancedSettings = ({
|
||||
fieldState,
|
||||
handleFieldChange,
|
||||
handleErrors,
|
||||
}: CheckboxFieldAdvancedSettingsProps) => {
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
const [values, setValues] = useState(fieldState.values ?? [{ id: 1, checked: false, value: '' }]);
|
||||
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
|
||||
const [required, setRequired] = useState(fieldState.required ?? false);
|
||||
const [validationLength, setValidationLength] = useState(fieldState.validationLength ?? 0);
|
||||
const [validationRule, setValidationRule] = useState(fieldState.validationRule ?? '');
|
||||
|
||||
const handleToggleChange = (field: keyof CheckboxFieldMeta, value: string | boolean) => {
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
const validationRule =
|
||||
field === 'validationRule' ? String(value) : String(fieldState.validationRule);
|
||||
const validationLength =
|
||||
field === 'validationLength' ? Number(value) : Number(fieldState.validationLength);
|
||||
|
||||
setReadOnly(readOnly);
|
||||
setRequired(required);
|
||||
setValidationRule(validationRule);
|
||||
setValidationLength(validationLength);
|
||||
|
||||
const errors = validateCheckboxField(
|
||||
values.map((item) => item.value),
|
||||
{
|
||||
readOnly,
|
||||
required,
|
||||
validationRule,
|
||||
validationLength,
|
||||
},
|
||||
);
|
||||
handleErrors(errors);
|
||||
|
||||
handleFieldChange(field, value);
|
||||
};
|
||||
|
||||
const addValue = () => {
|
||||
const newId = values.length > 0 ? Math.max(...values.map((val) => val.id)) + 1 : 1;
|
||||
setValues([...values, { id: newId, checked: false, value: '' }]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const errors = validateCheckboxField(
|
||||
values.map((item) => item.value),
|
||||
{
|
||||
readOnly,
|
||||
required,
|
||||
validationRule,
|
||||
validationLength,
|
||||
},
|
||||
);
|
||||
handleErrors(errors);
|
||||
handleFieldChange('values', values);
|
||||
}, [values]);
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
if (values.length === 1) return;
|
||||
|
||||
const newValues = [...values];
|
||||
newValues.splice(index, 1);
|
||||
setValues(newValues);
|
||||
handleFieldChange('values', newValues);
|
||||
};
|
||||
|
||||
const handleCheckboxValue = (
|
||||
index: number,
|
||||
property: 'value' | 'checked',
|
||||
newValue: string | boolean,
|
||||
) => {
|
||||
const newValues = [...values];
|
||||
|
||||
if (property === 'checked') {
|
||||
newValues[index].checked = Boolean(newValue);
|
||||
} else if (property === 'value') {
|
||||
newValues[index].value = String(newValue);
|
||||
}
|
||||
|
||||
setValues(newValues);
|
||||
handleFieldChange('values', newValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValues(fieldState.values ?? [{ id: 1, checked: false, value: '' }]);
|
||||
}, [fieldState.values]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-x-4">
|
||||
<div className="flex w-2/3 flex-col">
|
||||
<Label>Validation</Label>
|
||||
<Select
|
||||
value={fieldState.validationRule}
|
||||
onValueChange={(val) => handleToggleChange('validationRule', val)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
|
||||
<SelectValue placeholder="Select at least" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{checkboxValidationRules.map((item, index) => (
|
||||
<SelectItem key={index} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-3 flex w-1/3 flex-col">
|
||||
<Select
|
||||
value={fieldState.validationLength ? String(fieldState.validationLength) : ''}
|
||||
onValueChange={(val) => handleToggleChange('validationLength', val)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
|
||||
<SelectValue placeholder="Pick a number" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{checkboxValidationLength.map((item, index) => (
|
||||
<SelectItem key={index} value={String(item)}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.required}
|
||||
onCheckedChange={(checked) => handleToggleChange('required', checked)}
|
||||
/>
|
||||
<Label>Required field</Label>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.readOnly}
|
||||
onCheckedChange={(checked) => handleToggleChange('readOnly', checked)}
|
||||
/>
|
||||
<Label>Read only</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
|
||||
variant="outline"
|
||||
onClick={() => setShowValidation((prev) => !prev)}
|
||||
>
|
||||
<span className="flex w-full flex-row justify-between">
|
||||
<span className="flex items-center">Checkbox values</span>
|
||||
{showValidation ? <ChevronUp /> : <ChevronDown />}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{showValidation && (
|
||||
<div>
|
||||
{values.map((value, index) => (
|
||||
<div key={index} className="mt-2 flex items-center gap-4">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-documenso border-foreground/30 h-5 w-5"
|
||||
checkClassName="text-white"
|
||||
checked={value.checked}
|
||||
onCheckedChange={(checked) => handleCheckboxValue(index, 'checked', checked)}
|
||||
/>
|
||||
<Input
|
||||
className="w-1/2"
|
||||
value={value.value}
|
||||
onChange={(e) => handleCheckboxValue(index, 'value', e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
|
||||
variant="outline"
|
||||
onClick={addValue}
|
||||
>
|
||||
Add another value
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
export const numberFormatValues = [
|
||||
{
|
||||
label: '123,456,789.00',
|
||||
value: '123,456,789.00',
|
||||
},
|
||||
{
|
||||
label: '123.456.789,00',
|
||||
value: '123.456.789,00',
|
||||
},
|
||||
{
|
||||
label: '123456,789.00',
|
||||
value: '123456,789.00',
|
||||
},
|
||||
];
|
||||
|
||||
export const checkboxValidationRules = ['Select at least', 'Select exactly', 'Select at most'];
|
||||
export const checkboxValidationLength = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
export const checkboxValidationSigns = [
|
||||
{
|
||||
label: 'Select at least',
|
||||
value: '>=',
|
||||
},
|
||||
{
|
||||
label: 'Select exactly',
|
||||
value: '=',
|
||||
},
|
||||
{
|
||||
label: 'Select at most',
|
||||
value: '<=',
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
||||
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
|
||||
type DropdownFieldAdvancedSettingsProps = {
|
||||
fieldState: DropdownFieldMeta;
|
||||
handleFieldChange: (
|
||||
key: keyof DropdownFieldMeta,
|
||||
value: string | { value: string }[] | boolean,
|
||||
) => void;
|
||||
handleErrors: (errors: string[]) => void;
|
||||
};
|
||||
|
||||
export const DropdownFieldAdvancedSettings = ({
|
||||
fieldState,
|
||||
handleFieldChange,
|
||||
handleErrors,
|
||||
}: DropdownFieldAdvancedSettingsProps) => {
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
const [values, setValues] = useState(fieldState.values ?? [{ value: 'Option 1' }]);
|
||||
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
|
||||
const [required, setRequired] = useState(fieldState.required ?? false);
|
||||
const [defaultValue, setDefaultValue] = useState(fieldState.defaultValue ?? 'Option 1');
|
||||
|
||||
const addValue = () => {
|
||||
setValues([...values, { value: 'New option' }]);
|
||||
handleFieldChange('values', [...values, { value: 'New option' }]);
|
||||
};
|
||||
|
||||
const removeValue = (index: number) => {
|
||||
if (values.length === 1) return;
|
||||
|
||||
const newValues = [...values];
|
||||
newValues.splice(index, 1);
|
||||
setValues(newValues);
|
||||
handleFieldChange('values', newValues);
|
||||
};
|
||||
|
||||
const handleToggleChange = (field: keyof DropdownFieldMeta, value: string | boolean) => {
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
setReadOnly(readOnly);
|
||||
setRequired(required);
|
||||
|
||||
const errors = validateDropdownField(undefined, { readOnly, required, values });
|
||||
handleErrors(errors);
|
||||
|
||||
handleFieldChange(field, value);
|
||||
};
|
||||
|
||||
const handleValueChange = (index: number, newValue: string) => {
|
||||
const updatedValues = [...values];
|
||||
updatedValues[index].value = newValue;
|
||||
setValues(updatedValues);
|
||||
handleFieldChange('values', updatedValues);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const errors = validateDropdownField(undefined, { readOnly, required, values });
|
||||
handleErrors(errors);
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
setValues(fieldState.values ?? [{ value: 'Option 1' }]);
|
||||
}, [fieldState.values]);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultValue(fieldState.defaultValue ?? 'Option 1');
|
||||
}, [fieldState.defaultValue]);
|
||||
|
||||
return (
|
||||
<div className="text-dark flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Select default option</Label>
|
||||
<Select
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={(val) => {
|
||||
setDefaultValue(val);
|
||||
handleFieldChange('defaultValue', val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
|
||||
<SelectValue defaultValue={defaultValue} placeholder="-- Select --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{values.map((item, index) => (
|
||||
<SelectItem
|
||||
key={index}
|
||||
value={item.value && item.value.length > 0 ? item.value.toString() : String(index)}
|
||||
>
|
||||
{item.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.required}
|
||||
onCheckedChange={(checked) => handleToggleChange('required', checked)}
|
||||
/>
|
||||
<Label>Required field</Label>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.readOnly}
|
||||
onCheckedChange={(checked) => handleToggleChange('readOnly', checked)}
|
||||
/>
|
||||
<Label>Read only</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
|
||||
variant="outline"
|
||||
onClick={() => setShowValidation((prev) => !prev)}
|
||||
>
|
||||
<span className="flex w-full flex-row justify-between">
|
||||
<span className="flex items-center">Dropdown options</span>
|
||||
{showValidation ? <ChevronUp /> : <ChevronDown />}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{showValidation && (
|
||||
<div>
|
||||
{values.map((value, index) => (
|
||||
<div key={index} className="mt-2 flex items-center gap-4">
|
||||
<Input
|
||||
className="w-1/2"
|
||||
value={value.value}
|
||||
onChange={(e) => handleValueChange(index, e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => removeValue(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
|
||||
variant="outline"
|
||||
onClick={addValue}
|
||||
>
|
||||
Add another option
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
import { type TNumberFieldMeta as NumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
|
||||
import { numberFormatValues } from './constants';
|
||||
|
||||
type NumberFieldAdvancedSettingsProps = {
|
||||
fieldState: NumberFieldMeta;
|
||||
handleFieldChange: (key: keyof NumberFieldMeta, value: string | boolean) => void;
|
||||
handleErrors: (errors: string[]) => void;
|
||||
};
|
||||
|
||||
export const NumberFieldAdvancedSettings = ({
|
||||
fieldState,
|
||||
handleFieldChange,
|
||||
handleErrors,
|
||||
}: NumberFieldAdvancedSettingsProps) => {
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
|
||||
const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => {
|
||||
const userValue = field === 'value' ? value : fieldState.value || 0;
|
||||
const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue || 0);
|
||||
const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue || 0);
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
const numberFormat = field === 'numberFormat' ? String(value) : fieldState.numberFormat || '';
|
||||
|
||||
const valueErrors = validateNumberField(String(userValue), {
|
||||
minValue: userMinValue,
|
||||
maxValue: userMaxValue,
|
||||
readOnly,
|
||||
required,
|
||||
numberFormat,
|
||||
});
|
||||
handleErrors(valueErrors);
|
||||
|
||||
handleFieldChange(field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
id="label"
|
||||
className="bg-background mt-2"
|
||||
placeholder="Label"
|
||||
value={fieldState.label}
|
||||
onChange={(e) => handleFieldChange('label', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mt-4">Placeholder</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
className="bg-background mt-2"
|
||||
placeholder="Placeholder"
|
||||
value={fieldState.placeholder}
|
||||
onChange={(e) => handleFieldChange('placeholder', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mt-4">Value</Label>
|
||||
<Input
|
||||
id="value"
|
||||
className="bg-background mt-2"
|
||||
placeholder="Value"
|
||||
value={fieldState.value}
|
||||
onChange={(e) => handleInput('value', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Number format</Label>
|
||||
<Select
|
||||
value={fieldState.numberFormat}
|
||||
onValueChange={(val) => handleInput('numberFormat', val)}
|
||||
>
|
||||
<SelectTrigger className="text-muted-foreground bg-background mt-2 w-full">
|
||||
<SelectValue placeholder="Field format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{numberFormatValues.map((item, index) => (
|
||||
<SelectItem key={index} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.required}
|
||||
onCheckedChange={(checked) => handleInput('required', checked)}
|
||||
/>
|
||||
<Label>Required field</Label>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.readOnly}
|
||||
onCheckedChange={(checked) => handleInput('readOnly', checked)}
|
||||
/>
|
||||
<Label>Read only</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
|
||||
variant="outline"
|
||||
onClick={() => setShowValidation((prev) => !prev)}
|
||||
>
|
||||
<span className="flex w-full flex-row justify-between">
|
||||
<span className="flex items-center">Validation</span>
|
||||
{showValidation ? <ChevronUp /> : <ChevronDown />}
|
||||
</span>
|
||||
</Button>
|
||||
{showValidation && (
|
||||
<div className="mb-4 flex flex-row gap-x-4">
|
||||
<div className="flex flex-col">
|
||||
<Label className="mt-4">Min</Label>
|
||||
<Input
|
||||
id="minValue"
|
||||
className="bg-background mt-2"
|
||||
placeholder="E.g. 0"
|
||||
value={fieldState.minValue}
|
||||
onChange={(e) => handleInput('minValue', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Label className="mt-4">Max</Label>
|
||||
<Input
|
||||
id="maxValue"
|
||||
className="bg-background mt-2"
|
||||
placeholder="E.g. 100"
|
||||
value={fieldState.maxValue}
|
||||
onChange={(e) => handleInput('maxValue', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
||||
|
||||
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
|
||||
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
|
||||
export type RadioFieldAdvancedSettingsProps = {
|
||||
fieldState: RadioFieldMeta;
|
||||
handleFieldChange: (
|
||||
key: keyof RadioFieldMeta,
|
||||
value: string | { checked: boolean; value: string }[] | boolean,
|
||||
) => void;
|
||||
handleErrors: (errors: string[]) => void;
|
||||
};
|
||||
|
||||
export const RadioFieldAdvancedSettings = ({
|
||||
fieldState,
|
||||
handleFieldChange,
|
||||
handleErrors,
|
||||
}: RadioFieldAdvancedSettingsProps) => {
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
const [values, setValues] = useState(
|
||||
fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }],
|
||||
);
|
||||
const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false);
|
||||
const [required, setRequired] = useState(fieldState.required ?? false);
|
||||
|
||||
const addValue = () => {
|
||||
const newId = values.length > 0 ? Math.max(...values.map((val) => val.id)) + 1 : 1;
|
||||
const newValue = { id: newId, checked: false, value: '' };
|
||||
const updatedValues = [...values, newValue];
|
||||
|
||||
setValues(updatedValues);
|
||||
handleFieldChange('values', updatedValues);
|
||||
};
|
||||
|
||||
const removeValue = (id: number) => {
|
||||
if (values.length === 1) return;
|
||||
|
||||
const newValues = values.filter((val) => val.id !== id);
|
||||
setValues(newValues);
|
||||
handleFieldChange('values', newValues);
|
||||
};
|
||||
|
||||
const handleCheckedChange = (checked: boolean, id: number) => {
|
||||
const newValues = values.map((val) => {
|
||||
if (val.id === id) {
|
||||
return { ...val, checked: Boolean(checked) };
|
||||
} else {
|
||||
return { ...val, checked: false };
|
||||
}
|
||||
});
|
||||
|
||||
setValues(newValues);
|
||||
handleFieldChange('values', newValues);
|
||||
};
|
||||
|
||||
const handleToggleChange = (field: keyof RadioFieldMeta, value: string | boolean) => {
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
setReadOnly(readOnly);
|
||||
setRequired(required);
|
||||
|
||||
const errors = validateRadioField(String(value), { readOnly, required, values });
|
||||
handleErrors(errors);
|
||||
|
||||
handleFieldChange(field, value);
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string, id: number) => {
|
||||
const newValues = values.map((val) => {
|
||||
if (val.id === id) {
|
||||
return { ...val, value };
|
||||
}
|
||||
return val;
|
||||
});
|
||||
|
||||
setValues(newValues);
|
||||
handleFieldChange('values', newValues);
|
||||
|
||||
return newValues;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValues(fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }]);
|
||||
}, [fieldState.values]);
|
||||
|
||||
useEffect(() => {
|
||||
const errors = validateRadioField(undefined, { readOnly, required, values });
|
||||
handleErrors(errors);
|
||||
}, [values]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.required}
|
||||
onCheckedChange={(checked) => handleToggleChange('required', checked)}
|
||||
/>
|
||||
<Label>Required field</Label>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.readOnly}
|
||||
onCheckedChange={(checked) => handleToggleChange('readOnly', checked)}
|
||||
/>
|
||||
<Label>Read only</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 mt-2 border"
|
||||
variant="outline"
|
||||
onClick={() => setShowValidation((prev) => !prev)}
|
||||
>
|
||||
<span className="flex w-full flex-row justify-between">
|
||||
<span className="flex items-center">Radio values</span>
|
||||
{showValidation ? <ChevronUp /> : <ChevronDown />}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{showValidation && (
|
||||
<div>
|
||||
{values.map((value) => (
|
||||
<div key={value.id} className="mt-2 flex items-center gap-4">
|
||||
<Checkbox
|
||||
className="data-[state=checked]:bg-documenso border-foreground/30 data-[state=checked]:ring-documenso dark:data-[state=checked]:ring-offset-background h-5 w-5 rounded-full data-[state=checked]:ring-1 data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-white"
|
||||
checked={value.checked}
|
||||
onCheckedChange={(checked) => handleCheckedChange(Boolean(checked), value.id)}
|
||||
/>
|
||||
<Input
|
||||
className="w-1/2"
|
||||
value={value.value}
|
||||
onChange={(e) => handleInputChange(e.target.value, value.id)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50 dark:text-white"
|
||||
onClick={() => removeValue(value.id)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="bg-foreground/10 hover:bg-foreground/5 border-foreground/10 ml-9 mt-4 border"
|
||||
variant="outline"
|
||||
onClick={addValue}
|
||||
>
|
||||
Add another value
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
type TextFieldAdvancedSettingsProps = {
|
||||
fieldState: TextFieldMeta;
|
||||
handleFieldChange: (key: keyof TextFieldMeta, value: string | boolean) => void;
|
||||
handleErrors: (errors: string[]) => void;
|
||||
};
|
||||
|
||||
export const TextFieldAdvancedSettings = ({
|
||||
fieldState,
|
||||
handleFieldChange,
|
||||
handleErrors,
|
||||
}: TextFieldAdvancedSettingsProps) => {
|
||||
const handleInput = (field: keyof TextFieldMeta, value: string | boolean) => {
|
||||
const text = field === 'text' ? String(value) : fieldState.text || '';
|
||||
const limit =
|
||||
field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit || 0);
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
|
||||
const textErrors = validateTextField(text, {
|
||||
characterLimit: Number(limit),
|
||||
readOnly,
|
||||
required,
|
||||
});
|
||||
|
||||
handleErrors(textErrors);
|
||||
handleFieldChange(field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
id="label"
|
||||
className="bg-background mt-2"
|
||||
placeholder="Field label"
|
||||
value={fieldState.label}
|
||||
onChange={(e) => handleFieldChange('label', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mt-4">Placeholder</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
className="bg-background mt-2"
|
||||
placeholder="Field placeholder"
|
||||
value={fieldState.placeholder}
|
||||
onChange={(e) => handleFieldChange('placeholder', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mt-4">Add text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
className="bg-background mt-2"
|
||||
placeholder="Add text to the field"
|
||||
value={fieldState.text}
|
||||
onChange={(e) => handleInput('text', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Character Limit</Label>
|
||||
<Input
|
||||
id="characterLimit"
|
||||
type="number"
|
||||
min={0}
|
||||
className="bg-background mt-2"
|
||||
placeholder="Field character limit"
|
||||
value={fieldState.characterLimit}
|
||||
onChange={(e) => handleInput('characterLimit', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.required}
|
||||
onCheckedChange={(checked) => handleInput('required', checked)}
|
||||
/>
|
||||
<Label>Required field</Label>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
className="bg-background"
|
||||
checked={fieldState.readOnly}
|
||||
onCheckedChange={(checked) => handleInput('readOnly', checked)}
|
||||
/>
|
||||
<Label>Read only</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
|
||||
@ -18,6 +21,14 @@ import { FieldType } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { FieldRootContainer } from '../../components/field/field';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
export type SinglePlayerModeFieldContainerProps = {
|
||||
field: FieldWithSignature;
|
||||
@ -110,9 +121,11 @@ export function SinglePlayerModeSignatureField({
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onClick?.()}
|
||||
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full duration-200"
|
||||
className={
|
||||
cn('group-hover:text-primary absolute inset-0 h-full w-full duration-200', fontCaveat.className)
|
||||
}
|
||||
>
|
||||
Signature
|
||||
<span className="text-muted-foreground truncate text-3xl font-medium ">Signature</span>
|
||||
</button>
|
||||
)}
|
||||
</SinglePlayerModeFieldCardContainer>
|
||||
@ -152,32 +165,82 @@ export function SinglePlayerModeCustomTextField({
|
||||
const fontSize = maxFontSize * scalingFactor;
|
||||
|
||||
return (
|
||||
<SinglePlayerModeFieldCardContainer field={field}>
|
||||
{field.inserted ? (
|
||||
<p
|
||||
ref={$paragraphEl}
|
||||
style={{
|
||||
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||
fontFamily: `var(${fontVariable})`,
|
||||
}}
|
||||
>
|
||||
{field.customText}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onClick?.()}
|
||||
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full text-lg duration-200"
|
||||
>
|
||||
{match(field.type)
|
||||
.with(FieldType.DATE, () => 'Date')
|
||||
.with(FieldType.NAME, () => 'Name')
|
||||
.with(FieldType.EMAIL, () => 'Email')
|
||||
.with(FieldType.TEXT, () => 'Text')
|
||||
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
|
||||
.otherwise(() => '')}
|
||||
</button>
|
||||
)}
|
||||
</SinglePlayerModeFieldCardContainer>
|
||||
<>
|
||||
<SinglePlayerModeFieldCardContainer field={field}>
|
||||
{field.inserted ? (
|
||||
<p
|
||||
ref={$paragraphEl}
|
||||
style={{
|
||||
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||
fontFamily: `var(${fontVariable})`,
|
||||
}}
|
||||
>
|
||||
{field.customText ??
|
||||
(field.fieldMeta && typeof field.fieldMeta === 'object' && 'label' in field.fieldMeta
|
||||
? field.fieldMeta.label
|
||||
: '')}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onClick?.()}
|
||||
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full text-lg duration-200"
|
||||
>
|
||||
{match(field.type)
|
||||
.with(FieldType.DATE, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<CalendarDays /> Date
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<User /> Name
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<Mail /> Email
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.TEXT, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<Type /> Text
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => (
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground w-full truncate text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
Signature
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.NUMBER, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<Hash /> Number
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.CHECKBOX, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<CheckSquare /> Checkbox
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.RADIO, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<Disc /> Radio
|
||||
</div>
|
||||
))
|
||||
.with(FieldType.DROPDOWN, () => (
|
||||
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||
<ChevronDown /> Dropdown
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => '')}
|
||||
</button>
|
||||
)}
|
||||
</SinglePlayerModeFieldCardContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZDocumentFlowFormSchema = z.object({
|
||||
@ -30,6 +31,7 @@ export const ZDocumentFlowFormSchema = z.object({
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
|
||||
@ -48,6 +50,10 @@ export const FRIENDLY_FIELD_TYPE: Record<FieldType, string> = {
|
||||
[FieldType.DATE]: 'Date',
|
||||
[FieldType.EMAIL]: 'Email',
|
||||
[FieldType.NAME]: 'Name',
|
||||
[FieldType.NUMBER]: 'Number',
|
||||
[FieldType.RADIO]: 'Radio',
|
||||
[FieldType.CHECKBOX]: 'Checkbox',
|
||||
[FieldType.DROPDOWN]: 'Select',
|
||||
};
|
||||
|
||||
export interface DocumentFlowStep {
|
||||
|
||||
@ -4,13 +4,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Disc,
|
||||
Hash,
|
||||
Mail,
|
||||
Type,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
@ -28,6 +42,7 @@ import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
||||
@ -35,8 +50,10 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||
import type { FieldFormType } from '../document-flow/add-fields';
|
||||
import { FieldAdvancedSettings } from '../document-flow/field-item-advanced-settings';
|
||||
import { useStep } from '../stepper';
|
||||
// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
@ -46,11 +63,8 @@ const fontCaveat = Caveat({
|
||||
variable: '--font-caveat',
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
||||
const DEFAULT_WIDTH_PERCENT = 15;
|
||||
|
||||
const MIN_HEIGHT_PX = 60;
|
||||
const MIN_WIDTH_PX = 200;
|
||||
const MIN_HEIGHT_PX = 40;
|
||||
const MIN_WIDTH_PX = 140;
|
||||
|
||||
export type AddTemplateFieldsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
@ -58,6 +72,7 @@ export type AddTemplateFieldsFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const AddTemplateFieldsFormPartial = ({
|
||||
@ -66,15 +81,19 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
recipients,
|
||||
fields,
|
||||
onSubmit,
|
||||
teamId,
|
||||
}: AddTemplateFieldsFormProps) => {
|
||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [currentField, setCurrentField] = useState<FieldFormType>();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
getValues,
|
||||
} = useForm<TAddTemplateFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
fields: fields.map((field) => ({
|
||||
@ -97,6 +116,25 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
|
||||
const onFormSubmit = handleSubmit(onSubmit);
|
||||
|
||||
const handleSavedFieldSettings = (fieldState: FieldMeta) => {
|
||||
const initialValues = getValues();
|
||||
|
||||
const updatedFields = initialValues.fields.map((field) => {
|
||||
if (field.formId === currentField?.formId) {
|
||||
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldState);
|
||||
|
||||
return {
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
};
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
setValue('fields', updatedFields);
|
||||
};
|
||||
|
||||
const {
|
||||
append,
|
||||
remove,
|
||||
@ -111,6 +149,11 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
|
||||
const selectedSignerStyles = useSignerColors(
|
||||
selectedSignerIndex === -1 ? 0 : selectedSignerIndex,
|
||||
);
|
||||
|
||||
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
@ -189,6 +232,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
signerEmail: selectedSigner.email,
|
||||
signerId: selectedSigner.id,
|
||||
signerToken: selectedSigner.token ?? '',
|
||||
fieldMeta: undefined,
|
||||
});
|
||||
|
||||
setIsFieldWithinBounds(false);
|
||||
@ -270,11 +314,9 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, width } = $page.getBoundingClientRect();
|
||||
|
||||
fieldBounds.current = {
|
||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
||||
height: Math.max(MIN_HEIGHT_PX),
|
||||
width: Math.max(MIN_WIDTH_PX),
|
||||
};
|
||||
});
|
||||
|
||||
@ -314,265 +356,428 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
const handleAdvancedSettings = () => {
|
||||
setShowAdvancedSettings((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
{selectedField && (
|
||||
<Card
|
||||
className={cn(
|
||||
'bg-background pointer-events-none fixed z-50 cursor-pointer transition-opacity',
|
||||
{
|
||||
'border-primary': isFieldWithinBounds,
|
||||
'opacity-50': !isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<FieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail}
|
||||
minHeight={fieldBounds.current.height}
|
||||
minWidth={fieldBounds.current.width}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
|
||||
{showAdvancedSettings && currentField ? (
|
||||
<FieldAdvancedSettings
|
||||
title="Advanced settings"
|
||||
description={`Configure the ${FRIENDLY_FIELD_TYPE[currentField.type]} field`}
|
||||
field={currentField}
|
||||
fields={localFields}
|
||||
onAdvancedSettings={handleAdvancedSettings}
|
||||
onSave={handleSavedFieldSettings}
|
||||
teamId={teamId}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={documentFlow.title}
|
||||
description={documentFlow.description}
|
||||
/>
|
||||
<DocumentFlowFormContainerContent>
|
||||
<div className="flex flex-col">
|
||||
{selectedField && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
|
||||
selectedSignerStyles.default.base,
|
||||
{
|
||||
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: coords.y,
|
||||
left: coords.x,
|
||||
height: fieldBounds.current.height,
|
||||
width: fieldBounds.current.width,
|
||||
}}
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
{localFields.map((field, index) => {
|
||||
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
return (
|
||||
<FieldItem
|
||||
key={index}
|
||||
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||
field={field}
|
||||
disabled={selectedSigner?.email !== field.signerEmail}
|
||||
minHeight={fieldBounds.current.height}
|
||||
minWidth={fieldBounds.current.width}
|
||||
passive={isFieldWithinBounds && !!selectedField}
|
||||
onResize={(options) => onFieldResize(options, index)}
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
}}
|
||||
hideRecipients={hideRecipients}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput />
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
No recipient matching this description was found.
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||
</div>
|
||||
|
||||
{recipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
No recipients with this role
|
||||
</div>
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
|
||||
selectedSignerStyles.default.base,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{recipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1')}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
{!selectedSigner?.email && (
|
||||
<span className="gradie flex-1 truncate text-left">
|
||||
{selectedSigner?.email}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedSigner?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
No recipient matching this description was found.
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
No recipients with this role
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleRecipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).default.comboxBoxItem,
|
||||
)}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner}
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||
<fieldset className="my-2 grid grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.name || 'Signature'}
|
||||
</p>
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
Signature
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner}
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
{'Email'}
|
||||
</p>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Email
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner}
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
{'Name'}
|
||||
</p>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Name
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner}
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
{'Date'}
|
||||
</p>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Date
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.TEXT)}
|
||||
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
||||
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
||||
>
|
||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.TEXT)}
|
||||
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
||||
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-xl font-medium',
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
{'Text'}
|
||||
</p>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Type className="h-4 w-4" />
|
||||
Text
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.NUMBER)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NUMBER)}
|
||||
data-selected={selectedField === FieldType.NUMBER ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Hash className="h-4 w-4" />
|
||||
Number
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.RADIO)}
|
||||
onMouseDown={() => setSelectedField(FieldType.RADIO)}
|
||||
data-selected={selectedField === FieldType.RADIO ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<Disc className="h-4 w-4" />
|
||||
Radio
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.CHECKBOX)}
|
||||
onMouseDown={() => setSelectedField(FieldType.CHECKBOX)}
|
||||
data-selected={selectedField === FieldType.CHECKBOX ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Checkbox
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
onClick={() => setSelectedField(FieldType.DROPDOWN)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DROPDOWN)}
|
||||
data-selected={selectedField === FieldType.DROPDOWN ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex h-full w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
// selectedSignerStyles.borderClass,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Dropdown
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={documentFlow.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
goNextLabel="Save Template"
|
||||
onGoBackClick={() => {
|
||||
previousStep();
|
||||
remove();
|
||||
}}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
goNextLabel="Save Template"
|
||||
onGoBackClick={() => {
|
||||
previousStep();
|
||||
remove();
|
||||
}}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
@ -16,6 +17,7 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
||||
pageY: z.number().min(0),
|
||||
pageWidth: z.number().min(0),
|
||||
pageHeight: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '../document-flow/document-flow-root';
|
||||
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||
@ -168,6 +169,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={documentFlow.title}
|
||||
description={documentFlow.description}
|
||||
/>
|
||||
<DocumentFlowFormContainerContent>
|
||||
{isDocumentPdfLoaded &&
|
||||
fields.map((field, index) => (
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '../document-flow/document-flow-root';
|
||||
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||
@ -79,6 +80,7 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
resolver: zodResolver(ZAddTemplateSettingsFormSchema),
|
||||
defaultValues: {
|
||||
title: template.title,
|
||||
externalId: template.externalId || undefined,
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
|
||||
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
|
||||
meta: {
|
||||
@ -103,6 +105,11 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader
|
||||
title={documentFlow.title}
|
||||
description={documentFlow.description}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
{isDocumentPdfLoaded &&
|
||||
fields.map((field, index) => (
|
||||
@ -223,6 +230,34 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
|
||||
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed">
|
||||
<div className="flex flex-col space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
External ID{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
Add an external ID to the template. This can be used to identify in
|
||||
external systems.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.dateFormat"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user