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

418 lines
13 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd';
import { useSearchParams } from 'react-router';
import { useElementBounds } from '@hanzo/esign-lib/client-only/hooks/use-element-bounds';
import { useIsPageInDom } from '@hanzo/esign-lib/client-only/hooks/use-is-page-in-dom';
import { PDF_VIEWER_PAGE_SELECTOR } from '@hanzo/esign-lib/constants/pdf-viewer';
import type { TFieldMetaInput } from '@hanzo/esign-lib/types/field-meta';
import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@hanzo/esign-lib/types/field-meta';
import { useRecipientColors } from '../../lib/recipient-colors';
import { cn } from '../../lib/utils';
import { FieldContent } from './field-content';
import type { TDocumentFlowFormInput } from './types';
type Field = TDocumentFlowFormInput['fields'][0];
export type FieldItemProps = {
field: Field;
fieldClassName?: string;
passive?: boolean;
disabled?: boolean;
minHeight?: number;
minWidth?: number;
defaultHeight?: number;
defaultWidth?: number;
onResize?: (_node: HTMLElement) => void;
onMove?: (_node: HTMLElement) => void;
onRemove?: () => void;
onDuplicate?: () => void;
onDuplicateAllPages?: () => void;
onAdvancedSettings?: () => void;
onFocus?: () => void;
onBlur?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
recipientIndex?: number;
hasErrors?: boolean;
active?: boolean;
onFieldActivate?: () => void;
onFieldDeactivate?: () => void;
};
/**
* The item when editing fields??
*/
export const FieldItem = (props: FieldItemProps) => {
const isPageInDom = useIsPageInDom(props.field.pageNumber);
if (!isPageInDom) {
return null;
}
return <FieldItemInner {...props} />;
};
const FieldItemInner = ({
fieldClassName,
field,
passive,
disabled,
minHeight,
minWidth,
defaultHeight,
defaultWidth,
onResize,
onMove,
onRemove,
onDuplicate,
onDuplicateAllPages,
onAdvancedSettings,
onFocus,
onBlur,
recipientIndex = 0,
hasErrors,
active,
onFieldActivate,
onFieldDeactivate,
}: FieldItemProps) => {
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [coords, setCoords] = useState({
pageX: 0,
pageY: 0,
pageHeight: defaultHeight || 0,
pageWidth: defaultWidth || 0,
});
const [settingsActive, setSettingsActive] = useState(false);
const $el = useRef<HTMLDivElement>(null);
const $pageBounds = useElementBounds(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
const signerStyles = useRecipientColors(recipientIndex);
const isDevMode = searchParams.get('devmode') === 'true';
const advancedField = [
'NUMBER',
'RADIO',
'CHECKBOX',
'DROPDOWN',
'TEXT',
'INITIALS',
'EMAIL',
'DATE',
'NAME',
].includes(field.type);
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`,
);
if (!$page) {
return;
}
const { height, width } = $page.getBoundingClientRect();
const top = $page.getBoundingClientRect().top + window.scrollY;
const left = $page.getBoundingClientRect().left + window.scrollX;
// X and Y are percentages of the page's height and width
const pageX = (field.pageX / 100) * width + left;
const pageY = (field.pageY / 100) * height + top;
const pageHeight = (field.pageHeight / 100) * height;
const pageWidth = (field.pageWidth / 100) * width;
setCoords({
pageX: pageX,
pageY: pageY,
pageHeight: pageHeight,
pageWidth: pageWidth,
});
}, [field.pageHeight, field.pageNumber, field.pageWidth, field.pageX, field.pageY]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const onClickOutsideOfField = (event: MouseEvent) => {
const isOutsideOfField = $el.current && !event.composedPath().includes($el.current);
setSettingsActive((active) => {
if (active && isOutsideOfField) {
return false;
}
return active;
});
if (isOutsideOfField) {
setSettingsActive(false);
onFieldDeactivate?.();
onBlur?.();
}
};
document.body.addEventListener('click', onClickOutsideOfField);
return () => {
document.body.removeEventListener('click', onClickOutsideOfField);
};
}, [onBlur]);
const hasFieldMetaValues = (
fieldType: string,
fieldMeta: TFieldMetaInput,
parser: typeof ZCheckboxFieldMeta | typeof ZRadioFieldMeta,
) => {
if (field.type !== fieldType || !fieldMeta) {
return false;
}
const parsedMeta = parser?.parse(fieldMeta);
return parsedMeta && parsedMeta.values && parsedMeta.values.length > 0;
};
const checkBoxHasValues = useMemo(
() => hasFieldMetaValues('CHECKBOX', field.fieldMeta, ZCheckboxFieldMeta),
[field.fieldMeta],
);
const radioHasValues = useMemo(
() => hasFieldMetaValues('RADIO', field.fieldMeta, ZRadioFieldMeta),
[field.fieldMeta],
);
const hasCheckedValues = (fieldMeta: TFieldMetaInput, type: FieldType) => {
if (!fieldMeta || (type !== FieldType.RADIO && type !== FieldType.CHECKBOX)) {
return false;
}
if (type === FieldType.RADIO) {
const parsed = ZRadioFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
if (type === FieldType.CHECKBOX) {
const parsed = ZCheckboxFieldMeta.parse(fieldMeta);
return parsed.values?.some((value) => value.checked) ?? false;
}
return false;
};
const fieldHasCheckedValues = useMemo(
() => hasCheckedValues(field.fieldMeta, field.type),
[field.fieldMeta, field.type],
);
const fixedSize = checkBoxHasValues || radioHasValues;
return createPortal(
<Rnd
key={coords.pageX + coords.pageY + coords.pageHeight + coords.pageWidth}
className={cn('dark-mode-disabled group', {
'pointer-events-none': passive,
'pointer-events-none cursor-not-allowed opacity-75': disabled,
'z-50': active && !disabled,
'z-20': !active && !disabled,
'z-10': disabled,
})}
minHeight={fixedSize ? '' : minHeight || 'auto'}
minWidth={fixedSize ? '' : minWidth || 'auto'}
default={{
x: coords.pageX,
y: coords.pageY,
height: fixedSize ? 'auto' : coords.pageHeight,
width: fixedSize ? 'auto' : coords.pageWidth,
}}
maxWidth={fixedSize && $pageBounds?.width ? $pageBounds.width - coords.pageX : undefined}
bounds={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`}
onDragStart={() => onFieldActivate?.()}
onResizeStart={() => onFieldActivate?.()}
onMouseEnter={() => onFocus?.()}
onMouseLeave={() => onBlur?.()}
enableResizing={!fixedSize}
resizeHandleStyles={{
bottom: { bottom: -8, cursor: 'ns-resize' },
top: { top: -8, cursor: 'ns-resize' },
left: { cursor: 'ew-resize' },
right: { cursor: 'ew-resize' },
}}
onResizeStop={(_e, _d, ref) => {
onFieldDeactivate?.();
onResize?.(ref);
}}
onDragStop={(_e, d) => {
onFieldDeactivate?.();
onMove?.(d.node);
}}
>
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 right-0 left-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'border-primary bg-foreground/5 border': !fieldHasCheckedValues,
'border-primary bg-sign-200 border': fieldHasCheckedValues,
},
)}
>
{field.fieldMeta.label}
</div>
)}
<div
className={cn(
'group/field-item relative flex h-full w-full items-center justify-center rounded-[2px] bg-white/90 px-2 ring-2 transition-colors',
!hasErrors && signerStyles.base,
!hasErrors && signerStyles.fieldItem,
fieldClassName,
{
'rounded-[2px] border bg-red-400/20 shadow-[0_0_0_5px_theme(colors.red.500/10%),0_0_0_2px_theme(colors.red.500/40%),0_0_0_0.5px_theme(colors.red.500)] ring-red-400':
hasErrors,
},
!fixedSize && '[container-type:size]',
)}
data-error={hasErrors ? 'true' : undefined}
onClick={(e) => {
e.stopPropagation();
setSettingsActive((prev) => !prev);
onFieldActivate?.();
onFocus?.();
}}
ref={$el}
data-field-id={field.nativeId}
data-field-type={field.type}
data-recipient-id={field.recipientId}
>
<FieldContent field={field} />
{/* On hover, display recipient initials on side of field. */}
<div className="absolute top-0 -right-5 z-20 hidden h-full w-5 items-center justify-center group-hover:flex">
<div
className={cn(
'flex h-5 w-5 flex-col items-center justify-center rounded-r-md text-[0.5rem] font-bold text-white opacity-0 transition duration-200 group-hover/field-item:opacity-100',
signerStyles.fieldItemInitials,
{
'!opacity-50': disabled || passive,
},
)}
>
{(field.signerEmail?.charAt(0)?.toUpperCase() ?? '') +
(field.signerEmail?.charAt(1)?.toUpperCase() ?? '')}
</div>
</div>
{isDevMode && (
<div className="border-border bg-background/95 absolute -top-20 left-1/2 z-50 -translate-x-1/2 rounded-md border px-2 py-1 shadow-sm backdrop-blur-sm">
<div className="flex flex-col gap-0.5 text-[9px]">
<span>
<span className="text-muted-foreground">
<Trans>Pos X:</Trans>
</span>
&nbsp;
<span className="text-foreground font-mono">{field.pageX.toFixed(2)}</span>
</span>
<span>
<span className="text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>
&nbsp;
<span className="text-foreground font-mono">{field.pageY.toFixed(2)}</span>
</span>
<span>
<span className="text-muted-foreground">
<Trans>Width:</Trans>
</span>
&nbsp;
<span className="text-foreground font-mono">{field.pageWidth.toFixed(2)}</span>
</span>
<span>
<span className="text-muted-foreground">
<Trans>Height:</Trans>
</span>
&nbsp;
<span className="text-foreground font-mono">{field.pageHeight.toFixed(2)}</span>
</span>
</div>
</div>
)}
</div>
{!disabled && settingsActive && (
<div className="absolute z-[60] mt-1 flex w-full items-center justify-center">
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && (
<button
title={_(msg`Advanced settings`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings}
>
<Settings2 className="h-3 w-3" />
</button>
)}
<button
title={_(msg`Duplicate`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate}
onTouchEnd={onDuplicate}
>
<CopyPlus className="h-3 w-3" />
</button>
<button
title={_(msg`Duplicate on all pages`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicateAllPages}
onTouchEnd={onDuplicateAllPages}
>
<SquareStack className="h-3 w-3" />
</button>
<button
title={_(msg`Remove`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove}
onTouchEnd={onRemove}
>
<Trash className="h-3 w-3" />
</button>
</div>
</div>
)}
</Rnd>,
document.body,
);
};