+ React eSign component from{' '} + + SuperDoc + +
+Event ID: {submitData?.eventId}
+ {submitData?.signerFields.find((f) => f.id === 'signature') && ( +Signature:
++ Use the document toolbar to download the current agreement at any time. +
+ +Test Document
', + mode: 'full', + validation: { + scroll: { + required: false, + }, + }, +}; + +const renderComponent = ( + props: PartialScroll document
', + mode: 'full', + validation: { scroll: { required: true } }, + }, + fields: { + signer: [ + { + id: 'sig-1', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }, + { ref }, + ); + + const scrollContainer = getByTestId('superdoc-scroll-container'); + const scrollController = configureScrollElement(scrollContainer, { + scrollHeight: 200, + clientHeight: 100, + scrollTop: 0, + }); + + await waitForSuperDocReady(); + + const submitButton = getByRole('button', { name: /submit/i }); + const input = getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + + await waitFor(() => expect(submitButton).toBeDisabled()); + + act(() => { + scrollController.update({ + scrollHeight: 1000, + clientHeight: 100, + scrollTop: 950, + }); + }); + + await waitFor(() => { + const state = ref.current?.getState(); + expect(state?.scrolled).toBe(true); + expect(state?.isValid).toBe(true); + }); + + const updatedSubmitButton = getByRole('button', { name: /submit/i }); + await userEvent.click(updatedSubmitButton); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + + it('invokes field and state change callbacks and updates SuperDoc document', async () => { + const onFieldChange = vi.fn(); + const onStateChange = vi.fn(); + + const { getByPlaceholderText } = renderComponent({ + onFieldChange, + onStateChange, + fields: { + signer: [ + { + id: 'sig-field', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const input = getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'John Doe' } }); + + await waitFor(() => { + expect(onFieldChange).toHaveBeenCalled(); + expect(onStateChange).toHaveBeenCalled(); + }); + + const lastFieldChange = onFieldChange.mock.calls.at(-1)?.[0]; + expect(lastFieldChange).toMatchObject({ + id: 'sig-field', + value: 'John Doe', + }); + + const lastState = onStateChange.mock.calls.at(-1)?.[0]; + expect(lastState?.fields.get('sig-field')).toBe('John Doe'); + + expect(superDocMock.mockUpdateStructuredContentById).toHaveBeenCalledWith( + 'sig-field', + expect.objectContaining({ + json: expect.objectContaining({ + attrs: expect.objectContaining({ src: expect.any(String) }), + }), + }), + ); + }); + + it('tracks audit trail and exposes ref methods', async () => { + const ref = createRefScroll doc
', + mode: 'full', + validation: { scroll: { required: true } }, + }, + fields: { + signer: [ + { + id: 'sig-field', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }, + { ref }, + ); + + await waitForSuperDocReady(); + await waitFor(() => expect(ref.current).toBeTruthy()); + + const scrollContainer = getByTestId('superdoc-scroll-container'); + const scrollController = configureScrollElement(scrollContainer, { + scrollHeight: 300, + clientHeight: 100, + scrollTop: 0, + }); + + const input = getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'Audit User' } }); + + act(() => { + scrollController.update({ scrollTop: 250 }); + }); + + const submitButton = getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled(), { + timeout: 2000, + }); + await userEvent.click(submitButton); + + await waitFor(() => { + const auditTrail = ref.current?.getAuditTrail() ?? []; + expect(auditTrail.length).toBeGreaterThanOrEqual(4); + }); + + const auditTrail = ref.current?.getAuditTrail() ?? []; + const types = auditTrail.map((event) => event.type); + expect(types[0]).toBe('ready'); + expect(types).to.include('field_change'); + expect(types.filter((type) => type === 'scroll').length).toBeGreaterThanOrEqual(1); + expect(types).to.include('submit'); + + auditTrail.forEach((event: AuditEvent) => { + expect(typeof event.timestamp).toBe('string'); + expect(Number.isNaN(new Date(event.timestamp).getTime())).toBe(false); + }); + + const stateBeforeReset = ref.current?.getState(); + expect(stateBeforeReset).toMatchObject({ + scrolled: true, + isValid: true, + isSubmitting: false, + }); + expect(stateBeforeReset?.fields.get('sig-field')).toBe('Audit User'); + + act(() => { + ref.current?.reset(); + }); + + const stateAfterReset = ref.current?.getState(); + expect(stateAfterReset).toMatchObject({ + scrolled: false, + isValid: false, + }); + expect(stateAfterReset?.fields.size).toBe(0); + expect(ref.current?.getAuditTrail()).toEqual([]); + }); + + it('prefers custom components when provided', async () => { + const onSubmit = vi.fn(); + const onDownload = vi.fn(); + + const CustomField: React.FCFull payload doc
', + mode: 'full', + validation: { scroll: { required: true } }, + }, + fields: { + document: [ + { + id: 'doc-field', + value: 'Document Value', + }, + ], + signer: [ + { + id: 'sig-field', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }); + + const scrollContainer = getByTestId('superdoc-scroll-container'); + const scrollController = configureScrollElement(scrollContainer, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 0, + }); + + await waitForSuperDocReady(); + + fireEvent.change(getByPlaceholderText('Type your full name'), { + target: { value: 'Payload User' }, + }); + + act(() => { + scrollController.update({ scrollTop: 390 }); + }); + + const submitButton = getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + await waitFor(() => { + const types = getAuditEventTypes(); + expect(types).to.include('submit'); + }); + + const submitData = onSubmit.mock.calls[0][0]; + expect(submitData.eventId).toBe('evt_test'); + expect(typeof submitData.timestamp).toBe('string'); + expect(Number.isNaN(new Date(submitData.timestamp).getTime())).toBe(false); + expect(typeof submitData.duration).toBe('number'); + expect(submitData.isFullyCompleted).toBe(true); + + expect(submitData.documentFields).toEqual([{ id: 'doc-field', value: 'Document Value' }]); + + expect(submitData.signerFields).toEqual([{ id: 'sig-field', 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'); + }); +}); diff --git a/packages/esign/src/__tests__/signature.test.ts b/packages/esign/src/__tests__/signature.test.ts new file mode 100644 index 0000000000..28d7282a48 --- /dev/null +++ b/packages/esign/src/__tests__/signature.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { textToImageDataUrl } from '../utils/signature'; + +describe('textToImageDataUrl', () => { + it('returns a data URL for a typed signature', () => { + const result = textToImageDataUrl('Jane Doe'); + // the mock is generated by the test/setup.ts file + expect(result).toBe('data:image/png;base64,mock'); + }); +}); diff --git a/packages/esign/src/defaults/CheckboxInput.tsx b/packages/esign/src/defaults/CheckboxInput.tsx new file mode 100644 index 0000000000..64741b98e0 --- /dev/null +++ b/packages/esign/src/defaults/CheckboxInput.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { FieldComponentProps } from '../types'; + +export const CheckboxInput: React.FC