chore: add more field types (#1141)
Adds a number of new field types and capabilities to existing fields. A massive change with far too many moving pieces to document in a single commit.
This commit is contained in:
@@ -98,6 +98,7 @@ export const SinglePlayerClient = () => {
|
|||||||
height: new Prisma.Decimal(field.pageHeight),
|
height: new Prisma.Decimal(field.pageHeight),
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta ?? {},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -131,7 +132,9 @@ export const SinglePlayerClient = () => {
|
|||||||
positionY: field.positionY.toNumber(),
|
positionY: field.positionY.toNumber(),
|
||||||
width: field.width.toNumber(),
|
width: field.width.toNumber(),
|
||||||
height: field.height.toNumber(),
|
height: field.height.toNumber(),
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
})),
|
})),
|
||||||
|
fieldMeta: { type: undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('Marketing: SPM - Document signed', {
|
analytics.capture('Marketing: SPM - Document signed', {
|
||||||
|
|||||||
@@ -232,6 +232,14 @@ export const EditDocumentForm = ({
|
|||||||
fields: data.fields,
|
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 is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
@@ -241,7 +249,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while adding signers.',
|
description: 'An error occurred while adding the fields.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -351,6 +359,7 @@ export const EditDocumentForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const ResendDocumentActionItem = ({
|
|||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<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"
|
checkClassName="text-white"
|
||||||
value={recipient.id}
|
value={recipient.id}
|
||||||
checked={value.includes(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 { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import {
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
DocumentFlowFormContainer,
|
|
||||||
DocumentFlowFormContainerHeader,
|
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
@@ -184,6 +181,14 @@ export const EditTemplateForm = ({
|
|||||||
fields: data.fields,
|
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({
|
toast({
|
||||||
title: 'Template saved',
|
title: 'Template saved',
|
||||||
description: 'Your templates has been saved successfully.',
|
description: 'Your templates has been saved successfully.',
|
||||||
@@ -232,11 +237,6 @@ export const EditTemplateForm = ({
|
|||||||
className="lg:h-[calc(100vh-6rem)]"
|
className="lg:h-[calc(100vh-6rem)]"
|
||||||
onSubmit={(e) => e.preventDefault()}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DocumentFlowFormContainerHeader
|
|
||||||
title={currentDocumentFlow.title}
|
|
||||||
description={currentDocumentFlow.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stepper
|
<Stepper
|
||||||
currentStep={currentDocumentFlow.stepIndex}
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||||
@@ -269,6 +269,7 @@ export const EditTemplateForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
teamId={team?.id}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { match } from 'ts-pattern';
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
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 { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
|
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
|
||||||
import { FieldType } 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 { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
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 { 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 { EmailField } from '~/app/(signing)/sign/[token]/email-field';
|
||||||
import { NameField } from '~/app/(signing)/sign/[token]/name-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 { 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 { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
|
||||||
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||||
@@ -200,15 +211,96 @@ export const SignDirectTemplateForm = ({
|
|||||||
onUnsignField={onUnsignField}
|
onUnsignField={onUnsignField}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.TEXT, () => (
|
.with(FieldType.TEXT, () => {
|
||||||
|
const parsedFieldMeta = field.fieldMeta
|
||||||
|
? ZTextFieldMeta.parse(field.fieldMeta)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
field={{
|
||||||
|
...field,
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
|
}}
|
||||||
recipient={directRecipient}
|
recipient={directRecipient}
|
||||||
onSignField={onSignField}
|
onSignField={onSignField}
|
||||||
onUnsignField={onUnsignField}
|
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),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</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 && (
|
{!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 && (
|
{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>
|
</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 && (
|
{!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>
|
</SigningFieldContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,10 +163,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!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}>
|
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||||
<DialogContent>
|
<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' && (
|
{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
|
Signature
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -199,12 +199,12 @@ export const SignatureField = ({
|
|||||||
<img
|
<img
|
||||||
src={signature.signatureImageAsBase64}
|
src={signature.signatureImageAsBase64}
|
||||||
alt={`Signature for ${recipient.name}`}
|
alt={`Signature for ${recipient.name}`}
|
||||||
className="h-full w-full object-contain dark:invert"
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === 'signed-text' && (
|
{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 */}
|
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
|
||||||
{signature?.typedSignature}
|
{signature?.typedSignature}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
@@ -34,8 +38,8 @@ export type SignatureFieldProps = {
|
|||||||
* The auth values will be passed in if available.
|
* The auth values will be passed in if available.
|
||||||
*/
|
*/
|
||||||
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
onRemove?: () => Promise<void> | void;
|
onRemove?: (fieldType?: string) => Promise<void> | void;
|
||||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox';
|
||||||
tooltipText?: string | null;
|
tooltipText?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +55,9 @@ export const SigningFieldContainer = ({
|
|||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
|
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
|
||||||
|
const readOnlyField = parsedFieldMeta?.readOnly || false;
|
||||||
|
|
||||||
const handleInsertField = async () => {
|
const handleInsertField = async () => {
|
||||||
if (field.inserted || !onSign) {
|
if (field.inserted || !onSign) {
|
||||||
return;
|
return;
|
||||||
@@ -102,21 +109,38 @@ export const SigningFieldContainer = ({
|
|||||||
await onRemove?.();
|
await onRemove?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClearCheckBoxValues = async (fieldType?: string) => {
|
||||||
|
if (!field.inserted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onRemove?.(fieldType);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn(type === 'Checkbox' ? 'group' : '')}>
|
||||||
<FieldRootContainer field={field}>
|
<FieldRootContainer field={field}>
|
||||||
{!field.inserted && !loading && (
|
{!field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute inset-0 z-10 h-full w-full"
|
className="absolute inset-0 z-10 h-full w-full rounded-md border"
|
||||||
onClick={async () => handleInsertField()}
|
onClick={async () => handleInsertField()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'Date' && field.inserted && !loading && (
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'Date' && field.inserted && !loading && !readOnlyField && (
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<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"
|
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}
|
onClick={onRemoveSignedFieldClick}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@@ -127,9 +151,20 @@ export const SigningFieldContainer = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type !== 'Date' && field.inserted && !loading && (
|
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
|
||||||
<button
|
<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"
|
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}
|
onClick={onRemoveSignedFieldClick}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@@ -138,5 +173,6 @@ export const SigningFieldContainer = ({
|
|||||||
|
|
||||||
{children}
|
{children}
|
||||||
</FieldRootContainer>
|
</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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
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 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 { CompletedField } from '@documenso/lib/types/fields';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldType, RecipientRole } 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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
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 { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { CheckboxField } from './checkbox-field';
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
|
import { DropdownField } from './dropdown-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
import { NameField } from './name-field';
|
import { NameField } from './name-field';
|
||||||
|
import { NumberField } from './number-field';
|
||||||
|
import { RadioField } from './radio-field';
|
||||||
import { SignatureField } from './signature-field';
|
import { SignatureField } from './signature-field';
|
||||||
import { TextField } from './text-field';
|
import { TextField } from './text-field';
|
||||||
|
|
||||||
@@ -101,9 +113,41 @@ export const SigningPageView = ({
|
|||||||
.with(FieldType.EMAIL, () => (
|
.with(FieldType.EMAIL, () => (
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
.with(FieldType.TEXT, () => (
|
.with(FieldType.TEXT, () => {
|
||||||
<TextField key={field.id} field={field} recipient={recipient} />
|
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),
|
.otherwise(() => null),
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
|
|||||||
@@ -4,29 +4,31 @@ import { useEffect, useState, useTransition } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
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 { 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 { trpc } from '@documenso/trpc/react';
|
||||||
import type {
|
import type {
|
||||||
TRemovedSignedFieldWithTokenMutationSchema,
|
TRemovedSignedFieldWithTokenMutationSchema,
|
||||||
TSignFieldWithTokenMutationSchema,
|
TSignFieldWithTokenMutationSchema,
|
||||||
} from '@documenso/trpc/server/field-router/schema';
|
} from '@documenso/trpc/server/field-router/schema';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
export type TextFieldProps = {
|
export type TextFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignatureAndFieldMeta;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
@@ -34,9 +36,16 @@ export type TextFieldProps = {
|
|||||||
|
|
||||||
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
|
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
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 { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -49,21 +58,52 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
|||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
} = 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 isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
const shouldAutoSignField =
|
||||||
|
(!field.inserted && parsedFieldMeta?.text) ||
|
||||||
|
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
|
||||||
|
|
||||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||||
const [localText, setLocalCustomText] = useState('');
|
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCustomTextModal) {
|
if (!showCustomTextModal) {
|
||||||
setLocalCustomText('');
|
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||||
|
setErrors(initialErrors);
|
||||||
}
|
}
|
||||||
}, [showCustomTextModal]);
|
}, [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.
|
* When the user clicks the sign button in the dialog where they enter the text field.
|
||||||
*/
|
*/
|
||||||
const onDialogSignClick = () => {
|
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);
|
setShowCustomTextModal(false);
|
||||||
|
|
||||||
void executeActionAuthProcedure({
|
void executeActionAuthProcedure({
|
||||||
@@ -73,17 +113,22 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPreSign = () => {
|
const onPreSign = () => {
|
||||||
if (!localText) {
|
|
||||||
setShowCustomTextModal(true);
|
setShowCustomTextModal(true);
|
||||||
return false;
|
|
||||||
|
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) => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
if (!localText) {
|
if (!localText || userInputHasErrors) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +181,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
|||||||
|
|
||||||
await removeSignedFieldWithToken(payload);
|
await removeSignedFieldWithToken(payload);
|
||||||
|
|
||||||
|
setLocalCustomText(parsedFieldMeta?.text ?? '');
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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 (
|
return (
|
||||||
<SigningFieldContainer
|
<SigningFieldContainer
|
||||||
field={field}
|
field={field}
|
||||||
@@ -163,27 +238,69 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!field.inserted && (
|
{!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}>
|
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogTitle>
|
<DialogTitle>{parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add Text'}</DialogTitle>
|
||||||
Enter your Text <span className="text-muted-foreground">({recipient.email})</span>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="">
|
<div>
|
||||||
<Label htmlFor="custom-text">Custom Text</Label>
|
<Textarea
|
||||||
|
|
||||||
<Input
|
|
||||||
id="custom-text"
|
id="custom-text"
|
||||||
className="border-border mt-2 w-full rounded-md border"
|
placeholder={parsedFieldMeta?.placeholder ?? 'Enter your text here'}
|
||||||
onChange={(e) => setLocalCustomText(e.target.value)}
|
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>
|
</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>
|
<DialogFooter>
|
||||||
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -201,10 +318,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localText}
|
disabled={!localText || userInputHasErrors}
|
||||||
onClick={() => onDialogSignClick()}
|
onClick={() => onDialogSignClick()}
|
||||||
>
|
>
|
||||||
Save Text
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
|||||||
<FieldRootContainer
|
<FieldRootContainer
|
||||||
field={field}
|
field={field}
|
||||||
key={field.id}
|
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">
|
<div className="absolute -right-3 -top-3">
|
||||||
<PopoverHover
|
<PopoverHover
|
||||||
trigger={
|
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">
|
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
||||||
{extractInitials(field.Recipient.name || field.Recipient.email)}
|
{extractInitials(field.Recipient.name || field.Recipient.email)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@@ -78,7 +78,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
|||||||
</PopoverHover>
|
</PopoverHover>
|
||||||
</div>
|
</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 &&
|
{field.Recipient.signingStatus === SigningStatus.SIGNED &&
|
||||||
match(field)
|
match(field)
|
||||||
.with({ type: FieldType.SIGNATURE }, (field) =>
|
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||||
@@ -95,9 +95,19 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.with(
|
.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,
|
() => field.customText,
|
||||||
)
|
)
|
||||||
|
.with({ type: FieldType.TEXT }, () => field.customText.substring(0, 20) + '...')
|
||||||
.with({ type: FieldType.DATE }, () =>
|
.with({ type: FieldType.DATE }, () =>
|
||||||
convertToLocalSystemFormat(
|
convertToLocalSystemFormat(
|
||||||
field.customText,
|
field.customText,
|
||||||
|
|||||||
@@ -1034,6 +1034,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const field = await getFieldById({
|
const field = await getFieldById({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
fieldId: Number(fieldId),
|
fieldId: Number(fieldId),
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|||||||
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]);
|
||||||
|
};
|
||||||
@@ -117,6 +117,9 @@ export const sealDocument = async ({
|
|||||||
await insertFieldInPDF(doc, field);
|
await insertFieldInPDF(doc, field);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-flatten post-insertion to handle fields that create arcoFields
|
||||||
|
flattenForm(doc);
|
||||||
|
|
||||||
const pdfBytes = await doc.save();
|
const pdfBytes = await doc.save();
|
||||||
|
|
||||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
@@ -161,14 +160,7 @@ export const sendDocument = async ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (allRecipientsHaveNoActionToTake) {
|
if (allRecipientsHaveNoActionToTake) {
|
||||||
const updatedDocument = await updateDocument({
|
await sealDocument({ documentId, requestMetadata });
|
||||||
documentId,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
data: { status: DocumentStatus.COMPLETED },
|
|
||||||
});
|
|
||||||
|
|
||||||
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
|
|
||||||
|
|
||||||
// Keep the return type the same for the `sendDocument` method
|
// Keep the return type the same for the `sendDocument` method
|
||||||
return await prisma.document.findFirstOrThrow({
|
return await prisma.document.findFirstOrThrow({
|
||||||
|
|||||||
@@ -1,15 +1,47 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type GetFieldByIdOptions = {
|
export type GetFieldByIdOptions = {
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
fieldId: 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({
|
const field = await prisma.field.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
documentId,
|
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 { 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 type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import {
|
import {
|
||||||
createDocumentAuditLogData,
|
createDocumentAuditLogData,
|
||||||
diffFieldChanges,
|
diffFieldChanges,
|
||||||
} from '@documenso/lib/utils/document-audit-logs';
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { Field, FieldType } from '@documenso/prisma/client';
|
import type { Field } from '@documenso/prisma/client';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export interface SetFieldsForDocumentOptions {
|
export interface SetFieldsForDocumentOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -20,6 +34,7 @@ export interface SetFieldsForDocumentOptions {
|
|||||||
pageY: number;
|
pageY: number;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
|
fieldMeta?: FieldMeta;
|
||||||
}[];
|
}[];
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
}
|
}
|
||||||
@@ -103,6 +118,83 @@ export const setFieldsForDocument = async ({
|
|||||||
linkedFields.map(async (field) => {
|
linkedFields.map(async (field) => {
|
||||||
const fieldSignerEmail = field.signerEmail.toLowerCase();
|
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({
|
const upsertedField = await tx.field.upsert({
|
||||||
where: {
|
where: {
|
||||||
id: field._persisted?.id ?? -1,
|
id: field._persisted?.id ?? -1,
|
||||||
@@ -114,6 +206,7 @@ export const setFieldsForDocument = async ({
|
|||||||
positionY: field.pageY,
|
positionY: field.pageY,
|
||||||
width: field.pageWidth,
|
width: field.pageWidth,
|
||||||
height: field.pageHeight,
|
height: field.pageHeight,
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
type: field.type,
|
type: field.type,
|
||||||
@@ -124,6 +217,7 @@ export const setFieldsForDocument = async ({
|
|||||||
height: field.pageHeight,
|
height: field.pageHeight,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
Document: {
|
Document: {
|
||||||
connect: {
|
connect: {
|
||||||
id: documentId,
|
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 { prisma } from '@documenso/prisma';
|
||||||
import type { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type SetFieldsForTemplateOptions = {
|
export type SetFieldsForTemplateOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -13,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
|
|||||||
pageY: number;
|
pageY: number;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
|
fieldMeta?: FieldMeta;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,8 +85,60 @@ export const setFieldsForTemplate = async ({
|
|||||||
const persistedFields = await prisma.$transaction(
|
const persistedFields = await prisma.$transaction(
|
||||||
// Disabling as wrapping promises here causes type issues
|
// Disabling as wrapping promises here causes type issues
|
||||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||||
linkedFields.map((field) =>
|
linkedFields.map((field) => {
|
||||||
prisma.field.upsert({
|
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: {
|
where: {
|
||||||
id: field._persisted?.id ?? -1,
|
id: field._persisted?.id ?? -1,
|
||||||
templateId,
|
templateId,
|
||||||
@@ -82,6 +149,7 @@ export const setFieldsForTemplate = async ({
|
|||||||
positionY: field.pageY,
|
positionY: field.pageY,
|
||||||
width: field.pageWidth,
|
width: field.pageWidth,
|
||||||
height: field.pageHeight,
|
height: field.pageHeight,
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
type: field.type,
|
type: field.type,
|
||||||
@@ -92,6 +160,7 @@ export const setFieldsForTemplate = async ({
|
|||||||
height: field.pageHeight,
|
height: field.pageHeight,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
Template: {
|
Template: {
|
||||||
connect: {
|
connect: {
|
||||||
id: templateId,
|
id: templateId,
|
||||||
@@ -106,8 +175,8 @@ export const setFieldsForTemplate = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (removedFields.length > 0) {
|
if (removedFields.length > 0) {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
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 { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
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 { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
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 type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
@@ -87,6 +99,52 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
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({
|
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||||
documentAuthOptions: document.authOptions,
|
documentAuthOptions: document.authOptions,
|
||||||
recipient,
|
recipient,
|
||||||
@@ -177,6 +235,16 @@ export const signFieldWithToken = async ({
|
|||||||
type,
|
type,
|
||||||
data: updatedField.customText,
|
data: updatedField.customText,
|
||||||
}))
|
}))
|
||||||
|
.with(
|
||||||
|
FieldType.NUMBER,
|
||||||
|
FieldType.RADIO,
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
FieldType.DROPDOWN,
|
||||||
|
(type) => ({
|
||||||
|
type,
|
||||||
|
data: updatedField.customText,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.exhaustive(),
|
.exhaustive(),
|
||||||
fieldSecurity: derivedRecipientActionAuth
|
fieldSecurity: derivedRecipientActionAuth
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { FieldType, Team } from '@documenso/prisma/client';
|
import type { FieldType, Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export type UpdateFieldOptions = {
|
|||||||
pageWidth?: number;
|
pageWidth?: number;
|
||||||
pageHeight?: number;
|
pageHeight?: number;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
|
fieldMeta?: FieldMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateField = async ({
|
export const updateField = async ({
|
||||||
@@ -33,6 +35,7 @@ export const updateField = async ({
|
|||||||
pageWidth,
|
pageWidth,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
|
fieldMeta,
|
||||||
}: UpdateFieldOptions) => {
|
}: UpdateFieldOptions) => {
|
||||||
const oldField = await prisma.field.findFirstOrThrow({
|
const oldField = await prisma.field.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -71,6 +74,7 @@ export const updateField = async ({
|
|||||||
positionY: pageY,
|
positionY: pageY,
|
||||||
width: pageWidth,
|
width: pageWidth,
|
||||||
height: pageHeight,
|
height: pageHeight,
|
||||||
|
fieldMeta,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||||
import fontkit from '@pdf-lib/fontkit';
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
import { PDFDocument, RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
||||||
import { match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
@@ -13,6 +13,8 @@ import { FieldType } from '@documenso/prisma/client';
|
|||||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
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) => {
|
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||||
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
const fontCaveat = await fetch(process.env.FONT_CAVEAT_URI).then(async (res) =>
|
||||||
res.arrayBuffer(),
|
res.arrayBuffer(),
|
||||||
@@ -77,10 +79,13 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
await pdf.embedFont(fontCaveat);
|
await pdf.embedFont(fontCaveat);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInsertingImage =
|
await match(field)
|
||||||
isSignatureField && typeof field.Signature?.signatureImageAsBase64 === 'string';
|
.with(
|
||||||
|
{
|
||||||
if (isSignatureField && isInsertingImage) {
|
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
|
||||||
|
Signature: { signatureImageAsBase64: P.string },
|
||||||
|
},
|
||||||
|
async (field) => {
|
||||||
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
const image = await pdf.embedPng(field.Signature?.signatureImageAsBase64 ?? '');
|
||||||
|
|
||||||
let imageWidth = image.width;
|
let imageWidth = image.width;
|
||||||
@@ -117,7 +122,81 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
height: imageHeight,
|
height: imageHeight,
|
||||||
rotate: degrees(pageRotationInDegrees),
|
rotate: degrees(pageRotationInDegrees),
|
||||||
});
|
});
|
||||||
} else {
|
},
|
||||||
|
)
|
||||||
|
.with({ type: FieldType.CHECKBOX }, (field) => {
|
||||||
|
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
|
||||||
|
|
||||||
|
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
|
const longestLineInTextForWidth = field.customText
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.sort((a, b) => b.length - a.length)[0];
|
.sort((a, b) => b.length - a.length)[0];
|
||||||
@@ -156,7 +235,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
font,
|
font,
|
||||||
rotate: degrees(pageRotationInDegrees),
|
rotate: degrees(pageRotationInDegrees),
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
return pdf;
|
return pdf;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
DocumentSource,
|
DocumentSource,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
Prisma,
|
||||||
RecipientRole,
|
RecipientRole,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
SigningStatus,
|
SigningStatus,
|
||||||
@@ -26,6 +27,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
|||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
|
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||||
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } 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 { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
|
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
@@ -296,12 +298,16 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
height: field.height,
|
height: field.height,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.field.createMany({
|
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.
|
// Create the direct recipient and their non signature fields.
|
||||||
@@ -331,6 +337,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
height: templateField.height,
|
height: templateField.height,
|
||||||
customText,
|
customText,
|
||||||
inserted: true,
|
inserted: true,
|
||||||
|
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -361,6 +368,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
height: templateField.height,
|
height: templateField.height,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: true,
|
inserted: true,
|
||||||
|
fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
|
||||||
Signature: {
|
Signature: {
|
||||||
create: {
|
create: {
|
||||||
recipientId: createdDirectRecipient.id,
|
recipientId: createdDirectRecipient.id,
|
||||||
@@ -454,10 +462,20 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
data:
|
data:
|
||||||
field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '',
|
field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '',
|
||||||
}))
|
}))
|
||||||
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
|
.with(
|
||||||
|
FieldType.DATE,
|
||||||
|
FieldType.EMAIL,
|
||||||
|
FieldType.NAME,
|
||||||
|
FieldType.TEXT,
|
||||||
|
FieldType.NUMBER,
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
FieldType.DROPDOWN,
|
||||||
|
FieldType.RADIO,
|
||||||
|
(type) => ({
|
||||||
type,
|
type,
|
||||||
data: field.customText,
|
data: field.customText,
|
||||||
}))
|
}),
|
||||||
|
)
|
||||||
.exhaustive(),
|
.exhaustive(),
|
||||||
fieldSecurity: derivedRecipientActionAuth
|
fieldSecurity: derivedRecipientActionAuth
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||||
|
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import {
|
import {
|
||||||
@@ -225,12 +226,16 @@ export const createDocumentFromTemplate = async ({
|
|||||||
height: field.height,
|
height: field.height,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.field.createMany({
|
await tx.field.createMany({
|
||||||
data: fieldsToCreate,
|
data: fieldsToCreate.map((field) => ({
|
||||||
|
...field,
|
||||||
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
|
|||||||
@@ -253,6 +253,22 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
|||||||
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
||||||
data: z.string(),
|
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(
|
fieldSecurity: z.preprocess(
|
||||||
(input) => {
|
(input) => {
|
||||||
|
|||||||
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>;
|
||||||
@@ -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.
|
* 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;
|
||||||
@@ -413,6 +413,10 @@ enum FieldType {
|
|||||||
EMAIL
|
EMAIL
|
||||||
DATE
|
DATE
|
||||||
TEXT
|
TEXT
|
||||||
|
NUMBER
|
||||||
|
RADIO
|
||||||
|
CHECKBOX
|
||||||
|
DROPDOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
model Field {
|
model Field {
|
||||||
@@ -433,6 +437,7 @@ model Field {
|
|||||||
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||||
Signature Signature?
|
Signature Signature?
|
||||||
|
fieldMeta Json?
|
||||||
|
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
@@index([templateId])
|
@@index([templateId])
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
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 { 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 { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||||
@@ -11,6 +12,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
|||||||
import {
|
import {
|
||||||
ZAddFieldsMutationSchema,
|
ZAddFieldsMutationSchema,
|
||||||
ZAddTemplateFieldsMutationSchema,
|
ZAddTemplateFieldsMutationSchema,
|
||||||
|
ZGetFieldQuerySchema,
|
||||||
ZRemovedSignedFieldWithTokenMutationSchema,
|
ZRemovedSignedFieldWithTokenMutationSchema,
|
||||||
ZSignFieldWithTokenMutationSchema,
|
ZSignFieldWithTokenMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@@ -34,6 +36,7 @@ export const fieldRouter = router({
|
|||||||
pageY: field.pageY,
|
pageY: field.pageY,
|
||||||
pageWidth: field.pageWidth,
|
pageWidth: field.pageWidth,
|
||||||
pageHeight: field.pageHeight,
|
pageHeight: field.pageHeight,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
})),
|
})),
|
||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
@@ -65,6 +68,7 @@ export const fieldRouter = router({
|
|||||||
pageY: field.pageY,
|
pageY: field.pageY,
|
||||||
pageWidth: field.pageWidth,
|
pageWidth: field.pageWidth,
|
||||||
pageHeight: field.pageHeight,
|
pageHeight: field.pageHeight,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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 { z } from 'zod';
|
||||||
|
|
||||||
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZAddFieldsMutationSchema = z.object({
|
export const ZAddFieldsMutationSchema = z.object({
|
||||||
@@ -16,6 +17,7 @@ export const ZAddFieldsMutationSchema = z.object({
|
|||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: z.number().min(0),
|
||||||
pageHeight: 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),
|
pageY: z.number().min(0),
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: z.number().min(0),
|
||||||
pageHeight: 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<
|
export type TRemovedSignedFieldWithTokenMutationSchema = z.infer<
|
||||||
typeof ZRemovedSignedFieldWithTokenMutationSchema
|
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.EMAIL, () => signer.email)
|
||||||
.with(FieldType.NAME, () => signer.name)
|
.with(FieldType.NAME, () => signer.name)
|
||||||
.with(FieldType.TEXT, () => signer.customText)
|
.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(() => '');
|
.otherwise(() => '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const singleplayerRouter = router({
|
|||||||
.input(ZCreateSinglePlayerDocumentMutationSchema)
|
.input(ZCreateSinglePlayerDocumentMutationSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const { signer, fields, documentData, documentName } = input;
|
const { signer, fields, documentData, documentName, fieldMeta } = input;
|
||||||
|
|
||||||
const document = await getFile({
|
const document = await getFile({
|
||||||
data: documentData.data,
|
data: documentData.data,
|
||||||
@@ -69,6 +69,7 @@ export const singleplayerRouter = router({
|
|||||||
documentId: -1,
|
documentId: -1,
|
||||||
templateId: null,
|
templateId: null,
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
|
fieldMeta: fieldMeta || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
|
import { DocumentDataType, FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
|
export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
|
||||||
@@ -24,6 +25,7 @@ export const ZCreateSinglePlayerDocumentMutationSchema = z.object({
|
|||||||
height: z.number(),
|
height: z.number(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
fieldMeta: ZFieldMetaSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCreateSinglePlayerDocumentMutationSchema = z.infer<
|
export type TCreateSinglePlayerDocumentMutationSchema = z.infer<
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
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 type { Field } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
@@ -22,6 +24,51 @@ export type FieldContainerPortalProps = {
|
|||||||
cardClassName?: string;
|
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({
|
export function FieldContainerPortal({
|
||||||
field,
|
field,
|
||||||
children,
|
children,
|
||||||
@@ -29,16 +76,22 @@ export function FieldContainerPortal({
|
|||||||
}: FieldContainerPortalProps) {
|
}: FieldContainerPortalProps) {
|
||||||
const coords = useFieldPageCoords(field);
|
const coords = useFieldPageCoords(field);
|
||||||
|
|
||||||
return createPortal(
|
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
|
||||||
<div
|
const isFieldSigned = field.inserted;
|
||||||
className={cn('absolute', className)}
|
|
||||||
style={{
|
const style = {
|
||||||
top: `${coords.y}px`,
|
top: `${coords.y}px`,
|
||||||
left: `${coords.x}px`,
|
left: `${coords.x}px`,
|
||||||
|
// height: `${coords.height}px`,
|
||||||
|
// width: `${coords.width}px`,
|
||||||
|
...((!isCheckboxOrRadioField) && {
|
||||||
height: `${coords.height}px`,
|
height: `${coords.height}px`,
|
||||||
width: `${coords.width}px`,
|
width: `${coords.width}px`,
|
||||||
}}
|
}),
|
||||||
>
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={cn('absolute', className)} style={style}>
|
||||||
{children}
|
{children}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
@@ -47,7 +100,6 @@ export function FieldContainerPortal({
|
|||||||
|
|
||||||
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
|
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<FieldContainerPortal field={field}>
|
<FieldContainerPortal field={field}>
|
||||||
<Card
|
<Card
|
||||||
id={`field-${field.id}`}
|
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}
|
ref={ref}
|
||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
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">
|
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
|
||||||
{children}
|
{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
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,18 +4,34 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { Caveat } from 'next/font/google';
|
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 { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
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 { nanoid } from '@documenso/lib/universal/id';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||||
import { FieldType, SendStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
|
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
import { Card, CardContent } from '../card';
|
import { Card, CardContent } from '../card';
|
||||||
@@ -32,6 +48,7 @@ import {
|
|||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
import { FieldItem } from './field-item';
|
import { FieldItem } from './field-item';
|
||||||
|
import { FieldAdvancedSettings } from './field-item-advanced-settings';
|
||||||
import { MissingSignatureFieldDialog } from './missing-signature-field-dialog';
|
import { MissingSignatureFieldDialog } from './missing-signature-field-dialog';
|
||||||
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
|
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
|
||||||
|
|
||||||
@@ -42,11 +59,21 @@ const fontCaveat = Caveat({
|
|||||||
variable: '--font-caveat',
|
variable: '--font-caveat',
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
const MIN_HEIGHT_PX = 40;
|
||||||
const DEFAULT_WIDTH_PERCENT = 15;
|
const MIN_WIDTH_PX = 140;
|
||||||
|
|
||||||
const MIN_HEIGHT_PX = 60;
|
export type FieldFormType = {
|
||||||
const MIN_WIDTH_PX = 200;
|
nativeId?: number;
|
||||||
|
formId: string;
|
||||||
|
pageNumber: number;
|
||||||
|
type: FieldType;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
signerEmail: string;
|
||||||
|
fieldMeta?: FieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
export type AddFieldsFormProps = {
|
export type AddFieldsFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
@@ -56,8 +83,15 @@ export type AddFieldsFormProps = {
|
|||||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||||
canGoBack?: boolean;
|
canGoBack?: boolean;
|
||||||
isDocumentPdfLoaded: 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 = ({
|
export const AddFieldsFormPartial = ({
|
||||||
documentFlow,
|
documentFlow,
|
||||||
hideRecipients = false,
|
hideRecipients = false,
|
||||||
@@ -66,6 +100,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
canGoBack = false,
|
canGoBack = false,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
|
teamId,
|
||||||
}: AddFieldsFormProps) => {
|
}: AddFieldsFormProps) => {
|
||||||
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
|
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
|
||||||
|
|
||||||
@@ -73,11 +108,15 @@ export const AddFieldsFormPartial = ({
|
|||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
const canRenderBackButtonAsRemove =
|
const canRenderBackButtonAsRemove =
|
||||||
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
|
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
|
const [currentField, setCurrentField] = useState<FieldFormType>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
} = useForm<TAddFieldsFormSchema>({
|
} = useForm<TAddFieldsFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
@@ -91,11 +130,30 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageHeight: Number(field.height),
|
pageHeight: Number(field.height),
|
||||||
signerEmail:
|
signerEmail:
|
||||||
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
|
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
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 {
|
const {
|
||||||
append,
|
append,
|
||||||
@@ -110,9 +168,45 @@ export const AddFieldsFormPartial = ({
|
|||||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
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 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 =
|
const isFieldsDisabled =
|
||||||
!selectedSigner ||
|
!selectedSigner ||
|
||||||
hasSelectedSignerBeenSent ||
|
hasSelectedSignerBeenSent ||
|
||||||
@@ -195,6 +289,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
pageWidth: fieldPageWidth,
|
pageWidth: fieldPageWidth,
|
||||||
pageHeight: fieldPageHeight,
|
pageHeight: fieldPageHeight,
|
||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
|
fieldMeta: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsFieldWithinBounds(false);
|
setIsFieldWithinBounds(false);
|
||||||
@@ -276,11 +371,9 @@ export const AddFieldsFormPartial = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height, width } = $page.getBoundingClientRect();
|
|
||||||
|
|
||||||
fieldBounds.current = {
|
fieldBounds.current = {
|
||||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
height: Math.max(MIN_HEIGHT_PX),
|
||||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
width: Math.max(MIN_WIDTH_PX),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,6 +413,10 @@ export const AddFieldsFormPartial = ({
|
|||||||
);
|
);
|
||||||
}, [recipientsByRole]);
|
}, [recipientsByRole]);
|
||||||
|
|
||||||
|
const handleAdvancedSettings = () => {
|
||||||
|
setShowAdvancedSettings((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
const handleGoNextClick = () => {
|
const handleGoNextClick = () => {
|
||||||
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
|
||||||
localFields.some(
|
localFields.some(
|
||||||
@@ -337,21 +434,33 @@ export const AddFieldsFormPartial = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{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
|
<DocumentFlowFormContainerHeader
|
||||||
title={documentFlow.title}
|
title={documentFlow.title}
|
||||||
description={documentFlow.description}
|
description={documentFlow.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{selectedField && (
|
{selectedField && (
|
||||||
<Card
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-field-card/80 pointer-events-none fixed z-50 cursor-pointer border-2 backdrop-blur-[1px]',
|
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
|
||||||
|
selectedSignerStyles.default.base,
|
||||||
{
|
{
|
||||||
'border-field-card-border': isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
|
||||||
'opacity-50': !isFieldWithinBounds,
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -361,26 +470,36 @@ export const AddFieldsFormPartial = ({
|
|||||||
width: fieldBounds.current.width,
|
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]}
|
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDocumentPdfLoaded &&
|
{isDocumentPdfLoaded &&
|
||||||
localFields.map((field, index) => (
|
localFields.map((field, index) => {
|
||||||
|
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||||
|
|
||||||
|
return (
|
||||||
<FieldItem
|
<FieldItem
|
||||||
key={index}
|
key={index}
|
||||||
|
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||||
field={field}
|
field={field}
|
||||||
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
|
disabled={
|
||||||
|
selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent
|
||||||
|
}
|
||||||
minHeight={fieldBounds.current.height}
|
minHeight={fieldBounds.current.height}
|
||||||
minWidth={fieldBounds.current.width}
|
minWidth={fieldBounds.current.width}
|
||||||
passive={isFieldWithinBounds && !!selectedField}
|
passive={isFieldWithinBounds && !!selectedField}
|
||||||
onResize={(options) => onFieldResize(options, index)}
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
|
onAdvancedSettings={() => {
|
||||||
|
setCurrentField(field);
|
||||||
|
handleAdvancedSettings();
|
||||||
|
}}
|
||||||
|
hideRecipients={hideRecipients}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{!hideRecipients && (
|
{!hideRecipients && (
|
||||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||||
@@ -389,7 +508,10 @@ export const AddFieldsFormPartial = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
|
className={cn(
|
||||||
|
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
|
||||||
|
selectedSignerStyles.default.base,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{selectedSigner?.email && (
|
{selectedSigner?.email && (
|
||||||
<span className="flex-1 truncate text-left">
|
<span className="flex-1 truncate text-left">
|
||||||
@@ -398,7 +520,9 @@ export const AddFieldsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedSigner?.email && (
|
{!selectedSigner?.email && (
|
||||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
<span className="gradie flex-1 truncate text-left">
|
||||||
|
{selectedSigner?.email}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
@@ -415,13 +539,13 @@ export const AddFieldsFormPartial = ({
|
|||||||
</span>
|
</span>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
|
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
|
||||||
<CommandGroup key={roleIndex}>
|
<CommandGroup key={roleIndex}>
|
||||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recipients.length === 0 && (
|
{roleRecipients.length === 0 && (
|
||||||
<div
|
<div
|
||||||
key={`${role}-empty`}
|
key={`${role}-empty`}
|
||||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||||
@@ -430,12 +554,21 @@ export const AddFieldsFormPartial = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
{roleRecipients.map((recipient) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
|
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,
|
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedSigner(recipient);
|
setSelectedSigner(recipient);
|
||||||
setShowRecipientsSelector(false);
|
setShowRecipientsSelector(false);
|
||||||
@@ -473,8 +606,8 @@ export const AddFieldsFormPartial = ({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
This document has already been sent to this recipient. You can no
|
This document has already been sent to this recipient. You can
|
||||||
longer edit this recipient.
|
no longer edit this recipient.
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -489,7 +622,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
<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">
|
<fieldset disabled={isFieldsDisabled} className="my-2 grid grid-cols-3 gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
@@ -497,18 +630,21 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<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">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedSigner?.name || 'Signature'}
|
Signature
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -520,17 +656,21 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -542,17 +682,21 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -564,17 +708,21 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -586,17 +734,125 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
||||||
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -604,7 +860,21 @@ export const AddFieldsFormPartial = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerContent>
|
</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>
|
<DocumentFlowFormContainerFooter>
|
||||||
<DocumentFlowFormContainerStep
|
<DocumentFlowFormContainerStep
|
||||||
title={documentFlow.title}
|
title={documentFlow.title}
|
||||||
@@ -615,6 +885,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
disableNextStep={hasErrors}
|
||||||
onGoBackClick={() => {
|
onGoBackClick={() => {
|
||||||
previousStep();
|
previousStep();
|
||||||
remove();
|
remove();
|
||||||
@@ -630,5 +901,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZAddFieldsFormSchema = z.object({
|
export const ZAddFieldsFormSchema = z.object({
|
||||||
@@ -14,6 +15,7 @@ export const ZAddFieldsFormSchema = z.object({
|
|||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: z.number().min(0),
|
||||||
pageHeight: z.number().min(0),
|
pageHeight: z.number().min(0),
|
||||||
|
fieldMeta: ZFieldMetaSchema,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const AddSignatureFormPartial = ({
|
|||||||
requireSignature = true,
|
requireSignature = true,
|
||||||
}: AddSignatureFormProps) => {
|
}: AddSignatureFormProps) => {
|
||||||
const { currentStep, totalSteps } = useStep();
|
const { currentStep, totalSteps } = useStep();
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
// Refined schema which takes into account whether to allow an empty name or signature.
|
// 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;
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,7 +189,7 @@ export const AddSignatureFormPartial = ({
|
|||||||
customText: form.getValues('name'),
|
customText: form.getValues('name'),
|
||||||
inserted: true,
|
inserted: true,
|
||||||
}))
|
}))
|
||||||
.with(FieldType.TEXT, () => ({
|
.with(FieldType.TEXT, FieldType.NUMBER, FieldType.RADIO, FieldType.CHECKBOX, () => ({
|
||||||
...field,
|
...field,
|
||||||
customText: form.getValues('customText'),
|
customText: form.getValues('customText'),
|
||||||
inserted: true,
|
inserted: true,
|
||||||
@@ -374,7 +393,16 @@ export const AddSignatureFormPartial = ({
|
|||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{localFields.map((field) =>
|
{localFields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
.with(FieldType.DATE, FieldType.TEXT, FieldType.EMAIL, FieldType.NAME, () => {
|
.with(
|
||||||
|
FieldType.DATE,
|
||||||
|
FieldType.TEXT,
|
||||||
|
FieldType.EMAIL,
|
||||||
|
FieldType.NAME,
|
||||||
|
FieldType.NUMBER,
|
||||||
|
FieldType.CHECKBOX,
|
||||||
|
FieldType.RADIO,
|
||||||
|
FieldType.DROPDOWN,
|
||||||
|
() => {
|
||||||
return (
|
return (
|
||||||
<SinglePlayerModeCustomTextField
|
<SinglePlayerModeCustomTextField
|
||||||
onClick={insertField(field)}
|
onClick={insertField(field)}
|
||||||
@@ -382,7 +410,8 @@ export const AddSignatureFormPartial = ({
|
|||||||
field={field}
|
field={field}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.with(FieldType.SIGNATURE, () => (
|
.with(FieldType.SIGNATURE, () => (
|
||||||
<SinglePlayerModeSignatureField
|
<SinglePlayerModeSignatureField
|
||||||
onClick={insertField(field)}
|
onClick={insertField(field)}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export const ZAddSignatureFormSchema = z.object({
|
|||||||
.email({ message: 'Invalid email address' }),
|
.email({ message: 'Invalid email address' }),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
customText: z.string(),
|
customText: z.string(),
|
||||||
|
number: z.number().optional(),
|
||||||
|
radio: z.string().optional(),
|
||||||
|
checkbox: z.boolean().optional(),
|
||||||
|
dropdown: z.string().optional(),
|
||||||
signature: z.string(),
|
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;
|
onGoNextClick?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disableNextStep?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentFlowFormContainerActions = ({
|
export const DocumentFlowFormContainerActions = ({
|
||||||
@@ -137,6 +138,7 @@ export const DocumentFlowFormContainerActions = ({
|
|||||||
onGoNextClick,
|
onGoNextClick,
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
disabled,
|
||||||
|
disableNextStep = false,
|
||||||
}: DocumentFlowFormContainerActionsProps) => {
|
}: DocumentFlowFormContainerActionsProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex gap-x-4">
|
<div className="mt-4 flex gap-x-4">
|
||||||
@@ -155,7 +157,7 @@ export const DocumentFlowFormContainerActions = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="bg-documenso flex-1"
|
className="bg-documenso flex-1"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={disabled || loading || !canGoNext}
|
disabled={disabled || disableNextStep || loading || !canGoNext}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={onGoNextClick}
|
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';
|
'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 { createPortal } from 'react-dom';
|
||||||
import { Rnd } from 'react-rnd';
|
import { Rnd } from 'react-rnd';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
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 { 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 type { TDocumentFlowFormSchema } from './types';
|
||||||
import { FRIENDLY_FIELD_TYPE } from './types';
|
|
||||||
|
|
||||||
type Field = TDocumentFlowFormSchema['fields'][0];
|
type Field = TDocumentFlowFormSchema['fields'][0];
|
||||||
|
|
||||||
|
const fontCaveat = Caveat({
|
||||||
|
weight: ['500'],
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-caveat',
|
||||||
|
});
|
||||||
|
|
||||||
export type FieldItemProps = {
|
export type FieldItemProps = {
|
||||||
field: Field;
|
field: Field;
|
||||||
passive?: boolean;
|
passive?: boolean;
|
||||||
@@ -24,17 +38,23 @@ export type FieldItemProps = {
|
|||||||
onResize?: (_node: HTMLElement) => void;
|
onResize?: (_node: HTMLElement) => void;
|
||||||
onMove?: (_node: HTMLElement) => void;
|
onMove?: (_node: HTMLElement) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
|
onAdvancedSettings?: () => void;
|
||||||
|
recipientIndex?: number;
|
||||||
|
hideRecipients?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FieldItem = ({
|
export const FieldItem = ({
|
||||||
field,
|
field,
|
||||||
passive,
|
passive,
|
||||||
disabled,
|
disabled,
|
||||||
minHeight: _minHeight,
|
minHeight,
|
||||||
minWidth: _minWidth,
|
minWidth,
|
||||||
onResize,
|
onResize,
|
||||||
onMove,
|
onMove,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onAdvancedSettings,
|
||||||
|
recipientIndex = 0,
|
||||||
|
hideRecipients = false,
|
||||||
}: FieldItemProps) => {
|
}: FieldItemProps) => {
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
@@ -43,6 +63,12 @@ export const FieldItem = ({
|
|||||||
pageHeight: 0,
|
pageHeight: 0,
|
||||||
pageWidth: 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 calculateCoords = useCallback(() => {
|
||||||
const $page = document.querySelector<HTMLElement>(
|
const $page = document.querySelector<HTMLElement>(
|
||||||
@@ -89,25 +115,63 @@ export const FieldItem = ({
|
|||||||
};
|
};
|
||||||
}, [calculateCoords]);
|
}, [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(
|
return createPortal(
|
||||||
<Rnd
|
<Rnd
|
||||||
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
|
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': passive,
|
||||||
'pointer-events-none opacity-75': disabled,
|
'pointer-events-none cursor-not-allowed opacity-75': disabled,
|
||||||
'z-10': !active || disabled,
|
'z-10': !active || disabled,
|
||||||
})}
|
})}
|
||||||
// minHeight={minHeight}
|
minHeight={fixedSize ? '' : minHeight || 'auto'}
|
||||||
// minWidth={minWidth}
|
minWidth={fixedSize ? '' : minWidth || 'auto'}
|
||||||
default={{
|
default={{
|
||||||
x: coords.pageX,
|
x: coords.pageX,
|
||||||
y: coords.pageY,
|
y: coords.pageY,
|
||||||
height: coords.pageHeight,
|
height: fixedSize ? '' : coords.pageHeight,
|
||||||
width: coords.pageWidth,
|
width: fixedSize ? '' : coords.pageWidth,
|
||||||
}}
|
}}
|
||||||
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
|
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
|
||||||
onDragStart={() => setActive(true)}
|
onDragStart={() => setActive(true)}
|
||||||
onResizeStart={() => setActive(true)}
|
onResizeStart={() => setActive(true)}
|
||||||
|
enableResizing={!fixedSize}
|
||||||
onResizeStop={(_e, _d, ref) => {
|
onResizeStop={(_e, _d, ref) => {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
onResize?.(ref);
|
onResize?.(ref);
|
||||||
@@ -117,35 +181,69 @@ export const FieldItem = ({
|
|||||||
onMove?.(d.node);
|
onMove?.(d.node);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!disabled && (
|
<div
|
||||||
<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,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<CardContent
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-field-card-foreground flex h-full w-full flex-col items-center justify-center p-2',
|
'relative flex h-full w-full items-center justify-center bg-white',
|
||||||
|
signerStyles.default.base,
|
||||||
|
signerStyles.default.fieldItem,
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsActive((prev) => !prev);
|
||||||
|
}}
|
||||||
|
ref={$el}
|
||||||
|
>
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!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,
|
||||||
{
|
{
|
||||||
'text-field-card-foreground/50': disabled,
|
'!opacity-50': disabled || passive,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{FRIENDLY_FIELD_TYPE[field.type]}
|
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
|
||||||
|
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="w-full truncate text-center text-xs">{field.signerEmail}</p>
|
{!disabled && settingsActive && (
|
||||||
</CardContent>
|
<div className="mt-1 flex justify-center">
|
||||||
</Card>
|
<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>,
|
</Rnd>,
|
||||||
document.body,
|
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 React, { useRef } from 'react';
|
||||||
|
|
||||||
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { CalendarDays, CheckSquare, ChevronDown, Disc, Hash, Mail, Type, User } from 'lucide-react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
|
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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
|
|
||||||
import { FieldRootContainer } from '../../components/field/field';
|
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 = {
|
export type SinglePlayerModeFieldContainerProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
@@ -110,9 +121,11 @@ export function SinglePlayerModeSignatureField({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => onClick?.()}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</SinglePlayerModeFieldCardContainer>
|
</SinglePlayerModeFieldCardContainer>
|
||||||
@@ -152,6 +165,7 @@ export function SinglePlayerModeCustomTextField({
|
|||||||
const fontSize = maxFontSize * scalingFactor;
|
const fontSize = maxFontSize * scalingFactor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SinglePlayerModeFieldCardContainer field={field}>
|
<SinglePlayerModeFieldCardContainer field={field}>
|
||||||
{field.inserted ? (
|
{field.inserted ? (
|
||||||
<p
|
<p
|
||||||
@@ -161,7 +175,10 @@ export function SinglePlayerModeCustomTextField({
|
|||||||
fontFamily: `var(${fontVariable})`,
|
fontFamily: `var(${fontVariable})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{field.customText}
|
{field.customText ??
|
||||||
|
(field.fieldMeta && typeof field.fieldMeta === 'object' && 'label' in field.fieldMeta
|
||||||
|
? field.fieldMeta.label
|
||||||
|
: '')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -169,15 +186,61 @@ export function SinglePlayerModeCustomTextField({
|
|||||||
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full text-lg duration-200"
|
className="group-hover:text-primary text-muted-foreground absolute inset-0 h-full w-full text-lg duration-200"
|
||||||
>
|
>
|
||||||
{match(field.type)
|
{match(field.type)
|
||||||
.with(FieldType.DATE, () => 'Date')
|
.with(FieldType.DATE, () => (
|
||||||
.with(FieldType.NAME, () => 'Name')
|
<div className="text-field-card-foreground flex items-center justify-center gap-x-1 text-xl font-light">
|
||||||
.with(FieldType.EMAIL, () => 'Email')
|
<CalendarDays /> Date
|
||||||
.with(FieldType.TEXT, () => 'Text')
|
</div>
|
||||||
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, () => 'Signature')
|
))
|
||||||
|
.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(() => '')}
|
.otherwise(() => '')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</SinglePlayerModeFieldCardContainer>
|
</SinglePlayerModeFieldCardContainer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZDocumentFlowFormSchema = z.object({
|
export const ZDocumentFlowFormSchema = z.object({
|
||||||
@@ -30,6 +31,7 @@ export const ZDocumentFlowFormSchema = z.object({
|
|||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: z.number().min(0),
|
||||||
pageHeight: 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.DATE]: 'Date',
|
||||||
[FieldType.EMAIL]: 'Email',
|
[FieldType.EMAIL]: 'Email',
|
||||||
[FieldType.NAME]: 'Name',
|
[FieldType.NAME]: 'Name',
|
||||||
|
[FieldType.NUMBER]: 'Number',
|
||||||
|
[FieldType.RADIO]: 'Radio',
|
||||||
|
[FieldType.CHECKBOX]: 'Checkbox',
|
||||||
|
[FieldType.DROPDOWN]: 'Select',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DocumentFlowStep {
|
export interface DocumentFlowStep {
|
||||||
|
|||||||
@@ -4,13 +4,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { Caveat } from 'next/font/google';
|
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 { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
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 { nanoid } from '@documenso/lib/universal/id';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
@@ -28,6 +42,7 @@ import {
|
|||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
|
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 { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
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 { useStep } from '../stepper';
|
||||||
// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
|
||||||
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
import type { TAddTemplateFieldsFormSchema } from './add-template-fields.types';
|
||||||
|
|
||||||
const fontCaveat = Caveat({
|
const fontCaveat = Caveat({
|
||||||
@@ -46,11 +63,8 @@ const fontCaveat = Caveat({
|
|||||||
variable: '--font-caveat',
|
variable: '--font-caveat',
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_HEIGHT_PERCENT = 5;
|
const MIN_HEIGHT_PX = 40;
|
||||||
const DEFAULT_WIDTH_PERCENT = 15;
|
const MIN_WIDTH_PX = 140;
|
||||||
|
|
||||||
const MIN_HEIGHT_PX = 60;
|
|
||||||
const MIN_WIDTH_PX = 200;
|
|
||||||
|
|
||||||
export type AddTemplateFieldsFormProps = {
|
export type AddTemplateFieldsFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
@@ -58,6 +72,7 @@ export type AddTemplateFieldsFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddTemplateFieldsFormPartial = ({
|
export const AddTemplateFieldsFormPartial = ({
|
||||||
@@ -66,15 +81,19 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
teamId,
|
||||||
}: AddTemplateFieldsFormProps) => {
|
}: AddTemplateFieldsFormProps) => {
|
||||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
|
|
||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||||
|
const [currentField, setCurrentField] = useState<FieldFormType>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
} = useForm<TAddTemplateFieldsFormSchema>({
|
} = useForm<TAddTemplateFieldsFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
@@ -97,6 +116,25 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
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 {
|
const {
|
||||||
append,
|
append,
|
||||||
remove,
|
remove,
|
||||||
@@ -111,6 +149,11 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
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 [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -189,6 +232,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
signerEmail: selectedSigner.email,
|
signerEmail: selectedSigner.email,
|
||||||
signerId: selectedSigner.id,
|
signerId: selectedSigner.id,
|
||||||
signerToken: selectedSigner.token ?? '',
|
signerToken: selectedSigner.token ?? '',
|
||||||
|
fieldMeta: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsFieldWithinBounds(false);
|
setIsFieldWithinBounds(false);
|
||||||
@@ -270,11 +314,9 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height, width } = $page.getBoundingClientRect();
|
|
||||||
|
|
||||||
fieldBounds.current = {
|
fieldBounds.current = {
|
||||||
height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX),
|
height: Math.max(MIN_HEIGHT_PX),
|
||||||
width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX),
|
width: Math.max(MIN_WIDTH_PX),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,17 +356,37 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
);
|
);
|
||||||
}, [recipientsByRole]);
|
}, [recipientsByRole]);
|
||||||
|
|
||||||
|
const handleAdvancedSettings = () => {
|
||||||
|
setShowAdvancedSettings((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{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>
|
<DocumentFlowFormContainerContent>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{selectedField && (
|
{selectedField && (
|
||||||
<Card
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background pointer-events-none fixed z-50 cursor-pointer transition-opacity',
|
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200',
|
||||||
|
selectedSignerStyles.default.base,
|
||||||
{
|
{
|
||||||
'border-primary': isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50': !isFieldWithinBounds,
|
||||||
'opacity-50': !isFieldWithinBounds,
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -334,15 +396,17 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
width: fieldBounds.current.width,
|
width: fieldBounds.current.width,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className="text-foreground flex h-full w-full items-center justify-center p-2">
|
|
||||||
{FRIENDLY_FIELD_TYPE[selectedField]}
|
{FRIENDLY_FIELD_TYPE[selectedField]}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localFields.map((field, index) => (
|
{localFields.map((field, index) => {
|
||||||
|
const recipientIndex = recipients.findIndex((r) => r.email === field.signerEmail);
|
||||||
|
|
||||||
|
return (
|
||||||
<FieldItem
|
<FieldItem
|
||||||
key={index}
|
key={index}
|
||||||
|
recipientIndex={recipientIndex === -1 ? 0 : recipientIndex}
|
||||||
field={field}
|
field={field}
|
||||||
disabled={selectedSigner?.email !== field.signerEmail}
|
disabled={selectedSigner?.email !== field.signerEmail}
|
||||||
minHeight={fieldBounds.current.height}
|
minHeight={fieldBounds.current.height}
|
||||||
@@ -351,8 +415,14 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
onResize={(options) => onFieldResize(options, index)}
|
onResize={(options) => onFieldResize(options, index)}
|
||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
|
onAdvancedSettings={() => {
|
||||||
|
setCurrentField(field);
|
||||||
|
handleAdvancedSettings();
|
||||||
|
}}
|
||||||
|
hideRecipients={hideRecipients}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{!hideRecipients && (
|
{!hideRecipients && (
|
||||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||||
@@ -361,7 +431,10 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="bg-background text-muted-foreground mb-12 justify-between font-normal"
|
className={cn(
|
||||||
|
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
|
||||||
|
selectedSignerStyles.default.base,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{selectedSigner?.email && (
|
{selectedSigner?.email && (
|
||||||
<span className="flex-1 truncate text-left">
|
<span className="flex-1 truncate text-left">
|
||||||
@@ -370,7 +443,9 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedSigner?.email && (
|
{!selectedSigner?.email && (
|
||||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
<span className="gradie flex-1 truncate text-left">
|
||||||
|
{selectedSigner?.email}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
@@ -378,21 +453,22 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent className="p-0" align="start">
|
<PopoverContent className="p-0" align="start">
|
||||||
<Command>
|
<Command value={selectedSigner?.email}>
|
||||||
<CommandInput />
|
<CommandInput />
|
||||||
|
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
<span className="text-muted-foreground inline-block px-4">
|
<span className="text-muted-foreground inline-block px-4">
|
||||||
No recipient matching this description was found.
|
No recipient matching this description was found.
|
||||||
</span>
|
</span>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
|
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
|
||||||
<CommandGroup key={roleIndex}>
|
<CommandGroup key={roleIndex}>
|
||||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||||
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recipients.length === 0 && (
|
{roleRecipients.length === 0 && (
|
||||||
<div
|
<div
|
||||||
key={`${role}-empty`}
|
key={`${role}-empty`}
|
||||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||||
@@ -401,10 +477,18 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipients.map((recipient) => (
|
{roleRecipients.map((recipient) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1')}
|
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={() => {
|
onSelect={() => {
|
||||||
setSelectedSigner(recipient);
|
setSelectedSigner(recipient);
|
||||||
setShowRecipientsSelector(false);
|
setShowRecipientsSelector(false);
|
||||||
@@ -435,27 +519,29 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
<div className="-mx-2 flex-1 overflow-y-auto px-2">
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-8">
|
<fieldset className="my-2 grid grid-cols-3 gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner}
|
|
||||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<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">
|
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-lg font-normal',
|
||||||
fontCaveat.className,
|
fontCaveat.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedSigner?.name || 'Signature'}
|
Signature
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-center text-xs">Signature</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -463,22 +549,25 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner}
|
|
||||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Email</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -486,22 +575,25 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner}
|
|
||||||
onClick={() => setSelectedField(FieldType.NAME)}
|
onClick={() => setSelectedField(FieldType.NAME)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Name</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -509,22 +601,25 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group h-full w-full"
|
className="group h-full w-full"
|
||||||
disabled={!selectedSigner}
|
|
||||||
onClick={() => setSelectedField(FieldType.DATE)}
|
onClick={() => setSelectedField(FieldType.DATE)}
|
||||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Date</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
@@ -536,21 +631,129 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
onMouseDown={() => setSelectedField(FieldType.TEXT)}
|
||||||
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
data-selected={selectedField === FieldType.TEXT ? true : undefined}
|
||||||
>
|
>
|
||||||
<Card className="group-data-[selected]:border-documenso h-full w-full cursor-pointer group-disabled:opacity-50">
|
<Card
|
||||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
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'}
|
<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>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs">Custom Text</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
<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>
|
||||||
@@ -574,5 +777,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZAddTemplateFieldsFormSchema = z.object({
|
export const ZAddTemplateFieldsFormSchema = z.object({
|
||||||
@@ -16,6 +17,7 @@ export const ZAddTemplateFieldsFormSchema = z.object({
|
|||||||
pageY: z.number().min(0),
|
pageY: z.number().min(0),
|
||||||
pageWidth: z.number().min(0),
|
pageWidth: z.number().min(0),
|
||||||
pageHeight: z.number().min(0),
|
pageHeight: z.number().min(0),
|
||||||
|
fieldMeta: ZFieldMetaSchema,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from '../document-flow/document-flow-root';
|
} from '../document-flow/document-flow-root';
|
||||||
import { ShowFieldItem } from '../document-flow/show-field-item';
|
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||||
@@ -168,6 +169,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={documentFlow.title}
|
||||||
|
description={documentFlow.description}
|
||||||
|
/>
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
{isDocumentPdfLoaded &&
|
{isDocumentPdfLoaded &&
|
||||||
fields.map((field, index) => (
|
fields.map((field, index) => (
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from '../document-flow/document-flow-root';
|
} from '../document-flow/document-flow-root';
|
||||||
import { ShowFieldItem } from '../document-flow/show-field-item';
|
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||||
@@ -104,6 +105,11 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={documentFlow.title}
|
||||||
|
description={documentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
{isDocumentPdfLoaded &&
|
{isDocumentPdfLoaded &&
|
||||||
fields.map((field, index) => (
|
fields.map((field, index) => (
|
||||||
|
|||||||
@@ -46,6 +46,13 @@
|
|||||||
--warning: 54 96% 45%;
|
--warning: 54 96% 45%;
|
||||||
|
|
||||||
--gold: 47.9 95.8% 53.1%;
|
--gold: 47.9 95.8% 53.1%;
|
||||||
|
|
||||||
|
--signer-green: 100 48% 55%;
|
||||||
|
--signer-blue: 212 56% 50%;
|
||||||
|
--signer-purple: 266 100% 64%;
|
||||||
|
--signer-orange: 36 92% 54%;
|
||||||
|
--signer-yellow: 51 100% 43%;
|
||||||
|
--signer-pink: 313 65% 57%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": "@documenso/tsconfig/react-library.json",
|
"extends": "@documenso/tsconfig/react-library.json",
|
||||||
"compilerOptions": {
|
"include": [
|
||||||
"baseUrl": ".",
|
"."
|
||||||
"paths": {
|
],
|
||||||
"@/*": ["./*"]
|
"exclude": [
|
||||||
}
|
"dist",
|
||||||
},
|
"build",
|
||||||
"include": ["."],
|
"node_modules"
|
||||||
"exclude": ["dist", "build", "node_modules"]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prebuild": {
|
"prebuild": {
|
||||||
|
"cache": false,
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"^prebuild"
|
"^prebuild"
|
||||||
],
|
|
||||||
"outputs": [
|
|
||||||
".next/**",
|
|
||||||
"!.next/cache/**"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|||||||
Reference in New Issue
Block a user