mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
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:
@ -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';
|
||||
|
||||
31
apps/remix/app/components/forms/editor/constants.ts
Normal file
31
apps/remix/app/components/forms/editor/constants.ts
Normal 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: '<=',
|
||||
},
|
||||
];
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user