From 59da462d83b37c8bb0fa5d2ccc6aaac3b0e67a4b Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Thu, 16 Apr 2026 12:08:10 +0300 Subject: [PATCH] feat: add presetContent for template builder --- .../template-builder/api-reference.mdx | 6 + .../template-builder/configuration.mdx | 31 ++++ .../solutions/template-builder/quickstart.mdx | 29 ++++ packages/template-builder/README.md | 37 +++- packages/template-builder/demo/src/App.tsx | 78 +++++++++ packages/template-builder/src/index.tsx | 10 +- .../tests/insertFieldPresetContent.test.tsx | 164 ++++++++++++++++++ packages/template-builder/src/types.ts | 4 + 8 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 packages/template-builder/src/tests/insertFieldPresetContent.test.tsx diff --git a/apps/docs/solutions/template-builder/api-reference.mdx b/apps/docs/solutions/template-builder/api-reference.mdx index 56012e0e53..7cfe3d98e1 100644 --- a/apps/docs/solutions/template-builder/api-reference.mdx +++ b/apps/docs/solutions/template-builder/api-reference.mdx @@ -178,6 +178,10 @@ interface FieldDefinition { id: string; // Unique identifier label: string; // Display name defaultValue?: string; // Default value for new instances + presetContent?: { + html?: string; // Block preset as HTML + json?: unknown; // Block preset as ProseMirror JSON + }; metadata?: Record; // Custom metadata stored in the SDT tag mode?: "inline" | "block"; // Insertion mode (default: "inline") group?: string; // Group ID for linked fields @@ -186,6 +190,8 @@ interface FieldDefinition { } ``` +`presetContent` is applied only for `mode: "block"` fields. Inline fields ignore it. + ### TemplateField Fields that exist in the template document: diff --git a/apps/docs/solutions/template-builder/configuration.mdx b/apps/docs/solutions/template-builder/configuration.mdx index a41e8e54e3..acbfe6a87d 100644 --- a/apps/docs/solutions/template-builder/configuration.mdx +++ b/apps/docs/solutions/template-builder/configuration.mdx @@ -100,6 +100,37 @@ Each color controls the field's border and label in the document. The sidebar ba The `fieldType` value flows through all callbacks (`onFieldInsert`, `onFieldsChange`, `onExport`, etc.) and is stored in the SDT tag metadata. +### Preset block content + +Use `presetContent` to prefill block fields with a structure such as a table or list: + +```tsx +Column AColumn B", + // or json: { ...ProseMirror JSON... } + }, + }, + ], + }} +/> +``` + +`presetContent` is passed into the existing block insertion command. For `mode: "inline"` fields, `presetContent` is ignored. + + + - `presetContent.json` is recommended when you need the most reliable preservation of structure, semantics, and editor-compatible styling. + - With `presetContent.html`, include required attributes and inline styles explicitly so the HTML parser can map them into node attributes. + + ### Field creation Allow users to create new fields while building templates: diff --git a/apps/docs/solutions/template-builder/quickstart.mdx b/apps/docs/solutions/template-builder/quickstart.mdx index da79542faa..8d98f3686b 100644 --- a/apps/docs/solutions/template-builder/quickstart.mdx +++ b/apps/docs/solutions/template-builder/quickstart.mdx @@ -75,6 +75,35 @@ Distinguish between owner-filled and signer-filled fields: Fields default to `'owner'` when no `fieldType` is specified. +### Add preset block content + +If a field should insert a predefined structure (for example, a table), add `presetContent` on a block field: + +```jsx +Column AColumn B", + }, + }, + ], + }} +/> +``` + +`presetContent` applies only to block fields. Inline fields ignore it. + + + - `presetContent.json` is recommended when you need the most reliable preservation of structure, semantics, and editor-compatible styling. + - With `presetContent.html`, include required attributes and inline styles explicitly so the HTML parser can map them into node attributes. + + ### Color-code fields in the editor Import the optional CSS to visually distinguish field types: diff --git a/packages/template-builder/README.md b/packages/template-builder/README.md index df1aa778e5..741c0d41d0 100644 --- a/packages/template-builder/README.md +++ b/packages/template-builder/README.md @@ -25,7 +25,16 @@ function TemplateEditor() { available: [ { id: '1324567890', label: 'Customer Name' }, { id: '1324567891', label: 'Invoice Date' }, - { id: '1324567892', label: 'Signature', mode: 'block', fieldType: 'signer' }, + { + id: '1324567892', + label: 'Sample Table', + mode: 'block', + fieldType: 'signer', + presetContent: { + html: '
Column AColumn B
', + }, + }, + { id: '1324567893', label: 'Signature', mode: 'block', fieldType: 'signer' }, ], }} onFieldInsert={(field) => { @@ -179,6 +188,32 @@ onFieldsChange={(fields) => { }} ``` +## Preset Block Content + +Block fields can include `presetContent` so insertion starts with a predefined structure (for example, a table or list). + +```jsx +const availableFields = [ + { + id: 'sample_table', + label: 'Sample Table', + mode: 'block', + fieldType: 'data', + lockMode: 'contentLocked', + presetContent: { + html: '
Column AColumn B
', + // or json: { ...ProseMirror JSON... } + }, + }, +]; +``` + +Notes: +- `presetContent` is used only for `mode: 'block'`. +- Inline fields ignore `presetContent`. +- Prefer `presetContent.json` when you need the most reliable preservation of structure, semantics, and editor-compatible styling. +- With `presetContent.html`, include required attributes and inline styles explicitly so the HTML parser can map them into node attributes. + ## Custom Field Creation Enable inline field creation in the dropdown menu: diff --git a/packages/template-builder/demo/src/App.tsx b/packages/template-builder/demo/src/App.tsx index b4f6c9e574..9e0a31fee4 100644 --- a/packages/template-builder/demo/src/App.tsx +++ b/packages/template-builder/demo/src/App.tsx @@ -10,6 +10,13 @@ import type { import 'superdoc/style.css'; import './App.css'; +const cellBorders = { + top: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + right: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + bottom: { val: 'single', size: 1, color: '#000000', style: 'solid' }, + left: { val: 'single', size: 1, color: '#000000', style: 'solid' }, +} as const; + const availableFields: FieldDefinition[] = [ { id: '1242142770', label: 'Agreement Date' }, { id: '1242142771', label: 'User Name', defaultValue: 'John Doe' }, @@ -20,6 +27,77 @@ const availableFields: FieldDefinition[] = [ { id: '1242142776', label: 'Signature', mode: 'block' }, { id: '1242142777', label: 'Signer Name', fieldType: 'signer' }, { id: '1242142778', label: 'Signer Table', mode: 'block', fieldType: 'signer' }, + { + id: '1242142779', + label: 'Sample Table', + mode: 'block', + fieldType: 'signer', + presetContent: { + html: '
Column AColumn B
', + }, + }, + { + id: '1242142780', + label: 'Sample List', + mode: 'block', + fieldType: 'signer', + presetContent: { + html: '
  • First item
  • Second item
  • Third item
', + }, + }, + { + id: '1242142781', + label: 'Sample Table (JSON)', + mode: 'block', + fieldType: 'signer', + presetContent: { + json: { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + attrs: { borders: cellBorders }, + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'Column A' }] }], + }, + ], + }, + { + type: 'tableHeader', + attrs: { borders: cellBorders }, + content: [ + { + type: 'paragraph', + content: [{ type: 'run', content: [{ type: 'text', text: 'Column B' }] }], + }, + ], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + attrs: { borders: cellBorders }, + content: [{ type: 'paragraph' }], + }, + { + type: 'tableCell', + attrs: { borders: cellBorders }, + content: [{ type: 'paragraph' }], + }, + ], + }, + ], + }, + }, + }, ]; export function App() { diff --git a/packages/template-builder/src/index.tsx b/packages/template-builder/src/index.tsx index 72fee91683..edf2ae3a28 100644 --- a/packages/template-builder/src/index.tsx +++ b/packages/template-builder/src/index.tsx @@ -183,7 +183,13 @@ const SuperDocTemplateBuilder = forwardRef true); +const insertStructuredContentBlockMock = vi.fn(() => true); + +vi.mock('superdoc', () => { + class MockSuperDoc { + activeEditor: any; + superdocStore: any; + + constructor(options: { onReady?: () => void }) { + this.activeEditor = { + state: { + selection: { from: 0, to: 0 }, + doc: { textBetween: () => '' }, + }, + view: { + coordsAtPos: () => ({ left: 0, top: 0, bottom: 0 }), + dispatch: vi.fn(), + }, + commands: { + insertStructuredContentInline: insertStructuredContentInlineMock, + insertStructuredContentBlock: insertStructuredContentBlockMock, + }, + helpers: { + structuredContentCommands: { + getStructuredContentTags: () => [], + }, + }, + on: vi.fn(), + }; + + this.superdocStore = { + documents: [{ getPresentationEditor: () => ({ coordsAtPos: () => ({ left: 0, top: 0, bottom: 0 }) }) }], + }; + + queueMicrotask(() => options.onReady?.()); + } + + destroy() {} + + setDocumentMode() {} + } + + return { SuperDoc: MockSuperDoc }; +}); + +const renderBuilder = async () => { + const ref = createRef(); + const onReady = vi.fn(); + + render( + , + ); + + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(ref.current).not.toBeNull()); + + return ref; +}; + +describe('SuperDocTemplateBuilder presetContent insertion', () => { + beforeEach(() => { + insertStructuredContentInlineMock.mockClear(); + insertStructuredContentBlockMock.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it('passes presetContent.html to block insert command', async () => { + const ref = await renderBuilder(); + let result = false; + + await act(async () => { + result = ref.current!.insertBlockField({ + alias: 'Sample Table', + presetContent: { html: '
Date
' }, + }); + }); + + expect(result).toBe(true); + expect(insertStructuredContentBlockMock).toHaveBeenCalledTimes(1); + expect(insertStructuredContentBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ + html: '
Date
', + }), + ); + const firstBlockCallArg = (insertStructuredContentBlockMock as any).mock.calls[0]?.[0]; + expect(firstBlockCallArg).toBeDefined(); + expect(firstBlockCallArg).not.toHaveProperty('text'); + }); + + it('passes presetContent.json to block insert command', async () => { + const ref = await renderBuilder(); + const json = { type: 'paragraph', content: [{ type: 'text', text: 'Preset block' }] }; + let result = false; + + await act(async () => { + result = ref.current!.insertBlockField({ + alias: 'Preset Block', + presetContent: { json }, + }); + }); + + expect(result).toBe(true); + expect(insertStructuredContentBlockMock).toHaveBeenCalledTimes(1); + expect(insertStructuredContentBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ + json, + }), + ); + const firstBlockCallArg = (insertStructuredContentBlockMock as any).mock.calls[0]?.[0]; + expect(firstBlockCallArg).toBeDefined(); + expect(firstBlockCallArg).not.toHaveProperty('text'); + }); + + it('keeps text fallback for block fields without presetContent', async () => { + const ref = await renderBuilder(); + let result = false; + + await act(async () => { + result = ref.current!.insertBlockField({ + alias: 'Signature', + defaultValue: 'Default signature content', + }); + }); + + expect(result).toBe(true); + expect(insertStructuredContentBlockMock).toHaveBeenCalledTimes(1); + expect(insertStructuredContentBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Default signature content', + }), + ); + }); + + it('ignores presetContent for inline insertion', async () => { + const ref = await renderBuilder(); + let result = false; + + await act(async () => { + result = ref.current!.insertField({ + alias: 'Inline Name', + defaultValue: 'Alice', + presetContent: { html: '
  • Ignored
' }, + }); + }); + + expect(result).toBe(true); + expect(insertStructuredContentInlineMock).toHaveBeenCalledTimes(1); + expect(insertStructuredContentInlineMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Alice', + }), + ); + expect(insertStructuredContentBlockMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/template-builder/src/types.ts b/packages/template-builder/src/types.ts index c214ddd6b6..77666904db 100644 --- a/packages/template-builder/src/types.ts +++ b/packages/template-builder/src/types.ts @@ -7,6 +7,10 @@ export interface FieldDefinition { id: string; label: string; defaultValue?: string; + presetContent?: { + html?: string; + json?: unknown; + }; metadata?: Record; mode?: 'inline' | 'block'; group?: string;