feat: bulk add fields (#1683)

## Demo

![CleanShot 2025-03-04 at 02 17
47](https://github.com/user-attachments/assets/2cffaee3-9933-49e9-bdab-eadfd4c35030)

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
This commit is contained in:
Ephraim Duncan
2025-05-14 19:35:32 +00:00
committed by GitHub
parent 9594e1fee8
commit 99b0ad574e
6 changed files with 153 additions and 62 deletions

View File

@ -173,23 +173,13 @@ export const ConfigureFieldsView = ({
}); });
const onFieldCopy = useCallback( const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => { (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false } = options ?? {}; const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) { if (lastActiveField) {
event?.preventDefault(); event?.preventDefault();
if (!duplicate) { if (duplicate) {
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = { const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField), ...structuredClone(lastActiveField),
nativeId: undefined, nativeId: undefined,
@ -201,6 +191,41 @@ export const ConfigureFieldsView = ({
}; };
append(newField); append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
pageNumber,
};
append(newField);
});
return;
}
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
} }
}, },
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast], [append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
@ -533,6 +558,7 @@ export const ConfigureFieldsView = ({
onMove={(node) => onFieldMove(node, index)} onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)} onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })} onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)} onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)} onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => { onAdvancedSettings={() => {

View File

@ -69,8 +69,6 @@ export const TemplatesTableActionDropdown = ({
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit` ? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
: `${templateRootPath}/${row.id}/edit`; : `${templateRootPath}/${row.id}/edit`;
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger data-testid="template-table-action-btn"> <DropdownMenuTrigger data-testid="template-table-action-btn">

View File

@ -400,25 +400,16 @@ export const AddFieldsFormPartial = ({
); );
const onFieldCopy = useCallback( const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => { (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false } = options ?? {}; const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) { if (lastActiveField) {
event?.preventDefault(); event?.preventDefault();
if (!duplicate) { if (duplicate) {
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = { const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField), ...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageX: lastActiveField.pageX + 3, pageX: lastActiveField.pageX + 3,
@ -426,9 +417,43 @@ export const AddFieldsFormPartial = ({
}; };
append(newField); append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
pageNumber,
};
append(newField);
});
return;
}
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
} }
}, },
[append, lastActiveField, selectedSigner?.email, toast], [append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
); );
const onFieldPaste = useCallback( const onFieldPaste = useCallback(
@ -641,6 +666,7 @@ export const AddFieldsFormPartial = ({
onMove={(options) => onFieldMove(options, index)} onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)} onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })} onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();

View File

@ -311,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
/> />
)) ))
.otherwise(() => null)} .otherwise(() => null)}
{errors.length > 0 && ( {errors.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<ul> <ul>
@ -323,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
</div> </div>
)} )}
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter className="mt-auto"> <DocumentFlowFormContainerFooter className="mt-auto">
<DocumentFlowFormContainerActions <DocumentFlowFormContainerActions
goNextLabel={msg`Save`} goNextLabel={msg`Save`}

View File

@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { CopyPlus, Settings2, Trash } from 'lucide-react'; import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Rnd } from 'react-rnd'; import { Rnd } from 'react-rnd';
@ -29,6 +31,7 @@ export type FieldItemProps = {
onMove?: (_node: HTMLElement) => void; onMove?: (_node: HTMLElement) => void;
onRemove?: () => void; onRemove?: () => void;
onDuplicate?: () => void; onDuplicate?: () => void;
onDuplicateAllPages?: () => void;
onAdvancedSettings?: () => void; onAdvancedSettings?: () => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
@ -55,15 +58,18 @@ export const FieldItem = ({
onMove, onMove,
onRemove, onRemove,
onDuplicate, onDuplicate,
onDuplicateAllPages,
onAdvancedSettings,
onFocus, onFocus,
onBlur, onBlur,
onAdvancedSettings,
recipientIndex = 0, recipientIndex = 0,
hasErrors, hasErrors,
active, active,
onFieldActivate, onFieldActivate,
onFieldDeactivate, onFieldDeactivate,
}: FieldItemProps) => { }: FieldItemProps) => {
const { _ } = useLingui();
const [coords, setCoords] = useState({ const [coords, setCoords] = useState({
pageX: 0, pageX: 0,
pageY: 0, pageY: 0,
@ -304,6 +310,7 @@ export const FieldItem = ({
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5"> <div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
{advancedField && ( {advancedField && (
<button <button
title={_(msg`Advanced settings`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100" className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onAdvancedSettings} onClick={onAdvancedSettings}
onTouchEnd={onAdvancedSettings} onTouchEnd={onAdvancedSettings}
@ -313,6 +320,7 @@ export const FieldItem = ({
)} )}
<button <button
title={_(msg`Duplicate`)}
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100" className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onDuplicate} onClick={onDuplicate}
onTouchEnd={onDuplicate} onTouchEnd={onDuplicate}
@ -321,6 +329,16 @@ export const FieldItem = ({
</button> </button>
<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" className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
onClick={onRemove} onClick={onRemove}
onTouchEnd={onRemove} onTouchEnd={onRemove}

View File

@ -139,25 +139,16 @@ export const AddTemplateFieldsFormPartial = ({
); );
const onFieldCopy = useCallback( const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => { (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false } = options ?? {}; const { duplicate = false, duplicateAll = false } = options ?? {};
if (lastActiveField) { if (lastActiveField) {
event?.preventDefault(); event?.preventDefault();
if (!duplicate) { if (duplicate) {
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = { const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField), ...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12), formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId, signerId: selectedSigner?.id ?? lastActiveField.signerId,
@ -167,16 +158,45 @@ export const AddTemplateFieldsFormPartial = ({
}; };
append(newField); append(newField);
return;
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (pageNumber === lastActiveField.pageNumber) {
return;
}
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
...structuredClone(lastActiveField),
nativeId: undefined,
formId: nanoid(12),
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
signerId: selectedSigner?.id ?? lastActiveField.signerId,
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
pageNumber,
};
append(newField);
});
return;
}
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
});
} }
}, },
[ [append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
append,
lastActiveField,
selectedSigner?.email,
selectedSigner?.id,
selectedSigner?.token,
toast,
],
); );
const onFieldPaste = useCallback( const onFieldPaste = useCallback(
@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
onMove={(options) => onFieldMove(options, index)} onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)} onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })} onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onAdvancedSettings={() => { onAdvancedSettings={() => {
setCurrentField(field); setCurrentField(field);
handleAdvancedSettings(); handleAdvancedSettings();