Files
sign/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx

355 lines
12 KiB
TypeScript
Raw Normal View History

2025-01-02 15:33:37 +11:00
import { useEffect, useState } from 'react';
2024-02-17 08:26:30 +00:00
2025-01-02 15:33:37 +11:00
import { msg } from '@lingui/core/macro';
2024-08-27 20:34:39 +09:00
import { useLingui } from '@lingui/react';
2025-01-02 15:33:37 +11:00
import { Plural, Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
2024-02-17 08:26:30 +00:00
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
fix: update document flow fetch logic (#1039) ## Description **Fixes issues with mismatching state between document steps.** For example, editing a recipient and proceeding to the next step may not display the updated recipient. And going back will display the old recipient instead of the updated values. **This PR also improves mutation and query speeds by adding logic to bypass query invalidation.** ```ts export const trpc = createTRPCReact<AppRouter>({ unstable_overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // This forces mutations to wait for all the queries on the page to reload, and in // this case one of the queries is `searchDocument` for the command overlay, which // on average takes ~500ms. This means that every single mutation must wait for this. await opts.queryClient.invalidateQueries(); }, }, }, }); ``` I've added workarounds to allow us to bypass things such as batching and invalidating queries. But I think we should instead remove this and update all the mutations where a query is required for a more optimised system. ## Example benchmarks Using stg-app vs this preview there's an average 50% speed increase across mutations. **Set signer step:** Average old speed: ~1100ms Average new speed: ~550ms **Set recipient step:** Average old speed: ~1200ms Average new speed: ~600ms **Set fields step:** Average old speed: ~1200ms Average new speed: ~600ms ## Related Issue This will resolve #470 ## Changes Made - Added ability to skip batch queries - Added a state to store the required document data. - Refetch the data between steps if/when required - Optimise mutations and queries ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --------- Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-03-26 21:12:41 +08:00
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
2024-03-28 13:13:29 +08:00
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
2024-02-17 08:26:30 +00:00
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
2024-02-17 08:26:30 +00:00
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Textarea } from '@documenso/ui/primitives/textarea';
2024-02-17 08:26:30 +00:00
import { useToast } from '@documenso/ui/primitives/use-toast';
2025-01-02 15:33:37 +11:00
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningFieldContainer } from './document-signing-field-container';
2025-03-11 21:46:12 +11:00
import {
DocumentSigningFieldsInserted,
DocumentSigningFieldsLoader,
DocumentSigningFieldsUninserted,
} from './document-signing-fields';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
2024-02-17 08:26:30 +00:00
2025-01-02 15:33:37 +11:00
export type DocumentSigningTextFieldProps = {
field: FieldWithSignatureAndFieldMeta;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
type ValidationErrors = {
required: string[];
characterLimit: string[];
};
export type TextFieldProps = {
field: FieldWithSignatureAndFieldMeta;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
2024-02-17 08:26:30 +00:00
};
2025-01-02 15:33:37 +11:00
export const DocumentSigningTextField = ({
field,
onSignField,
onUnsignField,
}: DocumentSigningTextFieldProps) => {
2024-08-27 20:34:39 +09:00
const { _ } = useLingui();
2024-02-17 08:26:30 +00:00
const { toast } = useToast();
2025-01-02 15:33:37 +11:00
const { revalidate } = useRevalidator();
2024-08-27 20:34:39 +09:00
const { recipient, isAssistantMode } = useDocumentSigningRecipientContext();
const initialErrors: ValidationErrors = {
required: [],
characterLimit: [],
};
const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
2025-01-02 15:33:37 +11:00
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
2024-02-17 08:26:30 +00:00
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
fix: update document flow fetch logic (#1039) ## Description **Fixes issues with mismatching state between document steps.** For example, editing a recipient and proceeding to the next step may not display the updated recipient. And going back will display the old recipient instead of the updated values. **This PR also improves mutation and query speeds by adding logic to bypass query invalidation.** ```ts export const trpc = createTRPCReact<AppRouter>({ unstable_overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // This forces mutations to wait for all the queries on the page to reload, and in // this case one of the queries is `searchDocument` for the command overlay, which // on average takes ~500ms. This means that every single mutation must wait for this. await opts.queryClient.invalidateQueries(); }, }, }, }); ``` I've added workarounds to allow us to bypass things such as batching and invalidating queries. But I think we should instead remove this and update all the mutations where a query is required for a more optimised system. ## Example benchmarks Using stg-app vs this preview there's an average 50% speed increase across mutations. **Set signer step:** Average old speed: ~1100ms Average new speed: ~550ms **Set recipient step:** Average old speed: ~1200ms Average new speed: ~600ms **Set fields step:** Average old speed: ~1200ms Average new speed: ~600ms ## Related Issue This will resolve #470 ## Changes Made - Added ability to skip batch queries - Added a state to store the required document data. - Refetch the data between steps if/when required - Optimise mutations and queries ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --------- Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-03-26 21:12:41 +08:00
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
2024-02-17 08:26:30 +00:00
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
fix: update document flow fetch logic (#1039) ## Description **Fixes issues with mismatching state between document steps.** For example, editing a recipient and proceeding to the next step may not display the updated recipient. And going back will display the old recipient instead of the updated values. **This PR also improves mutation and query speeds by adding logic to bypass query invalidation.** ```ts export const trpc = createTRPCReact<AppRouter>({ unstable_overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // This forces mutations to wait for all the queries on the page to reload, and in // this case one of the queries is `searchDocument` for the command overlay, which // on average takes ~500ms. This means that every single mutation must wait for this. await opts.queryClient.invalidateQueries(); }, }, }, }); ``` I've added workarounds to allow us to bypass things such as batching and invalidating queries. But I think we should instead remove this and update all the mutations where a query is required for a more optimised system. ## Example benchmarks Using stg-app vs this preview there's an average 50% speed increase across mutations. **Set signer step:** Average old speed: ~1100ms Average new speed: ~550ms **Set recipient step:** Average old speed: ~1200ms Average new speed: ~600ms **Set fields step:** Average old speed: ~1200ms Average new speed: ~600ms ## Related Issue This will resolve #470 ## Changes Made - Added ability to skip batch queries - Added a state to store the required document data. - Refetch the data between steps if/when required - Optimise mutations and queries ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --------- Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-03-26 21:12:41 +08:00
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
2024-02-17 08:26:30 +00:00
const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
2025-01-02 15:33:37 +11:00
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField =
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
2024-02-17 08:26:30 +00:00
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
2024-02-17 08:26:30 +00:00
useEffect(() => {
2024-03-28 13:13:29 +08:00
if (!showCustomTextModal) {
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
2024-02-17 08:26:30 +00:00
}
2024-03-28 13:13:29 +08:00
}, [showCustomTextModal]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
setLocalCustomText(text);
if (parsedFieldMeta) {
const validationErrors = validateTextField(text, parsedFieldMeta, true);
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
}
};
2024-03-28 13:13:29 +08:00
/**
* When the user clicks the sign button in the dialog where they enter the text field.
*/
const onDialogSignClick = () => {
if (parsedFieldMeta) {
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
if (validationErrors.length > 0) {
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
return;
}
}
2024-03-28 13:13:29 +08:00
setShowCustomTextModal(false);
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
};
2024-02-17 08:26:30 +00:00
2024-03-28 13:13:29 +08:00
const onPreSign = () => {
setShowCustomTextModal(true);
if (localText && parsedFieldMeta) {
const validationErrors = validateTextField(localText, parsedFieldMeta, true);
setErrors({
required: validationErrors.filter((error) => error.includes('required')),
characterLimit: validationErrors.filter((error) => error.includes('character limit')),
});
2024-03-28 13:13:29 +08:00
}
return false;
2024-03-28 13:13:29 +08:00
};
2024-02-17 08:26:30 +00:00
2024-03-28 13:13:29 +08:00
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!localText || userInputHasErrors) {
2024-02-17 08:26:30 +00:00
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
2024-02-17 08:26:30 +00:00
token: recipient.token,
fieldId: field.id,
value: localText,
2024-02-17 08:26:30 +00:00
isBase64: true,
2024-03-28 13:13:29 +08:00
authOptions,
};
if (onSignField) {
await onSignField(payload);
return;
}
await signFieldWithToken(payload);
2024-02-17 08:26:30 +00:00
setLocalCustomText('');
2025-01-02 15:33:37 +11:00
await revalidate();
2024-02-17 08:26:30 +00:00
} catch (err) {
2024-03-28 13:13:29 +08:00
const error = AppError.parseError(err);
if (error.code === AppErrorCode.UNAUTHORIZED) {
throw error;
}
2024-02-17 08:26:30 +00:00
console.error(err);
toast({
2024-08-27 20:34:39 +09:00
title: _(msg`Error`),
description: isAssistantMode
? _(msg`An error occurred while signing as assistant.`)
: _(msg`An error occurred while signing the document.`),
2024-02-17 08:26:30 +00:00
variant: 'destructive',
});
}
};
const onRemove = async () => {
try {
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
2024-02-17 08:26:30 +00:00
token: recipient.token,
fieldId: field.id,
};
if (onUnsignField) {
await onUnsignField(payload);
return;
}
await removeSignedFieldWithToken(payload);
2024-02-17 08:26:30 +00:00
setLocalCustomText(parsedFieldMeta?.text ?? '');
2025-01-02 15:33:37 +11:00
await revalidate();
2024-02-17 08:26:30 +00:00
} catch (err) {
console.error(err);
toast({
2024-08-27 20:34:39 +09:00
title: _(msg`Error`),
description: _(msg`An error occurred while removing the field.`),
2024-02-17 08:26:30 +00:00
variant: 'destructive',
});
}
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
const labelDisplay =
parsedField?.label && parsedField.label.length < 20
? parsedField.label
: parsedField?.label
? parsedField?.label.substring(0, 20) + '...'
: undefined;
const textDisplay =
parsedField?.text && parsedField.text.length < 20
? parsedField.text
: parsedField?.text
? parsedField?.text.substring(0, 20) + '...'
: undefined;
2024-08-27 20:34:39 +09:00
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
2024-02-17 08:26:30 +00:00
return (
2025-01-02 15:33:37 +11:00
<DocumentSigningFieldContainer
2024-03-28 13:13:29 +08:00
field={field}
onPreSign={onPreSign}
onSign={onSign}
onRemove={onRemove}
type="Text"
2024-03-28 13:13:29 +08:00
>
2025-03-11 21:46:12 +11:00
{isLoading && <DocumentSigningFieldsLoader />}
2024-02-17 08:26:30 +00:00
{!field.inserted && (
2025-03-11 21:46:12 +11:00
<DocumentSigningFieldsUninserted>
{fieldDisplayName || <Trans>Text</Trans>}
</DocumentSigningFieldsUninserted>
2024-02-17 08:26:30 +00:00
)}
{field.inserted && (
2025-03-11 21:46:12 +11:00
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 20) + '...'}
</DocumentSigningFieldsInserted>
)}
2024-02-17 08:26:30 +00:00
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
<DialogContent>
2024-08-27 20:34:39 +09:00
<DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Text</Trans>}
2024-08-27 20:34:39 +09:00
</DialogTitle>
2024-02-17 08:26:30 +00:00
<div>
<Textarea
2024-02-17 08:26:30 +00:00
id="custom-text"
2024-08-27 20:34:39 +09:00
placeholder={parsedFieldMeta?.placeholder ?? _(msg`Enter your text here`)}
className={cn('mt-2 w-full rounded-md', {
2025-03-11 21:46:12 +11:00
'border-2 border-red-300 text-left 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,
2025-03-11 21:46:12 +11:00
'text-center': parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})}
value={localText}
onChange={handleTextChange}
2024-02-17 08:26:30 +00:00
/>
</div>
{parsedFieldMeta?.characterLimit !== undefined &&
parsedFieldMeta?.characterLimit > 0 &&
!userInputHasErrors && (
<div className="text-muted-foreground text-sm">
2024-08-27 20:34:39 +09:00
<Plural
value={charactersRemaining}
one="1 character remaining"
other={`${charactersRemaining} characters remaining`}
/>
</div>
)}
{userInputHasErrors && (
<div className="text-sm">
{errors.required.map((error, index) => (
<p key={index} className="text-red-500">
{error}
</p>
))}
{errors.characterLimit.map((error, index) => (
<p key={index} className="text-red-500">
{error}{' '}
2024-08-27 20:34:39 +09:00
{charactersRemaining < 0 && (
<Plural
value={Math.abs(charactersRemaining)}
one="(1 character over)"
other="(# characters over)"
/>
)}
</p>
))}
</div>
)}
2024-02-17 08:26:30 +00:00
<DialogFooter>
<div className="mt-4 flex w-full flex-1 flex-nowrap gap-4">
2024-02-17 08:26:30 +00:00
<Button
type="button"
variant="secondary"
2025-03-11 21:46:12 +11:00
className="flex-1"
2024-02-17 08:26:30 +00:00
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
}}
>
2024-08-27 20:34:39 +09:00
<Trans>Cancel</Trans>
2024-02-17 08:26:30 +00:00
</Button>
<Button
type="button"
className="flex-1"
disabled={!localText || userInputHasErrors}
2024-03-28 13:13:29 +08:00
onClick={() => onDialogSignClick()}
2024-02-17 08:26:30 +00:00
>
2024-08-27 20:34:39 +09:00
<Trans>Save</Trans>
2024-02-17 08:26:30 +00:00
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
2025-01-02 15:33:37 +11:00
</DocumentSigningFieldContainer>
2024-02-17 08:26:30 +00:00
);
};