feat: add envelopes (#2025)

This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
This commit is contained in:
David Nguyen
2025-10-14 21:56:36 +11:00
committed by GitHub
parent 7b17156e56
commit 7f09ba72f4
447 changed files with 33467 additions and 9622 deletions

View File

@ -17,12 +17,12 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
} from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';

View File

@ -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: '<=',
},
];

View File

@ -0,0 +1,293 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import {
type TCheckboxFieldMeta as CheckboxFieldMeta,
ZCheckboxFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator';
import { checkboxValidationLength, checkboxValidationRules } from './constants';
import {
EditorGenericReadOnlyField,
EditorGenericRequiredField,
} from './editor-field-generic-field-forms';
const ZCheckboxFieldFormSchema = ZCheckboxFieldMeta.pick({
label: true,
direction: true,
validationRule: true,
validationLength: true,
required: true,
values: true,
readOnly: true,
})
.extend({
validationLength: z.coerce.number().optional(),
})
.refine(
(data) => {
// You need to specify both validation rule and length together
if (data.validationRule && !data.validationLength) {
return false;
}
if (data.validationLength && !data.validationRule) {
return false;
}
return true;
},
{
message: 'You need to specify both the validation rule and the number of options',
path: ['validationRule'],
},
);
type TCheckboxFieldFormSchema = z.infer<typeof ZCheckboxFieldFormSchema>;
type EditorFieldCheckboxFormProps = {
value: CheckboxFieldMeta | undefined;
onValueChange: (value: CheckboxFieldMeta) => void;
};
export const EditorFieldCheckboxForm = ({
value = {
type: 'checkbox',
direction: 'vertical',
},
onValueChange,
}: EditorFieldCheckboxFormProps) => {
const form = useForm<TCheckboxFieldFormSchema>({
resolver: zodResolver(ZCheckboxFieldFormSchema),
mode: 'onChange',
defaultValues: {
label: value.label || '',
direction: value.direction || 'vertical',
validationRule: value.validationRule || '',
validationLength: value.validationLength || 0,
values: value.values || [{ id: 1, checked: false, value: '' }],
required: value.required || false,
readOnly: value.readOnly || false,
},
});
const { control } = form;
const formValues = useWatch({
control,
});
const addValue = () => {
const currentValues = form.getValues('values') || [];
const newId =
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
form.setValue('values', newValues);
};
const removeValue = (index: number) => {
const currentValues = form.getValues('values') || [];
if (currentValues.length === 1) {
return;
}
const newValues = [...currentValues];
newValues.splice(index, 1);
form.setValue('values', newValues);
};
useEffect(() => {
const validatedFormValues = ZCheckboxFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
...value,
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Direction</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row items-center justify-start gap-x-4">
<div className="flex w-2/3 flex-col">
<FormField
control={form.control}
name="validationRule"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Validation</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Select at least`} />
</SelectTrigger>
<SelectContent position="popper">
{checkboxValidationRules.map((item, index) => (
<SelectItem key={index} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="mt-3 flex w-1/3 flex-col">
<FormField
control={form.control}
name="validationLength"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ? String(field.value) : ''}
onValueChange={field.onChange}
>
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
<SelectValue placeholder={t`Pick a number`} />
</SelectTrigger>
<SelectContent position="popper">
{checkboxValidationLength.map((item, index) => (
<SelectItem key={index} value={String(item)}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>
<EditorGenericReadOnlyField formControl={form.control} />
<section className="space-y-2">
<div className="-mx-4 mb-4 mt-2">
<Separator />
</div>
<div className="flex flex-row items-center justify-between gap-2">
<p className="text-sm font-medium">
<Trans>Checkbox values</Trans>
</p>
<button type="button" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<ul className="space-y-2">
{(formValues.values || []).map((value, index) => (
<li key={`checkbox-value-${index}`} className="flex flex-row items-center gap-2">
<FormField
control={form.control}
name={`values.${index}.checked`}
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`values.${index}.value`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
</FormControl>
</FormItem>
)}
/>
<button
type="button"
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
<Trash className="h-5 w-5" />
</button>
</li>
))}
</ul>
</section>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TDateFieldMeta as DateFieldMeta,
ZDateFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import {
EditorGenericFontSizeField,
EditorGenericTextAlignField,
} from './editor-field-generic-field-forms';
const ZDateFieldFormSchema = ZDateFieldMeta.pick({
fontSize: true,
textAlign: true,
});
type TDateFieldFormSchema = z.infer<typeof ZDateFieldFormSchema>;
type EditorFieldDateFormProps = {
value: DateFieldMeta | undefined;
onValueChange: (value: DateFieldMeta) => void;
};
export const EditorFieldDateForm = ({
value = {
type: 'date',
},
onValueChange,
}: EditorFieldDateFormProps) => {
const form = useForm<TDateFieldFormSchema>({
resolver: zodResolver(ZDateFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZDateFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'date',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<EditorGenericTextAlignField formControl={form.control} />
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,240 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { type TDropdownFieldMeta as DropdownFieldMeta } from '@documenso/lib/types/field-meta';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator';
import {
EditorGenericReadOnlyField,
EditorGenericRequiredField,
} from './editor-field-generic-field-forms';
const ZDropdownFieldFormSchema = z
.object({
defaultValue: z.string().optional(),
values: z
.object({
value: z.string().min(1, {
message: msg`Option value cannot be empty`.id,
}),
})
.array()
.min(1, {
message: msg`Dropdown must have at least one option`.id,
})
.refine(
(data) => {
// Todo: Envelopes - This doesn't work.
console.log({
data,
});
if (data) {
const values = data.map((item) => item.value);
return new Set(values).size === values.length;
}
return true;
},
{
message: 'Duplicate values are not allowed',
},
),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// Default value must be one of the available options
if (data.defaultValue && data.values) {
return data.values.some((item) => item.value === data.defaultValue);
}
return true;
},
{
message: 'Default value must be one of the available options',
path: ['defaultValue'],
},
);
type TDropdownFieldFormSchema = z.infer<typeof ZDropdownFieldFormSchema>;
type EditorFieldDropdownFormProps = {
value: DropdownFieldMeta | undefined;
onValueChange: (value: DropdownFieldMeta) => void;
};
export const EditorFieldDropdownForm = ({
value = {
type: 'dropdown',
},
onValueChange,
}: EditorFieldDropdownFormProps) => {
const { t } = useLingui();
const form = useForm<TDropdownFieldFormSchema>({
resolver: zodResolver(ZDropdownFieldFormSchema),
mode: 'onChange',
defaultValues: {
defaultValue: value.defaultValue,
values: value.values || [{ value: 'Option 1' }],
required: value.required || false,
readOnly: value.readOnly || false,
},
});
const formValues = useWatch({
control: form.control,
});
const addValue = () => {
const currentValues = form.getValues('values') || [];
const newValues = [...currentValues, { value: 'New option' }];
form.setValue('values', newValues);
};
const removeValue = (index: number) => {
const currentValues = form.getValues('values') || [];
if (currentValues.length === 1) {
return;
}
const newValues = [...currentValues];
newValues.splice(index, 1);
form.setValue('values', newValues);
};
useEffect(() => {
const validatedFormValues = ZDropdownFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'dropdown',
...validatedFormValues.data,
});
}
}, [formValues]);
const { formState } = form;
useEffect(() => {
console.log({
errors: formState.errors,
formValues,
});
}, [formState, formState.errors, formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="defaultValue"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Select default option</Trans>
</FormLabel>
<FormControl>
<Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field}
value={field.value}
onValueChange={(val) => field.onChange(val)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} />
</SelectTrigger>
<SelectContent position="popper">
{(formValues.values || []).map((item, index) => (
<SelectItem key={index} value={item.value || ''}>
{item.value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>
<EditorGenericReadOnlyField formControl={form.control} />
<section className="space-y-2">
<div className="-mx-4 mb-4 mt-2">
<Separator />
</div>
<div className="flex flex-row items-center justify-between gap-2">
<p className="text-sm font-medium">
<Trans>Dropdown values</Trans>
</p>
<button type="button" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<ul className="space-y-2">
{(formValues.values || []).map((value, index) => (
<li key={`dropdown-value-${index}`} className="flex flex-row gap-2">
<FormField
control={form.control}
name={`values.${index}.value`}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
<Trash className="h-5 w-5" />
</button>
</li>
))}
</ul>
</section>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TEmailFieldMeta as EmailFieldMeta,
ZEmailFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import {
EditorGenericFontSizeField,
EditorGenericTextAlignField,
} from './editor-field-generic-field-forms';
const ZEmailFieldFormSchema = ZEmailFieldMeta.pick({
fontSize: true,
textAlign: true,
});
type TEmailFieldFormSchema = z.infer<typeof ZEmailFieldFormSchema>;
type EditorFieldEmailFormProps = {
value: EmailFieldMeta | undefined;
onValueChange: (value: EmailFieldMeta) => void;
};
export const EditorFieldEmailForm = ({
value = {
type: 'email',
},
onValueChange,
}: EditorFieldEmailFormProps) => {
const form = useForm<TEmailFieldFormSchema>({
resolver: zodResolver(ZEmailFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZEmailFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'email',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<EditorGenericTextAlignField formControl={form.control} />
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,222 @@
import { useEffect } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Control, useFormContext } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
// Can't seem to get the non-any type to work with correct types.
// Eg Control<{ fontSize?: number } doesn't seem to work when there are required items.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FormControlType = Control<any>;
export const EditorGenericFontSizeField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="fontSize"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Font Size</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={8}
max={96}
className="bg-background"
placeholder={t`Field font size`}
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericTextAlignField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="textAlign"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Text Align</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select text align`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">
<Trans>Left</Trans>
</SelectItem>
<SelectItem value="center">
<Trans>Center</Trans>
</SelectItem>
<SelectItem value="right">
<Trans>Right</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericRequiredField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { watch, setValue } = useFormContext();
const readOnly = watch('readOnly');
useEffect(() => {
if (readOnly) {
setValue('required', false);
}
}, [readOnly]);
return (
<FormField
control={formControl}
name="required"
render={({ field }) => (
<FormItem className={cn('flex items-center space-x-2', className)}>
<FormControl>
<div className="flex items-center">
<Checkbox
id="field-required"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
<Trans>Required Field</Trans>
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericReadOnlyField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { watch, setValue } = useFormContext();
const required = watch('required');
useEffect(() => {
if (required) {
setValue('readOnly', false);
}
}, [required]);
return (
<FormField
control={formControl}
name="readOnly"
render={({ field }) => (
<FormItem className={cn('flex items-center space-x-2', className)}>
<FormControl>
<div className="flex items-center">
<Checkbox
id="field-read-only"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
<Trans>Read Only</Trans>
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLabelField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="label"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TInitialsFieldMeta as InitialsFieldMeta,
ZInitialsFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import {
EditorGenericFontSizeField,
EditorGenericTextAlignField,
} from './editor-field-generic-field-forms';
const ZInitialsFieldFormSchema = ZInitialsFieldMeta.pick({
fontSize: true,
textAlign: true,
});
type TInitialsFieldFormSchema = z.infer<typeof ZInitialsFieldFormSchema>;
type EditorFieldInitialsFormProps = {
value: InitialsFieldMeta | undefined;
onValueChange: (value: InitialsFieldMeta) => void;
};
export const EditorFieldInitialsForm = ({
value = {
type: 'initials',
},
onValueChange,
}: EditorFieldInitialsFormProps) => {
const form = useForm<TInitialsFieldFormSchema>({
resolver: zodResolver(ZInitialsFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZInitialsFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'initials',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<EditorGenericTextAlignField formControl={form.control} />
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TNameFieldMeta as NameFieldMeta,
ZNameFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import {
EditorGenericFontSizeField,
EditorGenericTextAlignField,
} from './editor-field-generic-field-forms';
const ZNameFieldFormSchema = ZNameFieldMeta.pick({
fontSize: true,
textAlign: true,
});
type TNameFieldFormSchema = z.infer<typeof ZNameFieldFormSchema>;
type EditorFieldNameFormProps = {
value: NameFieldMeta | undefined;
onValueChange: (value: NameFieldMeta) => void;
};
export const EditorFieldNameForm = ({
value = {
type: 'name',
},
onValueChange,
}: EditorFieldNameFormProps) => {
const form = useForm<TNameFieldFormSchema>({
resolver: zodResolver(ZNameFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZNameFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'name',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField formControl={form.control} />
<EditorGenericTextAlignField formControl={form.control} />
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,277 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
type TNumberFieldMeta as NumberFieldMeta,
ZNumberFieldMeta,
} from '@documenso/lib/types/field-meta';
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator';
import {
EditorGenericFontSizeField,
EditorGenericLabelField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
} from './editor-field-generic-field-forms';
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
label: true,
placeholder: true,
value: true,
numberFormat: true,
fontSize: true,
textAlign: true,
required: true,
readOnly: true,
minValue: true,
maxValue: true,
})
.refine(
(data) => {
// Minimum value cannot be greater than maximum value
if (typeof data.minValue === 'number' && typeof data.maxValue === 'number') {
return data.minValue <= data.maxValue;
}
return true;
},
{
message: 'Minimum value cannot be greater than maximum value',
path: ['minValue'],
},
)
.refine(
(data) => {
// A read-only field must have a value greater than 0
if (data.readOnly && data.value !== undefined && data.value !== '') {
const numberValue = parseFloat(data.value);
return !isNaN(numberValue) && numberValue > 0;
}
return !data.readOnly || (data.value !== undefined && data.value !== '');
},
{
message: 'A read-only field must have a value greater than 0',
path: ['value'],
},
);
type TNumberFieldFormSchema = z.infer<typeof ZNumberFieldFormSchema>;
type EditorFieldNumberFormProps = {
value: NumberFieldMeta | undefined;
onValueChange: (value: NumberFieldMeta) => void;
};
export const EditorFieldNumberForm = ({
value = {
type: 'number',
},
onValueChange,
}: EditorFieldNumberFormProps) => {
const { t } = useLingui();
const form = useForm<TNumberFieldFormSchema>({
resolver: zodResolver(ZNumberFieldFormSchema),
mode: 'onChange',
defaultValues: {
label: value.label || '',
placeholder: value.placeholder || '',
value: value.value || '',
numberFormat: value.numberFormat || null,
fontSize: value.fontSize || 14,
textAlign: value.textAlign || 'left',
required: value.required || false,
readOnly: value.readOnly || false,
minValue: value.minValue,
maxValue: value.maxValue,
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'number',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericLabelField formControl={form.control} />
<FormField
control={form.control}
name="placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Value</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Value`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="numberFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Number format</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Field format`} />
</SelectTrigger>
<SelectContent position="popper">
{numberFormatValues.map((item, index) => (
<SelectItem key={index} value={item.value}>
{item.label}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>None</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>
<EditorGenericReadOnlyField formControl={form.control} />
{/* Validation section */}
<section className="space-y-2">
<div className="-mx-4 mb-4 mt-2">
<Separator />
</div>
<p className="text-sm font-medium">
<Trans>Validation</Trans>
</p>
<div className="flex flex-row gap-x-4">
<FormField
control={form.control}
name="minValue"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Min</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
placeholder="E.g. 0"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value === '' ? null : e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxValue"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Max</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
placeholder="E.g. 100"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value === '' ? null : e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,190 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Separator } from '@documenso/ui/primitives/separator';
import {
EditorGenericReadOnlyField,
EditorGenericRequiredField,
} from './editor-field-generic-field-forms';
const ZRadioFieldFormSchema = z
.object({
label: z.string().optional(),
values: z
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
.array()
.min(1)
.optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// There cannot be more than one checked option
if (data.values) {
const checkedValues = data.values.filter((option) => option.checked);
return checkedValues.length <= 1;
}
return true;
},
{
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
export type EditorFieldRadioFormProps = {
value: RadioFieldMeta | undefined;
onValueChange: (value: RadioFieldMeta) => void;
};
export const EditorFieldRadioForm = ({
value = {
type: 'radio',
},
onValueChange,
}: EditorFieldRadioFormProps) => {
const form = useForm<TRadioFieldFormSchema>({
resolver: zodResolver(ZRadioFieldFormSchema),
mode: 'onChange',
defaultValues: {
label: value.label || '',
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false,
readOnly: value.readOnly || false,
},
});
const formValues = useWatch({
control: form.control,
});
const addValue = () => {
const currentValues = form.getValues('values') || [];
const newId =
currentValues.length > 0 ? Math.max(...currentValues.map((val) => val.id)) + 1 : 1;
const newValues = [...currentValues, { id: newId, checked: false, value: '' }];
form.setValue('values', newValues);
};
const removeValue = (index: number) => {
const currentValues = form.getValues('values') || [];
if (currentValues.length === 1) {
return;
}
const newValues = [...currentValues];
newValues.splice(index, 1);
form.setValue('values', newValues);
};
useEffect(() => {
const validatedFormValues = ZRadioFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'radio',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2 pb-2">
<EditorGenericRequiredField formControl={form.control} />
<EditorGenericReadOnlyField formControl={form.control} />
<section className="space-y-2">
<div className="-mx-4 mb-4 mt-2">
<Separator />
</div>
<div className="flex flex-row items-center justify-between gap-2">
<p className="text-sm font-medium">
<Trans>Radio values</Trans>
</p>
<button type="button" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<ul className="space-y-2">
{(formValues.values || []).map((value, index) => (
<li key={`radio-value-${index}`} className="flex flex-row items-center gap-2">
<FormField
control={form.control}
name={`values.${index}.checked`}
render={({ field }) => (
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
checked={field.value}
onCheckedChange={(value) => {
// Uncheck all other values.
const currentValues = form.getValues('values') || [];
if (value) {
const newValues = currentValues.map((val) => ({
...val,
checked: false,
}));
form.setValue('values', newValues);
}
field.onChange(value);
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`values.${index}.value`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
</FormControl>
</FormItem>
)}
/>
<button
type="button"
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
<Trash className="h-5 w-5" />
</button>
</li>
))}
</ul>
</section>
</fieldset>
</form>
</Form>
);
};

View File

@ -0,0 +1,191 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import {
EditorGenericFontSizeField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
} from './editor-field-generic-field-forms';
const ZTextFieldFormSchema = z
.object({
label: z.string().optional(),
placeholder: z.string().optional(),
text: z.string().optional(),
characterLimit: z.coerce.number().min(0).optional(),
fontSize: z.coerce.number().min(8).max(96).optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// A read-only field must have text
return !data.readOnly || (data.text && data.text.length > 0);
},
{
message: 'A read-only field must have text',
path: ['text'],
},
);
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
type EditorFieldTextFormProps = {
value: TextFieldMeta | undefined;
onValueChange: (value: TextFieldMeta) => void;
};
export const EditorFieldTextForm = ({
value = {
type: 'text',
},
onValueChange,
}: EditorFieldTextFormProps) => {
const { t } = useLingui();
const form = useForm<TTextFieldFormSchema>({
resolver: zodResolver(ZTextFieldFormSchema),
mode: 'onChange',
defaultValues: {
label: value.label || '',
placeholder: value.placeholder || '',
text: value.text || '',
characterLimit: value.characterLimit || 0,
fontSize: value.fontSize || 14,
textAlign: value.textAlign || 'left',
required: value.required || false,
readOnly: value.readOnly || false,
},
});
const { control } = form;
const formValues = useWatch({
control,
});
// Dupecode/Inefficient: Done because native isValid won't work for our usecase.
useEffect(() => {
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
if (validatedFormValues.success) {
onValueChange({
type: 'text',
...validatedFormValues.data,
});
}
}, [formValues]);
return (
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field placeholder`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Add text</Trans>
</FormLabel>
<FormControl>
<Textarea
className="h-auto"
placeholder={t`Add text to the field`}
{...field}
rows={1}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="characterLimit"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Character Limit</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
className="bg-background"
placeholder={t`Field character limit`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>
<EditorGenericReadOnlyField formControl={form.control} />
</fieldset>
</form>
</Form>
);
};