From ca8a00249e40d6b1598ce73b4477a2a47d37543d Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Fri, 4 Sep 2026 11:34:11 +1000 Subject: [PATCH] feat(editor): add metadata panel, subject editing, and variable catalogue Wire controlled metadata fields and a variable catalogue into EmailTemplateEditor working TemplateSourceFiles state so admins can edit key/name/description/subject and copy or insert placeholders without memorising variable names. Closes #44 Co-authored-by: Cursor --- .../src/email-template-editor.tsx | 53 ++++- packages/post-kit-editor/src/index.spec.tsx | 24 +- .../post-kit-editor/src/insertion-target.tsx | 61 +++++ .../src/metadata/MetadataPanel.spec.tsx | 68 ++++++ .../src/metadata/MetadataPanel.tsx | 164 ++++++++++++++ .../src/metadata/insert-at-cursor.ts | 29 +++ .../src/metadata/metadata-helpers.spec.ts | 62 ++++++ .../src/metadata/subject-preview.ts | 15 ++ .../src/metadata/template-key.ts | 37 ++++ packages/post-kit-editor/src/types.ts | 11 +- .../src/variables/VariableCatalogue.tsx | 209 ++++++++++++++++++ .../src/variables/clipboard.ts | 16 ++ .../src/variables/resolve-catalogue.ts | 15 ++ .../src/variables/variable-catalogue.spec.tsx | 128 +++++++++++ packages/post-kit-editor/src/working-files.ts | 36 +++ 15 files changed, 910 insertions(+), 18 deletions(-) create mode 100644 packages/post-kit-editor/src/insertion-target.tsx create mode 100644 packages/post-kit-editor/src/metadata/MetadataPanel.spec.tsx create mode 100644 packages/post-kit-editor/src/metadata/MetadataPanel.tsx create mode 100644 packages/post-kit-editor/src/metadata/insert-at-cursor.ts create mode 100644 packages/post-kit-editor/src/metadata/metadata-helpers.spec.ts create mode 100644 packages/post-kit-editor/src/metadata/subject-preview.ts create mode 100644 packages/post-kit-editor/src/metadata/template-key.ts create mode 100644 packages/post-kit-editor/src/variables/VariableCatalogue.tsx create mode 100644 packages/post-kit-editor/src/variables/clipboard.ts create mode 100644 packages/post-kit-editor/src/variables/resolve-catalogue.ts create mode 100644 packages/post-kit-editor/src/variables/variable-catalogue.spec.tsx create mode 100644 packages/post-kit-editor/src/working-files.ts diff --git a/packages/post-kit-editor/src/email-template-editor.tsx b/packages/post-kit-editor/src/email-template-editor.tsx index 59c89e0..2cc49a4 100644 --- a/packages/post-kit-editor/src/email-template-editor.tsx +++ b/packages/post-kit-editor/src/email-template-editor.tsx @@ -3,8 +3,12 @@ import React, { useCallback, useState } from 'react'; import { EmailBuilderCanvas } from './canvas/EmailBuilderCanvas'; -import type { EmailBuilderDocument } from './types'; -import type { TemplateSourceFiles, TemplateVariable } from './types'; +import { InsertionTargetProvider } from './insertion-target'; +import { MetadataPanel } from './metadata/MetadataPanel'; +import type { EmailBuilderDocument, TemplateSourceFiles, TemplateVariable } from './types'; +import type { TemplateSourceMetadata } from '@singleton-sd/post-kit-types'; +import { VariableCatalogue } from './variables/VariableCatalogue'; +import { withDocument, withMetadata, withMetadataVariables } from './working-files'; /** * Prefix for every CSS class the editor emits. @@ -25,31 +29,58 @@ export interface EmailTemplateEditorProps { } /** - * Email template editor with an EmailBuilder.js canvas. + * Email template editor with metadata panel, variable catalogue, and canvas. * * Holds the working `TemplateSourceFiles` in local state, seeded from the - * `template` prop. Canvas edits update `templateJson`; metadata and preview - * data are passed through unchanged until dedicated editing surfaces land in - * later issues. Persistence is consumer-supplied via `onSave`. + * `template` prop. Canvas edits update `templateJson`; metadata edits merge + * into `metadata`. Persistence is consumer-supplied via `onSave`. */ export function EmailTemplateEditor({ template, + availableVariables, className, }: EmailTemplateEditorProps): JSX.Element { const [workingFiles, setWorkingFiles] = useState(template); const handleDocumentChange = useCallback((document: EmailBuilderDocument) => { - setWorkingFiles((current) => ({ - ...current, - templateJson: document, - })); + setWorkingFiles((current) => withDocument(current, document)); + }, []); + + const handleMetadataChange = useCallback((metadata: TemplateSourceMetadata) => { + setWorkingFiles((current) => withMetadata(current, metadata)); + }, []); + + const handleMetadataVariablesChange = useCallback((variables: string[]) => { + setWorkingFiles((current) => withMetadataVariables(current, variables)); }, []); const rootClassName = [`${EDITOR_CLASS_PREFIX}root`, className].filter(Boolean).join(' '); return (
- + +
+ + +
+
); } diff --git a/packages/post-kit-editor/src/index.spec.tsx b/packages/post-kit-editor/src/index.spec.tsx index 0711ace..66c0031 100644 --- a/packages/post-kit-editor/src/index.spec.tsx +++ b/packages/post-kit-editor/src/index.spec.tsx @@ -72,9 +72,31 @@ describe('EmailTemplateEditor', () => { assert.match(html, /Hello \{\{name\}\}/); }); + it('renders the metadata panel and variable catalogue beside the canvas', () => { + const html = renderToStaticMarkup(); + assert.match(html, new RegExp(`${EDITOR_CLASS_PREFIX}metadata`)); + assert.match(html, new RegExp(`${EDITOR_CLASS_PREFIX}variables`)); + assert.match(html, /value="marketing\.contact-us"/); + assert.match(html, /value="Contact Us"/); + assert.match(html, /Preview: New message from Jane Doe/); + assert.match(html, /\{\{name\}\}/); + }); + + it('lists availableVariables when the consumer supplies them', () => { + const html = renderToStaticMarkup( + , + ); + assert.match(html, /Reset URL/); + assert.match(html, /\{\{resetUrl\}\}/); + }); + it('does not render save or send-test chrome yet', () => { const html = renderToStaticMarkup(); - assert.doesNotMatch(html, /save/i); + assert.doesNotMatch(html, /send test/i); + assert.doesNotMatch(html, /data-testid="[^"]*save/); }); }); diff --git a/packages/post-kit-editor/src/insertion-target.tsx b/packages/post-kit-editor/src/insertion-target.tsx new file mode 100644 index 0000000..d75c65f --- /dev/null +++ b/packages/post-kit-editor/src/insertion-target.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; + +export interface InsertionTarget { + /** Insert `text` at the target's current caret / selection. */ + insert: (text: string) => void; + /** Short label used in disabled-button explanations. */ + label: string; +} + +interface InsertionTargetContextValue { + target: InsertionTarget | null; + setTarget: (target: InsertionTarget | null) => void; +} + +const InsertionTargetContext = createContext(null); + +export function InsertionTargetProvider({ children }: { children: React.ReactNode }): JSX.Element { + const [target, setTarget] = useState(null); + const value = useMemo(() => ({ target, setTarget }), [target]); + return ( + {children} + ); +} + +export function useInsertionTarget(): InsertionTargetContextValue { + const ctx = useContext(InsertionTargetContext); + if (!ctx) { + throw new Error('useInsertionTarget must be used within InsertionTargetProvider'); + } + return ctx; +} + +/** + * Register an insertion target while a focusable element is focused. + * Clears the target on blur only when this registration is still current. + */ +export function useRegisterInsertionTarget( + label: string, + insert: (text: string) => void, +): { + onFocus: () => void; + onBlur: () => void; +} { + const { setTarget } = useInsertionTarget(); + const insertRef = useRef(insert); + insertRef.current = insert; + + const stableInsert = useCallback((text: string) => { + insertRef.current(text); + }, []); + + const onFocus = useCallback(() => { + setTarget({ label, insert: stableInsert }); + }, [label, setTarget, stableInsert]); + + const onBlur = useCallback(() => { + setTarget(null); + }, [setTarget]); + + return { onFocus, onBlur }; +} diff --git a/packages/post-kit-editor/src/metadata/MetadataPanel.spec.tsx b/packages/post-kit-editor/src/metadata/MetadataPanel.spec.tsx new file mode 100644 index 0000000..4b0d70a --- /dev/null +++ b/packages/post-kit-editor/src/metadata/MetadataPanel.spec.tsx @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { EDITOR_CLASS_PREFIX } from '../email-template-editor'; +import { InsertionTargetProvider } from '../insertion-target'; +import type { TemplateSourceMetadata } from '@singleton-sd/post-kit-types'; +import { MetadataPanel } from './MetadataPanel'; +import { insertAtCursor, variablePlaceholder } from './insert-at-cursor'; + +const metadata: TemplateSourceMetadata = { + key: 'marketing.contact-us', + name: 'Contact Us', + description: 'Public contact form', + subject: 'New message from {{name}}', + variables: ['name'], + schemaVersion: '1', +}; + +describe('MetadataPanel', () => { + it('renders editable metadata fields bound to the working values', () => { + const html = renderToStaticMarkup( + + {}} /> + , + ); + + assert.match(html, new RegExp(`${EDITOR_CLASS_PREFIX}metadata`)); + assert.match(html, /value="marketing\.contact-us"/); + assert.match(html, /value="Contact Us"/); + assert.match(html, /Public contact form/); + assert.match(html, /value="New message from \{\{name\}\}"/); + assert.match(html, new RegExp(`${EDITOR_CLASS_PREFIX}meta-schema-version[^>]*>1<`)); + assert.match(html, /