diff --git a/packages/esign/demo/src/App.tsx b/packages/esign/demo/src/App.tsx index b78c8e75e0..18b7e9413b 100644 --- a/packages/esign/demo/src/App.tsx +++ b/packages/esign/demo/src/App.tsx @@ -1,12 +1,6 @@ -import React, { useState, useRef } from 'react'; +import { useState, useRef } from 'react'; import SuperDocESign, { textToImageDataUrl } from '@superdoc-dev/esign'; -import type { - SubmitData, - SigningState, - FieldChange, - DownloadData, - SuperDocESignHandle, -} from '@superdoc-dev/esign'; +import type { SubmitData, SigningState, FieldChange, DownloadData, SuperDocESignHandle } from '@superdoc-dev/esign'; import CustomSignature from './CustomSignature'; import 'superdoc/style.css'; import './App.css'; @@ -14,7 +8,16 @@ import './App.css'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const documentSource = - 'https://storage.googleapis.com/public_static_hosting/public_demo_docs/service_agreement_updated.docx'; + 'https://storage.googleapis.com/public_static_hosting/public_demo_docs/service_agreement_with_table.docx'; + +// Document field definitions with labels +interface DocumentFieldConfig { + id: string; + value: string | string[][]; + type?: 'text' | 'table'; + label?: string; + readOnly?: boolean; +} const signerFieldsConfig = [ { @@ -25,13 +28,13 @@ const signerFieldsConfig = [ component: CustomSignature, }, { - id: 'terms', + id: '1', type: 'checkbox' as const, label: 'I accept the terms and conditions', validation: { required: true }, }, { - id: 'email', + id: '2', type: 'checkbox' as const, label: 'Send me a copy of the agreement', validation: { required: false }, @@ -76,36 +79,43 @@ const downloadBlob = async (response: Response, fileName: string) => { }; // Document field definitions with labels -const documentFieldsConfig = [ +const documentFieldsConfig: DocumentFieldConfig[] = [ { id: '123456', label: 'Date', - defaultValue: new Date().toLocaleDateString(), + value: new Date().toLocaleDateString(), readOnly: true, - type: 'text' as const, + type: 'text', }, { id: '234567', label: 'Full Name', - defaultValue: 'John Doe', + value: 'John Doe', readOnly: false, - type: 'text' as const, + type: 'text', }, { id: '345678', label: 'Company', - defaultValue: 'SuperDoc', + value: 'SuperDoc', readOnly: false, - type: 'text' as const, + type: 'text', }, - { id: '456789', label: 'Plan', defaultValue: 'Premium', readOnly: false, type: 'text' as const }, - { id: '567890', label: 'State', defaultValue: 'CA', readOnly: false, type: 'text' as const }, + { id: '456789', label: 'Plan', value: 'Premium', readOnly: false, type: 'text' as const }, + { id: '567890', label: 'State', value: 'CA', readOnly: false, type: 'text' as const }, { id: '678901', label: 'Address', - defaultValue: '123 Main St, Anytown, USA', + value: '123 Main St, Anytown, USA', + readOnly: false, + type: 'text', + }, + { + id: '238312460', + label: 'User responsibilities', + value: [[' - Provide accurate and complete information']], readOnly: false, - type: 'text' as const, + type: 'table', }, ]; @@ -121,13 +131,20 @@ export function App() { const esignRef = useRef(null); // State for document field values - const [documentFields, setDocumentFields] = useState>(() => - Object.fromEntries(documentFieldsConfig.map((f) => [f.id, f.defaultValue])), + const [documentFields, setDocumentFields] = useState>(() => + Object.fromEntries(documentFieldsConfig.map((f) => [f.id, f.value])), ); - const updateDocumentField = (id: string, value: string) => { + const updateDocumentField = (id: string, value: string | string[][]) => { + const fieldConfig = documentFieldsConfig.find((f) => f.id === id); setDocumentFields((prev) => ({ ...prev, [id]: value })); - esignRef.current?.updateFieldInDocument({ id, value }); + esignRef.current?.updateFieldInDocument({ id, value, type: fieldConfig?.type }); + }; + + // Helper to get table rows as 2D array (for table fields) + const getTableRows = (fieldId: string): string[][] => { + const value = documentFields[fieldId]; + return Array.isArray(value) ? value : []; }; const log = (msg: string) => { @@ -237,17 +254,13 @@ export function App() {

- + @superdoc-dev/esign

React eSign component from{' '} - + SuperDoc

@@ -308,9 +321,9 @@ export function App() { Use the document toolbar to download the current agreement at any time.

-
+
{/* Main content */} -
+
{/* Event Log */} @@ -372,9 +385,9 @@ export function App() {
{/* Right Sidebar - Document Fields */} -
+

Document Fields

-
+
{documentFieldsConfig.map((field) => (
- updateDocumentField(field.id, e.target.value)} - readOnly={field.readOnly} - style={{ - width: '100%', - padding: '8px 10px', - fontSize: '14px', - border: '1px solid #d1d5db', - borderRadius: '6px', - background: field.readOnly ? '#f3f4f6' : 'white', - color: field.readOnly ? '#6b7280' : '#111827', - cursor: field.readOnly ? 'not-allowed' : 'text', - boxSizing: 'border-box', - }} - /> + {field.type === 'table' ? ( +
+ {getTableRows(field.id).map((row, rowIndex) => ( +
+ {row.map((cellValue, cellIndex) => ( + { + const rows = [...getTableRows(field.id)]; + rows[rowIndex] = [...rows[rowIndex]]; + rows[rowIndex][cellIndex] = e.target.value; + updateDocumentField(field.id, rows); + }} + style={{ + flex: 1, + padding: '8px 10px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + boxSizing: 'border-box', + }} + /> + ))} + +
+ ))} + +
+ ) : ( + updateDocumentField(field.id, e.target.value)} + readOnly={field.readOnly} + style={{ + width: '100%', + padding: '8px 10px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + background: field.readOnly ? '#f3f4f6' : 'white', + color: field.readOnly ? '#6b7280' : '#111827', + cursor: field.readOnly ? 'not-allowed' : 'text', + boxSizing: 'border-box', + }} + /> + )}
))}
diff --git a/packages/esign/eslint.config.js b/packages/esign/eslint.config.js index 965db512fc..9419ab23ff 100644 --- a/packages/esign/eslint.config.js +++ b/packages/esign/eslint.config.js @@ -5,25 +5,25 @@ import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; export default [ - { ignores: ['dist/', 'node_modules/'] }, + { ignores: ['dist/', '**/dist/', 'node_modules/', '**/node_modules/'] }, js.configs.recommended, ...tseslint.configs.recommended, { files: ['**/*.{ts,tsx}'], plugins: { react: reactPlugin, - 'react-hooks': reactHooksPlugin + 'react-hooks': reactHooksPlugin, }, settings: { react: { - version: 'detect' - } + version: 'detect', + }, }, rules: { '@typescript-eslint/no-explicit-any': 'warn', 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn' - } + 'react-hooks/exhaustive-deps': 'warn', + }, }, { files: ['demo/server/**/*.js'], @@ -40,8 +40,8 @@ export default [ setInterval: 'readonly', clearInterval: 'readonly', setImmediate: 'readonly', - global: 'readonly' - } - } - } + global: 'readonly', + }, + }, + }, ]; diff --git a/packages/esign/src/__tests__/SuperDocESign.test.tsx b/packages/esign/src/__tests__/SuperDocESign.test.tsx index 568058a3e7..f256ace32f 100644 --- a/packages/esign/src/__tests__/SuperDocESign.test.tsx +++ b/packages/esign/src/__tests__/SuperDocESign.test.tsx @@ -1,15 +1,11 @@ -import React, { createRef } from 'react'; +import { createRef } from 'react'; +import type { FC, ComponentType } from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import SuperDocESign from '../index'; -import type { - FieldComponentProps, - SuperDocESignHandle, - SuperDocESignProps, - AuditEvent, -} from '../types'; +import type { FieldComponentProps, SuperDocESignHandle, SuperDocESignProps, AuditEvent } from '../types'; import { SuperDoc } from 'superdoc'; import { getAuditEventTypes, resetAuditEvents } from '../test/setup'; @@ -85,10 +81,19 @@ const configureScrollElement = (element: HTMLElement, initial: ScrollMetrics) => }; type MockFn = ReturnType; +type MockEditor = { + commands: Record; + helpers: { structuredContentCommands: Record }; + state: Record; + view: { dispatch: MockFn }; +}; type SuperDocMockType = typeof SuperDoc & { mockUpdateStructuredContentById: MockFn; mockGetStructuredContentTags: MockFn; + mockAppendRowsToStructuredContentTable: MockFn; + mockGetStructuredContentTablesById: MockFn; mockDestroy: MockFn; + mockEditor: MockEditor; }; const superDocMock = SuperDoc as unknown as SuperDocMockType; @@ -134,6 +139,9 @@ beforeEach(() => { superDocMock.mockGetStructuredContentTags.mockReset(); superDocMock.mockGetStructuredContentTags.mockReturnValue([]); superDocMock.mockUpdateStructuredContentById.mockReset(); + superDocMock.mockAppendRowsToStructuredContentTable.mockReset(); + superDocMock.mockGetStructuredContentTablesById.mockReset(); + superDocMock.mockGetStructuredContentTablesById.mockReturnValue([]); superDocMock.mockDestroy.mockReset(); resetAuditEvents(); }); @@ -164,7 +172,7 @@ describe('SuperDocESign component', () => { fields: { signer: [ { - id: 'sig-1', + id: '1', type: 'signature', label: 'Signature', validation: { required: true }, @@ -219,7 +227,7 @@ describe('SuperDocESign component', () => { fields: { signer: [ { - id: 'sig-field', + id: '2', type: 'signature', label: 'Signature', validation: { required: true }, @@ -240,15 +248,15 @@ describe('SuperDocESign component', () => { const lastFieldChange = onFieldChange.mock.calls.at(-1)?.[0]; expect(lastFieldChange).toMatchObject({ - id: 'sig-field', + id: '2', value: 'John Doe', }); const lastState = onStateChange.mock.calls.at(-1)?.[0]; - expect(lastState?.fields.get('sig-field')).toBe('John Doe'); + expect(lastState?.fields.get('2')).toBe('John Doe'); expect(superDocMock.mockUpdateStructuredContentById).toHaveBeenCalledWith( - 'sig-field', + '2', expect.objectContaining({ json: expect.objectContaining({ attrs: expect.objectContaining({ src: expect.any(String) }), @@ -270,7 +278,7 @@ describe('SuperDocESign component', () => { fields: { signer: [ { - id: 'sig-field', + id: '2', type: 'signature', label: 'Signature', validation: { required: true }, @@ -327,7 +335,7 @@ describe('SuperDocESign component', () => { isValid: true, isSubmitting: false, }); - expect(stateBeforeReset?.fields.get('sig-field')).toBe('Audit User'); + expect(stateBeforeReset?.fields.get('2')).toBe('Audit User'); act(() => { ref.current?.reset(); @@ -346,31 +354,31 @@ describe('SuperDocESign component', () => { const onSubmit = vi.fn(); const onDownload = vi.fn(); - const CustomField: React.FC = ({ onChange, label }) => ( + const CustomField: FC = ({ onChange, label }) => (
{label} -
); - const SubmitButton: React.FC<{ + const SubmitButton: FC<{ onClick: () => void; isDisabled: boolean; isValid: boolean; isSubmitting: boolean; }> = ({ onClick, isDisabled, isValid }) => ( - ); - const DownloadButton: React.FC<{ + const DownloadButton: FC<{ onClick: () => void; fileName?: string; isDisabled: boolean; }> = ({ onClick, isDisabled }) => ( - ); @@ -381,7 +389,7 @@ describe('SuperDocESign component', () => { fields: { signer: [ { - id: 'custom-field', + id: '3', type: 'text', label: 'Custom Field', component: CustomField, @@ -390,10 +398,10 @@ describe('SuperDocESign component', () => { ], }, submit: { - component: SubmitButton as unknown as React.ComponentType, + component: SubmitButton as unknown as ComponentType, }, download: { - component: DownloadButton as unknown as React.ComponentType, + component: DownloadButton as unknown as ComponentType, }, }); @@ -420,7 +428,7 @@ describe('SuperDocESign component', () => { expect(downloadPayload).toMatchObject({ eventId: 'evt_test', fields: { - signer: [{ id: 'custom-field', value: 'custom-value' }], + signer: [{ id: '3', value: 'custom-value' }], }, fileName: 'document.pdf', }); @@ -439,13 +447,13 @@ describe('SuperDocESign component', () => { fields: { document: [ { - id: 'doc-field', + id: '4', value: 'Document Value', }, ], signer: [ { - id: 'sig-field', + id: '2', type: 'signature', label: 'Signature', validation: { required: true }, @@ -489,12 +497,231 @@ describe('SuperDocESign component', () => { expect(typeof submitData.duration).toBe('number'); expect(submitData.isFullyCompleted).toBe(true); - expect(submitData.documentFields).toEqual([{ id: 'doc-field', value: 'Document Value' }]); + expect(submitData.documentFields).toEqual([{ id: '4', value: 'Document Value' }]); - expect(submitData.signerFields).toEqual([{ id: 'sig-field', value: 'Payload User' }]); + expect(submitData.signerFields).toEqual([{ id: '2', value: 'Payload User' }]); const auditTypes = submitData.auditTrail.map((event: AuditEvent) => event.type); expect(auditTypes).to.include.members(['ready', 'field_change']); expect(auditTypes).to.include('submit'); }); + + describe('table field support', () => { + const mockTableTag = (id: string) => ({ + node: { + attrs: { id }, + type: { name: 'structuredContentBlock' }, + textContent: '', + }, + }); + + it('calls appendRowsToStructuredContentTable for table type fields on initial load', async () => { + // Mock structured content tags to include the table field + superDocMock.mockGetStructuredContentTags.mockReturnValue([mockTableTag('table-1')]); + + // Mock table exists in document (required for append to be called) + const mockTableNode = { childCount: 1, child: () => ({ nodeSize: 10 }) }; + superDocMock.mockGetStructuredContentTablesById.mockReturnValue([{ node: mockTableNode, pos: 100 }]); + + renderComponent({ + fields: { + document: [ + { + id: 'table-1', + type: 'table', + value: [['Row 1 Cell 1'], ['Row 2 Cell 1']], + }, + ], + }, + }); + + await waitForSuperDocReady(); + + await waitFor(() => { + expect(superDocMock.mockAppendRowsToStructuredContentTable).toHaveBeenCalledWith({ + id: 'table-1', + rows: [['Row 1 Cell 1'], ['Row 2 Cell 1']], + copyRowStyle: true, + }); + }); + }); + + it('does not call appendRowsToStructuredContentTable for non-table fields', async () => { + // Mock structured content tags to include the text field + superDocMock.mockGetStructuredContentTags.mockReturnValue([mockTableTag('text-1')]); + + renderComponent({ + fields: { + document: [ + { + id: 'text-1', + value: 'Simple text value', + }, + ], + }, + }); + + await waitForSuperDocReady(); + + await waitFor(() => { + expect(superDocMock.mockUpdateStructuredContentById).toHaveBeenCalledWith('text-1', { + text: 'Simple text value', + }); + }); + + expect(superDocMock.mockAppendRowsToStructuredContentTable).not.toHaveBeenCalled(); + }); + + it('updates table field via ref.updateFieldInDocument', async () => { + const ref = createRef(); + + superDocMock.mockGetStructuredContentTags.mockReturnValue([mockTableTag('table-2')]); + + // Mock table exists in document (required for append to be called) + const mockTableNode = { childCount: 1, child: () => ({ nodeSize: 10 }) }; + superDocMock.mockGetStructuredContentTablesById.mockReturnValue([{ node: mockTableNode, pos: 100 }]); + + renderComponent( + { + fields: { + document: [ + { + id: 'table-2', + type: 'table', + value: [['Initial']], + }, + ], + }, + }, + { ref }, + ); + + await waitForSuperDocReady(); + await waitFor(() => expect(ref.current).toBeTruthy()); + + // Clear the mock to check the update call + superDocMock.mockAppendRowsToStructuredContentTable.mockClear(); + + act(() => { + ref.current?.updateFieldInDocument({ + id: 'table-2', + type: 'table', + value: [['Updated Row 1'], ['Updated Row 2'], ['Updated Row 3']], + }); + }); + + expect(superDocMock.mockAppendRowsToStructuredContentTable).toHaveBeenCalledWith({ + id: 'table-2', + rows: [['Updated Row 1'], ['Updated Row 2'], ['Updated Row 3']], + copyRowStyle: true, + }); + }); + + it('deletes existing rows (except row 0) before appending new ones', async () => { + const ref = createRef(); + + superDocMock.mockGetStructuredContentTags.mockReturnValue([mockTableTag('table-delete')]); + + // Mock a table with 3 existing rows (row 0 = header/template, rows 1-2 = data) + const mockTableNode = { + childCount: 3, + child: () => ({ nodeSize: 10 }), // Each row has size 10 + }; + + superDocMock.mockGetStructuredContentTablesById.mockReturnValue([{ node: mockTableNode, pos: 100 }]); + + // Mock the transaction + const mockMapping = { map: (pos: number) => pos }; + const mockTr = { + mapping: mockMapping, + delete: vi.fn().mockReturnThis(), + }; + + // Get access to the mock editor to set up state.tr + const mockEditor = superDocMock.mockEditor; + mockEditor.state = { tr: mockTr }; + mockEditor.view.dispatch = vi.fn(); + + renderComponent( + { + fields: { + document: [ + { + id: 'table-delete', + type: 'table', + value: [['Initial']], + }, + ], + }, + }, + { ref }, + ); + + await waitForSuperDocReady(); + await waitFor(() => expect(ref.current).toBeTruthy()); + + // Clear mocks before the update + mockTr.delete.mockClear(); + mockEditor.view.dispatch.mockClear(); + superDocMock.mockAppendRowsToStructuredContentTable.mockClear(); + + act(() => { + ref.current?.updateFieldInDocument({ + id: 'table-delete', + type: 'table', + value: [['New Row 1'], ['New Row 2']], + }); + }); + + // Should delete rows 2 and 1 (keeping row 0 as header/template) + expect(mockTr.delete).toHaveBeenCalledTimes(2); + + // Should dispatch the transaction once + expect(mockEditor.view.dispatch).toHaveBeenCalledTimes(1); + expect(mockEditor.view.dispatch).toHaveBeenCalledWith(mockTr); + + // Should append new rows after row 0 with copyRowStyle + expect(superDocMock.mockAppendRowsToStructuredContentTable).toHaveBeenCalledWith({ + id: 'table-delete', + rows: [['New Row 1'], ['New Row 2']], + copyRowStyle: true, + }); + }); + + it('includes table fields in submit payload', async () => { + const onSubmit = vi.fn(); + + superDocMock.mockGetStructuredContentTags.mockReturnValue([mockTableTag('table-3'), mockTableTag('text-field')]); + + const { getByRole } = renderComponent({ + onSubmit, + fields: { + document: [ + { + id: 'table-3', + type: 'table', + value: [['Table Value 1'], ['Table Value 2']], + }, + { + id: 'text-field', + value: 'Text Value', + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const submitButton = getByRole('button', { name: /submit/i }); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + const submitData = onSubmit.mock.calls[0][0]; + expect(submitData.documentFields).toEqual([ + { id: 'table-3', type: 'table', value: [['Table Value 1'], ['Table Value 2']] }, + { id: 'text-field', value: 'Text Value' }, + ]); + }); + }); }); diff --git a/packages/esign/src/index.tsx b/packages/esign/src/index.tsx index 2ff6505c7b..a13e2998bb 100644 --- a/packages/esign/src/index.tsx +++ b/packages/esign/src/index.tsx @@ -2,12 +2,7 @@ import { useRef, useState, useEffect, useCallback, forwardRef, useImperativeHand import type { SuperDoc } from 'superdoc'; import type * as Types from './types'; import { textToImageDataUrl } from './utils/signature'; -import { - SignatureInput, - CheckboxInput, - createDownloadButton, - createSubmitButton, -} from './defaults'; +import { SignatureInput, CheckboxInput, createDownloadButton, createSubmitButton } from './defaults'; export * from './types'; export { textToImageDataUrl }; @@ -15,460 +10,485 @@ export { SignatureInput, CheckboxInput }; type Editor = NonNullable; -const SuperDocESign = forwardRef( - (props, ref) => { - const { - eventId, - document, - fields = {}, - download, - submit, - onSubmit, - onDownload, - onStateChange, - onFieldChange, - onFieldsDiscovered, - isDisabled = false, - className, - style, - documentHeight = '600px', - } = props; - - const [scrolled, setScrolled] = useState(!document.validation?.scroll?.required); - const [fieldValues, setFieldValues] = useState>(new Map()); - const [isValid, setIsValid] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - const [auditTrail, setAuditTrail] = useState([]); - const [isReady, setIsReady] = useState(false); - - const containerRef = useRef(null); - const superdocRef = useRef(null); - const startTimeRef = useRef(Date.now()); - const fieldsRef = useRef(fields); - const auditTrailRef = useRef([]); - const onFieldsDiscoveredRef = useRef(onFieldsDiscovered); - fieldsRef.current = fields; - onFieldsDiscoveredRef.current = onFieldsDiscovered; - - useEffect(() => { - auditTrailRef.current = auditTrail; - }, [auditTrail]); - - const updateFieldInDocument = useCallback((field: Types.FieldUpdate) => { - if (!superdocRef.current?.activeEditor) return; - const editor = superdocRef.current.activeEditor; - - const signerField = fieldsRef.current.signer?.find((f) => f.id === field.id); - - let updatePayload; - - if (signerField?.type === 'signature' && field.value) { - const imageUrl = - typeof field.value === 'string' && field.value.startsWith('data:image/') - ? field.value - : textToImageDataUrl(String(field.value)); - - updatePayload = { - json: { - type: 'image', - attrs: { src: imageUrl, alt: 'Signature' }, - }, - }; - } else { - updatePayload = { text: String(field.value ?? '') }; - } +const SuperDocESign = forwardRef((props, ref) => { + const { + eventId, + document, + fields = {}, + download, + submit, + onSubmit, + onDownload, + onStateChange, + onFieldChange, + onFieldsDiscovered, + isDisabled = false, + className, + style, + documentHeight = '600px', + } = props; + + const [scrolled, setScrolled] = useState(!document.validation?.scroll?.required); + const [fieldValues, setFieldValues] = useState>(new Map()); + const [isValid, setIsValid] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [auditTrail, setAuditTrail] = useState([]); + const [isReady, setIsReady] = useState(false); + + const containerRef = useRef(null); + const superdocRef = useRef(null); + const startTimeRef = useRef(Date.now()); + const fieldsRef = useRef(fields); + const auditTrailRef = useRef([]); + const onFieldsDiscoveredRef = useRef(onFieldsDiscovered); + fieldsRef.current = fields; + onFieldsDiscoveredRef.current = onFieldsDiscovered; + + useEffect(() => { + auditTrailRef.current = auditTrail; + }, [auditTrail]); + + const updateFieldInDocument = useCallback((field: Types.FieldUpdate) => { + if (!superdocRef.current?.activeEditor) return; + const editor = superdocRef.current.activeEditor; + + const signerField = fieldsRef.current.signer?.find((f) => f.id === field.id); + + // Handle table fields + if (field.type === 'table' && Array.isArray(field.value)) { + const helpers = (editor.helpers as any)?.structuredContentCommands; + const tables = helpers?.getStructuredContentTablesById?.(field.id, editor.state) || []; + + if (tables.length) { + const { node: tableNode, pos: tablePos } = tables[0]; + const rowCount = tableNode.childCount; + + // Delete all rows except the first one (template/header row) in a single transaction + if (rowCount > 1) { + let tr = editor.state.tr; + + // Delete from bottom to top to ensure position mapping works correctly + for (let i = rowCount - 1; i >= 1; i--) { + let rowOffset = 1; // Start after table opening + for (let j = 0; j < i; j++) { + rowOffset += tableNode.child(j).nodeSize; + } + + const rowNode = tableNode.child(i); + const rowStart = tablePos + rowOffset; + const rowEnd = rowStart + rowNode.nodeSize; + + tr = tr.delete(tr.mapping.map(rowStart), tr.mapping.map(rowEnd)); + } - if (field.id) { - editor.commands.updateStructuredContentById?.(field.id, updatePayload); + editor.view?.dispatch(tr); + } + + // Append new rows after row 0 (copies style from row 0) + (editor.commands as any)?.appendRowsToStructuredContentTable?.({ + id: field.id, + rows: field.value, + copyRowStyle: true, + }); } - }, []); - const discoverAndApplyFields = useCallback( - (editor: Editor) => { - if (!editor) return; + return; + } - const tags = editor.helpers.structuredContentCommands.getStructuredContentTags( - editor.state, - ); + let updatePayload; - const configValues = new Map(); + if (signerField?.type === 'signature' && field.value) { + const imageUrl = + typeof field.value === 'string' && field.value.startsWith('data:image/') + ? field.value + : textToImageDataUrl(String(field.value)); - fieldsRef.current.document?.forEach((f) => { - if (f.id) configValues.set(f.id, f.value); - }); + updatePayload = { + json: { + type: 'image', + attrs: { src: imageUrl, alt: 'Signature' }, + }, + }; + } else { + updatePayload = { text: String(field.value ?? '') }; + } - fieldsRef.current.signer?.forEach((f) => { - if (f.value !== undefined) { - configValues.set(f.id, f.value); - } - }); + if (field.id) { + editor.commands?.updateStructuredContentById?.(field.id, updatePayload); + } + }, []); + + const discoverAndApplyFields = useCallback( + (editor: Editor) => { + if (!editor) return; + + const tags = editor.helpers.structuredContentCommands.getStructuredContentTags(editor.state); + + const configValues = new Map(); - const discovered: Types.FieldInfo[] = tags - .map(({ node }: any) => ({ - id: node.attrs.id, - label: node.attrs.label, - value: configValues.get(node.attrs.id) ?? node.textContent ?? '', - })) - .filter((f: Types.FieldInfo) => f.id); - - if (discovered.length > 0) { - onFieldsDiscoveredRef.current?.(discovered); - - const allFields = [ - ...(fieldsRef.current.document || []), - ...(fieldsRef.current.signer || []), - ]; - - allFields - .filter((field) => field.value !== undefined) - .forEach((field) => - updateFieldInDocument({ - id: field.id, - value: field.value!, - }), - ); + fieldsRef.current.document?.forEach((f) => { + if (f.id) configValues.set(f.id, f.value); + }); + + fieldsRef.current.signer?.forEach((f) => { + if (f.value !== undefined) { + configValues.set(f.id, f.value); } - }, - [updateFieldInDocument], - ); + }); - const addAuditEvent = (event: Omit): Types.AuditEvent[] => { - const auditEvent: Types.AuditEvent = { - ...event, - timestamp: new Date().toISOString(), - }; - const auditMock = (globalThis as any)?.__SUPERDOC_AUDIT_MOCK__; - if (auditMock) { - auditMock(auditEvent); + const discovered: Types.FieldInfo[] = tags + .map(({ node }: any) => ({ + id: node.attrs.id, + label: node.attrs.label, + value: configValues.get(node.attrs.id) ?? node.textContent ?? '', + })) + .filter((f: Types.FieldInfo) => f.id); + + if (discovered.length > 0) { + onFieldsDiscoveredRef.current?.(discovered); + + // Apply document fields (with type for table support) + (fieldsRef.current.document || []) + .filter((field) => field.value !== undefined) + .forEach((field) => + updateFieldInDocument({ + id: field.id, + value: field.value, + type: field.type, + }), + ); + + // Apply signer fields + (fieldsRef.current.signer || []) + .filter((field) => field.value !== undefined) + .forEach((field) => + updateFieldInDocument({ + id: field.id, + value: field.value!, + }), + ); } - const nextTrail = [...auditTrailRef.current, auditEvent]; - auditTrailRef.current = nextTrail; - setAuditTrail(nextTrail); - return nextTrail; + }, + [updateFieldInDocument], + ); + + const addAuditEvent = (event: Omit): Types.AuditEvent[] => { + const auditEvent: Types.AuditEvent = { + ...event, + timestamp: new Date().toISOString(), }; + const auditMock = (globalThis as any)?.__SUPERDOC_AUDIT_MOCK__; + if (auditMock) { + auditMock(auditEvent); + } + const nextTrail = [...auditTrailRef.current, auditEvent]; + auditTrailRef.current = nextTrail; + setAuditTrail(nextTrail); + return nextTrail; + }; + + // Initialize SuperDoc - uses abort pattern to handle React 18 Strict Mode + // which intentionally double-invokes effects to help identify cleanup issues + useEffect(() => { + if (!containerRef.current) return; + + let aborted = false; + let instance: SuperDoc | null = null; + + const initSuperDoc = async () => { + const { SuperDoc } = await import('superdoc'); + + // If cleanup ran while we were importing, abort + if (aborted) return; + + instance = new SuperDoc({ + selector: containerRef.current!, + document: document.source, + documentMode: 'viewing', + modules: { + comments: false, + }, + // @ts-expect-error - layoutMode is a valid SuperDoc option + layoutMode: document.layoutMode, + layoutMargins: document.layoutMargins, + onReady: () => { + // Guard callback execution if cleanup already ran + if (aborted) return; + if (instance?.activeEditor) { + discoverAndApplyFields(instance.activeEditor); + } + addAuditEvent({ type: 'ready' }); + setIsReady(true); + }, + }); - // Initialize SuperDoc - uses abort pattern to handle React 18 Strict Mode - // which intentionally double-invokes effects to help identify cleanup issues - useEffect(() => { - if (!containerRef.current) return; - - let aborted = false; - let instance: SuperDoc | null = null; - - const initSuperDoc = async () => { - const { SuperDoc } = await import('superdoc'); - - // If cleanup ran while we were importing, abort - if (aborted) return; - - instance = new SuperDoc({ - selector: containerRef.current!, - document: document.source, - documentMode: 'viewing', - modules: { - comments: false, - }, - // @ts-expect-error - layoutMode is not supported in SuperDoc v1.1.0 yet - layoutMode: document.layoutMode, - layoutMargins: document.layoutMargins, - onReady: () => { - // Guard callback execution if cleanup already ran - if (aborted) return; - if (instance?.activeEditor) { - discoverAndApplyFields(instance.activeEditor); - } - addAuditEvent({ type: 'ready' }); - setIsReady(true); - }, - }); - - superdocRef.current = instance; - }; + superdocRef.current = instance; + }; - initSuperDoc(); + initSuperDoc(); - return () => { - aborted = true; - if (instance) { - if (typeof instance.destroy === 'function') { - instance.destroy(); - } - } - superdocRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- compare margin primitives to avoid re-init on every render - }, [ - document.source, - document.mode, - document.layoutMode, - document.layoutMargins?.top, - document.layoutMargins?.bottom, - document.layoutMargins?.left, - document.layoutMargins?.right, - discoverAndApplyFields, - ]); - - useEffect(() => { - if (!document.validation?.scroll?.required || !isReady) return; - - const scrollContainer = containerRef.current; - if (!scrollContainer) return; - - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = scrollContainer; - const scrollPercentage = scrollTop / (scrollHeight - clientHeight); - - if (scrollPercentage >= 0.95 || scrollHeight <= clientHeight) { - setScrolled(true); - addAuditEvent({ - type: 'scroll', - data: { percent: Math.round(scrollPercentage * 100) }, - }); + return () => { + aborted = true; + if (instance) { + if (typeof instance.destroy === 'function') { + instance.destroy(); } - }; - - scrollContainer.addEventListener('scroll', handleScroll); - handleScroll(); + } + superdocRef.current = null; + }; + // Compare margin primitives to avoid re-init on every render + }, [ + document.source, + document.mode, + document.layoutMode, + document.layoutMargins?.top, + document.layoutMargins?.bottom, + document.layoutMargins?.left, + document.layoutMargins?.right, + discoverAndApplyFields, + ]); + + useEffect(() => { + if (!document.validation?.scroll?.required || !isReady) return; + + const scrollContainer = containerRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const scrollPercentage = scrollTop / (scrollHeight - clientHeight); + + if (scrollPercentage >= 0.95 || scrollHeight <= clientHeight) { + setScrolled(true); + addAuditEvent({ + type: 'scroll', + data: { percent: Math.round(scrollPercentage * 100) }, + }); + } + }; - return () => scrollContainer.removeEventListener('scroll', handleScroll); - }, [document.validation?.scroll?.required, isReady]); + scrollContainer.addEventListener('scroll', handleScroll); + handleScroll(); - const handleFieldChange = useCallback( - (fieldId: string, value: Types.FieldValue) => { - setFieldValues((prev) => { - const previousValue = prev.get(fieldId); - const newMap = new Map(prev); - newMap.set(fieldId, value); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [document.validation?.scroll?.required, isReady]); - updateFieldInDocument({ - id: fieldId, - value: value, - }); + const handleFieldChange = useCallback( + (fieldId: string, value: Types.FieldValue) => { + setFieldValues((prev) => { + const previousValue = prev.get(fieldId); + const newMap = new Map(prev); + newMap.set(fieldId, value); - addAuditEvent({ - type: 'field_change', - data: { fieldId, value, previousValue }, - }); + updateFieldInDocument({ + id: fieldId, + value: value, + }); - onFieldChange?.({ - id: fieldId, - value, - previousValue, - }); + addAuditEvent({ + type: 'field_change', + data: { fieldId, value, previousValue }, + }); - return newMap; + onFieldChange?.({ + id: fieldId, + value, + previousValue, }); - }, - [onFieldChange, updateFieldInDocument], - ); - const checkIsValid = useCallback((): boolean => { - if (document.validation?.scroll?.required && !scrolled) { - return false; - } - return (fields.signer || []).every((field) => { - if (!field.validation?.required) return true; - const value = fieldValues.get(field.id); - return value && (typeof value !== 'string' || value.trim()); + return newMap; }); - }, [scrolled, fields.signer, fieldValues, document.validation?.scroll?.required]); - useEffect(() => { - const valid = checkIsValid(); - setIsValid(valid); + }, + [onFieldChange, updateFieldInDocument], + ); + const checkIsValid = useCallback((): boolean => { + if (document.validation?.scroll?.required && !scrolled) { + return false; + } + + return (fields.signer || []).every((field) => { + if (!field.validation?.required) return true; + const value = fieldValues.get(field.id); + return value && (typeof value !== 'string' || value.trim()); + }); + }, [scrolled, fields.signer, fieldValues, document.validation?.scroll?.required]); + useEffect(() => { + const valid = checkIsValid(); + setIsValid(valid); + + const state: Types.SigningState = { + scrolled, + fields: fieldValues, + isValid: valid, + isSubmitting, + }; + onStateChange?.(state); + }, [scrolled, fieldValues, isSubmitting, checkIsValid, onStateChange]); - const state: Types.SigningState = { - scrolled, - fields: fieldValues, - isValid: valid, - isSubmitting, - }; - onStateChange?.(state); - }, [scrolled, fieldValues, isSubmitting, checkIsValid, onStateChange]); - - const handleDownload = useCallback(async () => { - if (isDisabled || isDownloading) return; - - setIsDownloading(true); - - const downloadData: Types.DownloadData = { - eventId, - documentSource: document.source, - fields: { - document: fields.document || [], - signer: (fields.signer || []).map((field) => ({ - id: field.id, - value: fieldValues.get(field.id) ?? null, - })), - }, - fileName: download?.fileName || 'document.pdf', - }; + const handleDownload = useCallback(async () => { + if (isDisabled || isDownloading) return; - try { - await onDownload?.(downloadData); - } finally { - setIsDownloading(false); - } - }, [ - isDisabled, - isDownloading, + setIsDownloading(true); + + const downloadData: Types.DownloadData = { eventId, - document.source, - fields, - fieldValues, - download, - onDownload, - ]); - - const handleSubmit = useCallback(async () => { - if (!isValid || isDisabled || isSubmitting) return; - - setIsSubmitting(true); - const nextAuditTrail = addAuditEvent({ type: 'submit' }); - - const submitData: Types.SubmitData = { - eventId, - timestamp: new Date().toISOString(), - duration: Math.floor((Date.now() - startTimeRef.current) / 1000), - auditTrail: nextAuditTrail, - documentFields: fields.document || [], - signerFields: (fields.signer || []).map((field) => ({ + documentSource: document.source, + fields: { + document: fields.document || [], + signer: (fields.signer || []).map((field) => ({ id: field.id, value: fieldValues.get(field.id) ?? null, })), - isFullyCompleted: isValid, - }; - - try { - await onSubmit(submitData); - } finally { - setIsSubmitting(false); - } - }, [isValid, isDisabled, isSubmitting, eventId, fields, fieldValues, onSubmit]); - - const renderField = (field: Types.SignerField) => { - const Component = field.component || getDefaultComponent(field.type); - - return ( - handleFieldChange(field.id, value)} - isDisabled={isDisabled} - label={field.label} - /> - ); + }, + fileName: download?.fileName || 'document.pdf', }; - const getDefaultComponent = (type: 'signature' | 'checkbox' | 'text') => { - switch (type) { - case 'signature': - case 'text': - return SignatureInput; - case 'checkbox': - return CheckboxInput; - } - }; + try { + await onDownload?.(downloadData); + } finally { + setIsDownloading(false); + } + }, [isDisabled, isDownloading, eventId, document.source, fields, fieldValues, download, onDownload]); - const renderDocumentControls = () => { - const DownloadButton = download?.component || createDownloadButton(download); + const handleSubmit = useCallback(async () => { + if (!isValid || isDisabled || isSubmitting) return; - if (!DownloadButton) return null; + setIsSubmitting(true); + addAuditEvent({ type: 'submit' }); - return ( - - ); + const nextAuditTrail = addAuditEvent({ type: 'submit' }); + + const submitData: Types.SubmitData = { + eventId, + timestamp: new Date().toISOString(), + duration: Math.floor((Date.now() - startTimeRef.current) / 1000), + auditTrail: nextAuditTrail, + documentFields: fields.document || [], + signerFields: (fields.signer || []).map((field) => ({ + id: field.id, + value: fieldValues.get(field.id) ?? null, + })), + isFullyCompleted: isValid, }; - const renderFormActions = () => { - if (document.mode === 'download') { - return null; - } + try { + await onSubmit(submitData); + } finally { + setIsSubmitting(false); + } + }, [isValid, isDisabled, isSubmitting, eventId, fields, fieldValues, onSubmit]); - const SubmitButton = submit?.component || createSubmitButton(submit); - - return ( -
- -
- ); - }; + const renderField = (field: Types.SignerField) => { + const Component = field.component || getDefaultComponent(field.type); - const documentControls = renderDocumentControls(); - const formActions = renderFormActions(); - - useImperativeHandle( - ref, - () => ({ - getState: () => ({ - scrolled, - fields: fieldValues, - isValid, - isSubmitting, - }), - getAuditTrail: () => auditTrailRef.current, - reset: () => { - setScrolled(!document.validation?.scroll?.required); - setFieldValues(new Map()); - setIsValid(false); - auditTrailRef.current = []; - setAuditTrail([]); - }, - updateFieldInDocument, - }), - [ - scrolled, - fieldValues, - isValid, - isSubmitting, - document.validation?.scroll?.required, - updateFieldInDocument, - ], + return ( + handleFieldChange(field.id, value)} + isDisabled={isDisabled} + label={field.label} + /> + ); + }; + + const getDefaultComponent = (type: 'signature' | 'checkbox' | 'text') => { + switch (type) { + case 'signature': + case 'text': + return SignatureInput; + case 'checkbox': + return CheckboxInput; + } + }; + + const renderDocumentControls = () => { + const DownloadButton = download?.component || createDownloadButton(download); + + if (!DownloadButton) return null; + + return ( + ); + }; + + const renderFormActions = () => { + if (document.mode === 'download') { + return null; + } + + const SubmitButton = submit?.component || createSubmitButton(submit); return ( -
- {/* Document viewer section */} -
- {documentControls && ( -
-
{documentControls}
-
- )} -
-
- - {/* Controls section - separate from document */} -
- {/* Signer fields */} - {fields.signer && fields.signer.length > 0 && ( -
- {fields.signer.map(renderField)} -
- )} - - {/* Action buttons */} - {formActions} -
+
+
); - }, -); + }; + + const documentControls = renderDocumentControls(); + const formActions = renderFormActions(); + + useImperativeHandle( + ref, + () => ({ + getState: () => ({ + scrolled, + fields: fieldValues, + isValid, + isSubmitting, + }), + getAuditTrail: () => auditTrailRef.current, + reset: () => { + setScrolled(!document.validation?.scroll?.required); + setFieldValues(new Map()); + setIsValid(false); + auditTrailRef.current = []; + setAuditTrail([]); + }, + updateFieldInDocument, + }), + [scrolled, fieldValues, isValid, isSubmitting, document.validation?.scroll?.required, updateFieldInDocument], + ); + + return ( +
+ {/* Document viewer section */} +
+ {documentControls && ( +
+
{documentControls}
+
+ )} +
+
+ + {/* Controls section - separate from document */} +
+ {/* Signer fields */} + {fields.signer && fields.signer.length > 0 && ( +
+ {fields.signer.map(renderField)} +
+ )} + + {/* Action buttons */} + {formActions} +
+
+ ); +}); SuperDocESign.displayName = 'SuperDocESign'; diff --git a/packages/esign/src/test/setup.ts b/packages/esign/src/test/setup.ts index 8357addd3c..623536d3c6 100644 --- a/packages/esign/src/test/setup.ts +++ b/packages/esign/src/test/setup.ts @@ -17,31 +17,33 @@ export const recordAuditEvent = (type: string, data?: Record) = export const getAuditEventTypes = () => auditEvents.map((event) => event.type); if (typeof window !== 'undefined') { - (window as any).__SUPERDOC_AUDIT_MOCK__ = (event: { - type: string; - data?: Record; - }) => { + (window as any).__SUPERDOC_AUDIT_MOCK__ = (event: { type: string; data?: Record }) => { recordAuditEvent(event.type, event.data); }; } -vi.stubGlobal( - '__SUPERDOC_AUDIT_MOCK__', - (event: { type: string; data?: Record }) => { - recordAuditEvent(event.type, event.data); - }, -); +vi.stubGlobal('__SUPERDOC_AUDIT_MOCK__', (event: { type: string; data?: Record }) => { + recordAuditEvent(event.type, event.data); +}); + +const mockAppendRowsToStructuredContentTable = vi.fn(); +const mockGetStructuredContentTablesById = vi.fn(() => []); const mockEditor = { commands: { updateStructuredContentById: mockUpdateStructuredContentById, + appendRowsToStructuredContentTable: mockAppendRowsToStructuredContentTable, }, helpers: { structuredContentCommands: { getStructuredContentTags: mockGetStructuredContentTags, + getStructuredContentTablesById: mockGetStructuredContentTablesById, }, }, state: {}, + view: { + dispatch: vi.fn(), + }, }; const SuperDocMock = vi.fn((options: any = {}) => { @@ -63,6 +65,8 @@ const SuperDocMock = vi.fn((options: any = {}) => { (SuperDocMock as any).mockEditor = mockEditor; (SuperDocMock as any).mockUpdateStructuredContentById = mockUpdateStructuredContentById; (SuperDocMock as any).mockGetStructuredContentTags = mockGetStructuredContentTags; +(SuperDocMock as any).mockAppendRowsToStructuredContentTable = mockAppendRowsToStructuredContentTable; +(SuperDocMock as any).mockGetStructuredContentTablesById = mockGetStructuredContentTablesById; (SuperDocMock as any).mockDestroy = mockDestroy; (SuperDocMock as any).mockAuditEvents = auditEvents; (SuperDocMock as any).resetAuditEvents = resetAuditEvents; diff --git a/packages/esign/src/types.ts b/packages/esign/src/types.ts index b49e1cbfb3..7fdebd78b0 100644 --- a/packages/esign/src/types.ts +++ b/packages/esign/src/types.ts @@ -1,15 +1,15 @@ import type { SuperDoc } from 'superdoc'; // eslint-disable-line -/** Value types for esign fields */ export type FieldValue = string | boolean | number | null | undefined; +export type TableFieldValue = string[][]; -/** Base interface for field identification */ export interface FieldReference { id: string; } export interface DocumentField extends FieldReference { - value: FieldValue; + type?: 'text' | 'table'; + value: FieldValue | TableFieldValue; } export interface SignerField extends FieldReference { diff --git a/packages/esign/vite.config.ts b/packages/esign/vite.config.ts index 4b2f28074a..ff8bbcde47 100644 --- a/packages/esign/vite.config.ts +++ b/packages/esign/vite.config.ts @@ -20,5 +20,6 @@ export default defineConfig({ globals: true, clearMocks: true, restoreMocks: true, + exclude: ['**/node_modules/**', '**/demo/server/**'], }, });