Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions packages/post-kit-editor/src/email-template-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<TemplateSourceFiles>(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 (
<div className={rootClassName} data-testid={`${EDITOR_CLASS_PREFIX}root`}>
<EmailBuilderCanvas document={workingFiles.templateJson} onChange={handleDocumentChange} />
<InsertionTargetProvider>
<div
className={`${EDITOR_CLASS_PREFIX}layout`}
data-testid={`${EDITOR_CLASS_PREFIX}layout`}
>
<aside className={`${EDITOR_CLASS_PREFIX}sidebar`}>
<MetadataPanel
metadata={workingFiles.metadata}
previewData={workingFiles.previewData}
onChange={handleMetadataChange}
/>
<VariableCatalogue
availableVariables={availableVariables}
metadataVariables={workingFiles.metadata.variables}
onMetadataVariablesChange={handleMetadataVariablesChange}
/>
</aside>
<EmailBuilderCanvas
document={workingFiles.templateJson}
onChange={handleDocumentChange}
/>
</div>
</InsertionTargetProvider>
</div>
);
}
24 changes: 23 additions & 1 deletion packages/post-kit-editor/src/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<EmailTemplateEditor {...baseProps} />);
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(
<EmailTemplateEditor
{...baseProps}
availableVariables={[{ name: 'resetUrl', label: 'Reset URL' }]}
/>,
);
assert.match(html, /Reset URL/);
assert.match(html, /\{\{resetUrl\}\}/);
});

it('does not render save or send-test chrome yet', () => {
const html = renderToStaticMarkup(<EmailTemplateEditor {...baseProps} />);
assert.doesNotMatch(html, /save/i);
assert.doesNotMatch(html, /send test/i);
assert.doesNotMatch(html, /data-testid="[^"]*save/);
});
});

Expand Down
61 changes: 61 additions & 0 deletions packages/post-kit-editor/src/insertion-target.tsx
Original file line number Diff line number Diff line change
@@ -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<InsertionTargetContextValue | null>(null);

export function InsertionTargetProvider({ children }: { children: React.ReactNode }): JSX.Element {
const [target, setTarget] = useState<InsertionTarget | null>(null);
const value = useMemo(() => ({ target, setTarget }), [target]);
return (
<InsertionTargetContext.Provider value={value}>{children}</InsertionTargetContext.Provider>
);
}

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 };
}
68 changes: 68 additions & 0 deletions packages/post-kit-editor/src/metadata/MetadataPanel.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InsertionTargetProvider>
<MetadataPanel metadata={metadata} previewData={{ name: 'Jane Doe' }} onChange={() => {}} />
</InsertionTargetProvider>,
);

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, /<span class="pk-editor-metadata-readonly"/);
});

it('shows a live non-authoritative subject preview from previewData', () => {
const html = renderToStaticMarkup(
<InsertionTargetProvider>
<MetadataPanel metadata={metadata} previewData={{ name: 'Jane Doe' }} onChange={() => {}} />
</InsertionTargetProvider>,
);
assert.match(html, /Preview: New message from Jane Doe/);
});

it('does not render a writable control for schemaVersion', () => {
const html = renderToStaticMarkup(
<InsertionTargetProvider>
<MetadataPanel metadata={metadata} previewData={{ name: 'Jane' }} onChange={() => {}} />
</InsertionTargetProvider>,
);
assert.doesNotMatch(html, /name="schemaVersion"/);
assert.doesNotMatch(
html,
new RegExp(`<input[^>]*data-testid="${EDITOR_CLASS_PREFIX}meta-schema-version"`),
);
});
});

describe('subject insert-at-cursor', () => {
it('inserts a placeholder into the subject at the caret', () => {
const subject = 'Hello there';
const { value, caret } = insertAtCursor(subject, 6, 6, variablePlaceholder('name'));
assert.equal(value, 'Hello {{name}} there');
assert.equal(caret, 14);
});
});
Loading
Loading