mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 13:35:20 +10:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4dd6799c7 | |||
| 7fee24042d | |||
| 400b6a24f1 |
@@ -0,0 +1,197 @@
|
||||
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DOCUMENT_NAME_SOURCE = {
|
||||
TEMPLATE: 'template',
|
||||
UPLOAD: 'upload',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
|
||||
type TDocumentNameSource = (typeof DOCUMENT_NAME_SOURCE)[keyof typeof DOCUMENT_NAME_SOURCE];
|
||||
|
||||
type TTemplateUseCustomDocumentData = {
|
||||
data?: File;
|
||||
};
|
||||
|
||||
type TTemplateUseDocumentNameData = {
|
||||
documentNameSource: TDocumentNameSource;
|
||||
customDocumentName: string;
|
||||
customDocumentData?: TTemplateUseCustomDocumentData[];
|
||||
};
|
||||
|
||||
type TTemplateUseDocumentNameFormValues = TTemplateUseDocumentNameData & {
|
||||
useCustomDocument: boolean;
|
||||
};
|
||||
|
||||
const getUploadedDocumentTitle = (customDocumentData?: TTemplateUseCustomDocumentData[]) => {
|
||||
const uploadedFiles = customDocumentData?.map((item) => item.data).filter((file): file is File => file !== undefined);
|
||||
|
||||
if (uploadedFiles?.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return uploadedFiles[0].name.replace(/\.[^/.]+$/, '').trim();
|
||||
};
|
||||
|
||||
export const getTemplateUseDocumentTitle = (data: TTemplateUseDocumentNameData) => {
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD) {
|
||||
return getUploadedDocumentTitle(data.customDocumentData);
|
||||
}
|
||||
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
|
||||
return data.customDocumentName.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const refineTemplateUseDocumentName = (data: TTemplateUseDocumentNameData, ctx: z.RefinementCtx) => {
|
||||
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.TEMPLATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = getTemplateUseDocumentTitle(data);
|
||||
const path = data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM ? 'customDocumentName' : 'documentNameSource';
|
||||
|
||||
if (!title) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM
|
||||
? msg`Document name is required`.id
|
||||
: msg`Select exactly one uploaded document to use its file name.`.id,
|
||||
path: [path],
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.length > DOCUMENT_TITLE_MAX_LENGTH) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: msg`Document name is too long`.id,
|
||||
path: [path],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const TemplateUseDialogDocumentName = () => {
|
||||
const { t } = useLingui();
|
||||
const { clearErrors, control, setValue } = useFormContext<TTemplateUseDocumentNameFormValues>();
|
||||
const [useCustomDocument, documentNameSource, customDocumentData] = useWatch({
|
||||
control,
|
||||
name: ['useCustomDocument', 'documentNameSource', 'customDocumentData'],
|
||||
});
|
||||
|
||||
const uploadedDocumentCount = customDocumentData?.filter((item) => item.data !== undefined).length ?? 0;
|
||||
const canUseUploadedDocumentName = useCustomDocument && uploadedDocumentCount === 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (documentNameSource !== DOCUMENT_NAME_SOURCE.UPLOAD || canUseUploadedDocumentName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
|
||||
clearErrors('documentNameSource');
|
||||
}, [canUseUploadedDocumentName, clearErrors, documentNameSource, setValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={control}
|
||||
name="documentNameSource"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Document name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
aria-label={t`Document name`}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
|
||||
if (value !== DOCUMENT_NAME_SOURCE.CUSTOM) {
|
||||
clearErrors('customDocumentName');
|
||||
}
|
||||
|
||||
if (value !== DOCUMENT_NAME_SOURCE.UPLOAD) {
|
||||
clearErrors('documentNameSource');
|
||||
}
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
|
||||
<label className="text-sm" htmlFor="document-name-source-template">
|
||||
<Trans>Use template name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem
|
||||
id="document-name-source-upload"
|
||||
value={DOCUMENT_NAME_SOURCE.UPLOAD}
|
||||
disabled={!canUseUploadedDocumentName}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
className={cn('text-sm', {
|
||||
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
|
||||
})}
|
||||
htmlFor="document-name-source-upload"
|
||||
>
|
||||
<Trans>Use uploaded file name</Trans>
|
||||
</label>
|
||||
|
||||
{!canUseUploadedDocumentName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Select exactly one uploaded document to use its file name.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
|
||||
<label className="text-sm" htmlFor="document-name-source-custom">
|
||||
<Trans>Enter custom document name</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
|
||||
<FormField
|
||||
control={control}
|
||||
name="customDocumentName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="ml-6">
|
||||
<FormLabel>
|
||||
<Trans>Custom document name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t`Enter a document name`} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -38,27 +38,44 @@ import { useNavigate } from 'react-router';
|
||||
import * as z from 'zod';
|
||||
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
import {
|
||||
DOCUMENT_NAME_SOURCE,
|
||||
getTemplateUseDocumentTitle,
|
||||
refineTemplateUseDocumentName,
|
||||
TemplateUseDialogDocumentName,
|
||||
} from './template-use-dialog-document-name';
|
||||
|
||||
const ZAddRecipientsForNewDocumentSchema = z
|
||||
.object({
|
||||
distributeDocument: z.boolean(),
|
||||
useCustomDocument: z.boolean().default(false),
|
||||
documentNameSource: z.enum([
|
||||
DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
DOCUMENT_NAME_SOURCE.UPLOAD,
|
||||
DOCUMENT_NAME_SOURCE.CUSTOM,
|
||||
]),
|
||||
customDocumentName: z.string(),
|
||||
customDocumentData: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
data: z.instanceof(File).optional(),
|
||||
envelopeItemId: z.string(),
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
refineTemplateUseDocumentName(data, ctx);
|
||||
});
|
||||
|
||||
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||
|
||||
@@ -106,6 +123,8 @@ export function TemplateUseDialog({
|
||||
return {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
|
||||
customDocumentName: '',
|
||||
customDocumentData: envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
@@ -142,6 +161,8 @@ export function TemplateUseDialog({
|
||||
|
||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||
try {
|
||||
const documentTitle = getTemplateUseDocumentTitle(data);
|
||||
|
||||
const customFilesToUpload = (data.customDocumentData || []).filter(
|
||||
(item): item is { data: File; envelopeItemId: string; title: string } =>
|
||||
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
|
||||
@@ -163,6 +184,7 @@ export function TemplateUseDialog({
|
||||
recipients: data.recipients,
|
||||
distributeDocument: data.distributeDocument,
|
||||
customDocumentData,
|
||||
...(documentTitle ? { override: { title: documentTitle } } : {}),
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -195,6 +217,8 @@ export function TemplateUseDialog({
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const useCustomDocument = form.watch('useCustomDocument');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(generateDefaultFormValues());
|
||||
@@ -401,7 +425,16 @@ export function TemplateUseDialog({
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.setValue('customDocumentData', undefined);
|
||||
const customDocumentData = form.getValues('customDocumentData');
|
||||
|
||||
form.setValue(
|
||||
'customDocumentData',
|
||||
customDocumentData?.map((item) => ({
|
||||
...item,
|
||||
data: undefined,
|
||||
})),
|
||||
);
|
||||
form.clearErrors('customDocumentData');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -428,7 +461,7 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('useCustomDocument') && (
|
||||
{useCustomDocument && (
|
||||
<div className="my-4 space-y-2">
|
||||
{isLoadingEnvelopeItems ? (
|
||||
<SpinnerBox className="py-16" />
|
||||
@@ -537,6 +570,7 @@ export function TemplateUseDialog({
|
||||
}
|
||||
|
||||
field.onChange(file);
|
||||
form.clearErrors('customDocumentData');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -550,6 +584,8 @@ export function TemplateUseDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TemplateUseDialogDocumentName />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
|
||||
@@ -96,7 +96,7 @@ export const SignUpForm = ({
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
mode: 'onBlur',
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(ZSignUpFormSchema),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user