feat: add envelope editor

This commit is contained in:
David Nguyen
2025-10-12 23:35:54 +11:00
parent bf89bc781b
commit 0da8e7dbc6
307 changed files with 24657 additions and 3681 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>
);
};