Files
zeekayandHanzo Dev 04b66be987 fix(forms): type the document and template flows by the form's input
@hookform/resolvers 5 types a zod resolver as input in, output out: the
form holds z.input of its schema, and only handleSubmit sees z.output. The
flows spelled their forms as the output and dropped the input on the floor,
where a default or a transform makes the two differ: signers' actionAuth and
distributionMethod have defaults, and field meta takes `{}` and makes it
undefined.

Each flow's schema now exports its input beside its output, and whatever
holds or passes on the form's values uses it: the clipboard and last-active
field, the field item, its content and tooltip (which re-parse the meta
anyway), the email checkboxes, and every autosave. Autosave reads
getValues(), which is what the user typed, not what the schema made of it,
so it is typed as that all the way to the mutation, whose request type is
now z.input of the request schema — what the server parses.

Three reads of form state now apply the schema's own default where the
value may be unset: `actionAuth ?? []` in both signer steps and
`distributionMethod ?? EMAIL` in the subject step. Every writer in those
forms sets the value, so nothing reachable changes. Two `value &&
onChange(value)` statements become ifs, which typescript-eslint 8 asks for.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:20:14 -07:00

406 lines
13 KiB
TypeScript

import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { DocumentSignatureType } from '@hanzo/esign-lib/constants/document';
import { isValidLanguageCode } from '@hanzo/esign-lib/constants/i18n';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@hanzo/esign-lib/constants/trpc';
import { ZDocumentAccessAuthTypesSchema } from '@hanzo/esign-lib/types/document-auth';
import type { TTemplate } from '@hanzo/esign-lib/types/template';
import { getDocumentDataUrlForPdfViewer } from '@hanzo/esign-lib/utils/envelope-download';
import type { ZSetFieldsForTemplateRequestSchema } from '@hanzo/esign-trpc/server/field-router/schema';
import type {
ZSetTemplateRecipientsRequestSchema,
ZSetTemplateRecipientsResponseSchema,
} from '@hanzo/esign-trpc/server/recipient-router/schema';
import type { ZUpdateTemplateRequestSchema } from '@hanzo/esign-trpc/server/template-router/schema';
import { useZapMutation, useZapQuery, useZapUtils } from '@hanzo/esign-trpc/zap/react';
import { cn } from '@hanzo/esign-ui/lib/utils';
import { Card, CardContent } from '@hanzo/esign-ui/primitives/card';
import { DocumentFlowFormContainer } from '@hanzo/esign-ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@hanzo/esign-ui/primitives/document-flow/types';
import { Stepper } from '@hanzo/esign-ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@hanzo/esign-ui/primitives/template-flow/add-template-fields';
import type {
TAddTemplateFieldsFormInput,
TAddTemplateFieldsFormSchema,
} from '@hanzo/esign-ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@hanzo/esign-ui/primitives/template-flow/add-template-placeholder-recipients';
import type {
TAddTemplatePlacholderRecipientsFormInput,
TAddTemplatePlacholderRecipientsFormSchema,
} from '@hanzo/esign-ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@hanzo/esign-ui/primitives/template-flow/add-template-settings';
import type { TAddTemplateSettingsFormSchema } from '@hanzo/esign-ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@hanzo/esign-ui/primitives/use-toast';
import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
export type TemplateEditFormProps = {
className?: string;
initialTemplate: TTemplate;
templateRootPath: string;
};
type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const TemplateEditForm = ({
initialTemplate,
className,
templateRootPath,
}: TemplateEditFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [step, setStep] = useState<EditTemplateStep>('settings');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = useZapUtils();
const { data, refetch: refetchTemplate } = useZapQuery<TTemplate>(
'template.getTemplateById',
{
templateId: initialTemplate.id,
},
{
initialData: initialTemplate,
},
);
// `initialData` guarantees a value at runtime; the fallback mirrors the
// optimistic-update default and keeps `template` non-undefined for the type
// checker (the ZAP query result is `T | undefined`, unlike tRPC's narrowed
// `initialData` overload).
const template = data ?? initialTemplate;
const { recipients, fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: msg`General`,
description: msg`Configure general settings for the template.`,
stepIndex: 1,
},
signers: {
title: msg`Add Placeholders`,
description: msg`Add all relevant placeholders for each recipient.`,
stepIndex: 2,
},
fields: {
title: msg`Add Fields`,
description: msg`Add all relevant fields for each recipient.`,
stepIndex: 3,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: updateTemplateSettings } = useZapMutation<
Partial<TTemplate>,
z.infer<typeof ZUpdateTemplateRequestSchema>
>('template.updateTemplate', {
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.setData<TTemplate>(
'template.getTemplateById',
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = useZapMutation<
Partial<TTemplate>,
z.input<typeof ZSetFieldsForTemplateRequestSchema>
>('field.setFieldsForTemplate', {
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.setData<TTemplate>(
'template.getTemplateById',
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: setRecipients } = useZapMutation<
z.infer<typeof ZSetTemplateRecipientsResponseSchema>,
z.input<typeof ZSetTemplateRecipientsRequestSchema>
>('recipient.setTemplateRecipients', {
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.setData<TTemplate>(
'template.getTemplateById',
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
const parsedGlobalAccessAuth = z
.array(ZDocumentAccessAuthTypesSchema)
.safeParse(data.globalAccessAuth);
return updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
globalActionAuth: data.globalActionAuth ?? [],
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
};
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await saveSettingsData(data);
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
};
const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => {
try {
await saveSettingsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template settings.`),
variant: 'destructive',
});
}
};
const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormInput) => {
const [, recipients] = await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers.map((signer) => ({
...signer,
id: signer.nativeId,
})),
}),
]);
return recipients;
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await saveTemplatePlaceholderData(data);
setStep('fields');
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
};
const onAddTemplatePlaceholderFormAutoSave = async (
data: TAddTemplatePlacholderRecipientsFormInput,
) => {
try {
return await saveTemplatePlaceholderData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template placeholders.`),
variant: 'destructive',
});
throw err;
}
};
const saveFieldsData = async (data: TAddTemplateFieldsFormInput) => {
return addTemplateFields({
templateId: template.id,
fields: data.fields.map((field) => ({
...field,
id: field.nativeId,
envelopeItemId: template.templateDocumentData.envelopeItemId,
})),
});
};
const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormInput) => {
try {
await saveFieldsData(data);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while auto-saving the template fields.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await saveFieldsData(data);
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
toast({
title: _(msg`Template saved`),
description: _(msg`Your templates has been saved successfully.`),
duration: 5000,
});
const templatePath = template.folderId
? `${templateRootPath}/f/${template.folderId}`
: templateRootPath;
await navigate(templatePath);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding fields.`),
variant: 'destructive',
});
}
};
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchTemplate();
}, [step]);
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<PDFViewer
key={template.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: initialTemplate.templateDocumentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
currentTeamMemberRole={team.currentTeamRole}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
onAutoSave={onAddSettingsFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={template.id}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
signingOrder={template.templateMeta?.signingOrder}
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial
key={template.id}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
onAutoSave={onAddFieldsFormAutoSave}
teamId={team?.id}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};