Files
documenso/apps/web/src/app/(signing)/sign/[token]/number-field.tsx
Lucas Smith 2984af769c feat: add text align option to fields (#1610)
## Description

Adds the ability to align text to the left, center or right for relevant
fields.

Previously text was always centered which can be less desirable.

See attached debug document which has left, center and right text
alignments set for fields.

<img width="614" alt="image"
src="https://github.com/user-attachments/assets/361a030e-813d-458b-9c7a-ff4c9fa5e33c"
/>


## Related Issue

N/A

## Changes Made

- Added text align option
- Update the insert in pdf method to support different alignments
- Added a debug mode to field insertion

## Testing Performed

- Ran manual tests using the debug mode
2025-01-28 12:29:38 +11:00

359 lines
12 KiB
TypeScript

'use client';
import { useEffect, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Hash, Loader } from 'lucide-react';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
isNumber: string[];
required: string[];
minValue: string[];
maxValue: string[];
numberFormat: string[];
};
export type NumberFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [showRadioModal, setShowRadioModal] = useState(false);
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
);
const initialErrors: ValidationErrors = {
isNumber: [],
required: [],
minValue: [],
maxValue: [],
numberFormat: [],
};
const [errors, setErrors] = useState(initialErrors);
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setLocalNumber(text);
if (parsedFieldMeta) {
const validationErrors = validateNumberField(text, parsedFieldMeta, true);
setErrors({
isNumber: validationErrors.filter((error) => error.includes('valid number')),
required: validationErrors.filter((error) => error.includes('required')),
minValue: validationErrors.filter((error) => error.includes('minimum value')),
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
numberFormat: validationErrors.filter((error) => error.includes('number format')),
});
} else {
const validationErrors = validateNumberField(text);
setErrors((prevErrors) => ({
...prevErrors,
isNumber: validationErrors.filter((error) => error.includes('valid number')),
}));
}
};
const onDialogSignClick = () => {
setShowRadioModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localNumber || Object.values(errors).some((error) => error.length > 0)) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value: localNumber,
isBase64: true,
authOptions,
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
setLocalNumber('');
startTransition(() => router.refresh());
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while signing the document.`),
variant: 'destructive',
});
}
};
const onPreSign = () => {
setShowRadioModal(true);
if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
setErrors({
isNumber: validationErrors.filter((error) => error.includes('valid number')),
required: validationErrors.filter((error) => error.includes('required')),
minValue: validationErrors.filter((error) => error.includes('minimum value')),
maxValue: validationErrors.filter((error) => error.includes('maximum value')),
numberFormat: validationErrors.filter((error) => error.includes('number format')),
});
}
return false;
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : '');
startTransition(() => router.refresh());
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`),
variant: 'destructive',
});
}
};
useEffect(() => {
if (!showRadioModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors);
}
}, [showRadioModal]);
useEffect(() => {
if (
(!field.inserted && defaultValue && localNumber) ||
(!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
let fieldDisplayName = 'Number';
if (parsedFieldMeta?.label) {
fieldDisplayName =
parsedFieldMeta.label.length > 10
? parsedFieldMeta.label.substring(0, 10) + '...'
: parsedFieldMeta.label;
}
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
return (
<SigningFieldContainer
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Signature"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
{!field.inserted && (
<p
className={cn(
'group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200',
{
'group-hover:text-yellow-300': !field.inserted && !parsedFieldMeta?.required,
'group-hover:text-red-300': !field.inserted && parsedFieldMeta?.required,
},
)}
>
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
)}
{field.inserted && (
<div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText}
</p>
</div>
)}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
<DialogContent>
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
</DialogTitle>
<div>
<Input
type="text"
placeholder={parsedFieldMeta?.placeholder ?? ''}
className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors,
})}
value={localNumber}
onChange={handleNumberChange}
/>
</div>
{userInputHasErrors && (
<div>
{errors.isNumber?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.required?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.minValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.maxValue?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
{errors.numberFormat?.map((error, index) => (
<p key={index} className="mt-2 text-sm text-red-500">
{error}
</p>
))}
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowRadioModal(false);
setLocalNumber('');
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!localNumber || userInputHasErrors}
onClick={() => onDialogSignClick()}
>
<Trans>Save</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</SigningFieldContainer>
);
};