From 3593b5b41cbd2cb913c26f4a6ad204f54064cd80 Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:44 +0200 Subject: [PATCH 1/6] feat: add TipTap email editor primitives Introduce reusable TipTap nodes, variable suggestions, and test coverage so initiative emails can support structured placeholders and CTA buttons without changing stored template syntax. --- apps/web/package.json | 11 +- .../editor/template-variables-helper.test.ts | 134 ++++++++++++++ .../editor/tiptap-editor-commands.test.ts | 82 ++++++++ .../components/editor/tiptap-email-button.ts | 175 ++++++++++++++++++ .../editor/tiptap-template-variable.test.ts | 133 +++++++++++++ .../editor/tiptap-template-variable.ts | 146 +++++++++++++++ .../editor/tiptap-variable-suggestion.test.ts | 60 ++++++ .../editor/tiptap-variable-suggestion.ts | 171 +++++++++++++++++ .../src/lib/components/template-variables.ts | 107 +++++++---- bun.lock | 134 ++++++++++++++ 10 files changed, 1114 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/lib/components/editor/template-variables-helper.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-email-button.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-template-variable.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-template-variable.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-variable-suggestion.test.ts create mode 100644 apps/web/src/lib/components/editor/tiptap-variable-suggestion.ts diff --git a/apps/web/package.json b/apps/web/package.json index 8697ee6..d28f368 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,5 +52,14 @@ "valibot": "^1.2.0", "vite": "^8.0.0" }, - "dependencies": {} + "dependencies": { + "@tiptap/core": "^3.22.2", + "@tiptap/extension-link": "^3.22.2", + "@tiptap/extension-placeholder": "^3.22.2", + "@tiptap/extension-text-align": "^3.22.2", + "@tiptap/extension-underline": "^3.22.2", + "@tiptap/pm": "^3.22.2", + "@tiptap/starter-kit": "^3.22.2", + "@tiptap/suggestion": "^3.22.2" + } } diff --git a/apps/web/src/lib/components/editor/template-variables-helper.test.ts b/apps/web/src/lib/components/editor/template-variables-helper.test.ts new file mode 100644 index 0000000..8dcd208 --- /dev/null +++ b/apps/web/src/lib/components/editor/template-variables-helper.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "bun:test"; +import { + getAllTemplateVariables, + CORE_LEAD_VARIABLES, + getTemplateVariablePreview, + highlightResolved, +} from "../template-variables"; + +describe("getAllTemplateVariables", () => { + it("returns core variables when no custom fields", () => { + const result = getAllTemplateVariables([]); + expect(result).toEqual(CORE_LEAD_VARIABLES); + }); + + it("includes custom fields after core variables", () => { + const customFields = [{ name: "industry" }, { name: "revenue" }]; + const result = getAllTemplateVariables(customFields); + + expect(result.length).toBe(CORE_LEAD_VARIABLES.length + 2); + + const customItems = result.slice(CORE_LEAD_VARIABLES.length); + expect(customItems[0]).toEqual({ + token: "{{custom.industry}}", + label: "custom.industry", + group: "Custom fields", + }); + expect(customItems[1]).toEqual({ + token: "{{custom.revenue}}", + label: "custom.revenue", + group: "Custom fields", + }); + }); + + it("preserves core variable structure", () => { + const result = getAllTemplateVariables([]); + const leadName = result.find((v) => v.token === "{{lead.name}}"); + expect(leadName).toBeDefined(); + expect(leadName!.label).toBe("lead.name"); + expect(leadName!.group).toBe("Lead fields"); + expect(leadName!.description).toBe("Business name"); + }); + + it("all core variables have group 'Lead fields'", () => { + const result = getAllTemplateVariables([]); + for (const v of result) { + expect(v.group).toBe("Lead fields"); + } + }); +}); + +describe("highlightResolved", () => { + it("replaces lead placeholders with highlighted values", () => { + const result = highlightResolved( + "
Hello {{lead.name}}
", + { + placeId: "place-1", + name: "Acme Corp", + }, + [] + ); + + expect(result).toContain(" { + const result = highlightResolved( + "Hello {{lead.email}}
", + { + placeId: "place-1", + name: "Acme Corp", + email: null, + }, + [] + ); + + expect(result).toContain("{{lead.email}}"); + expect(result).toContain("#fee2e2"); + }); +}); + +describe("getTemplateVariablePreview", () => { + it("returns the raw token when no lead is selected", () => { + expect(getTemplateVariablePreview("lead.name", null)).toEqual({ + text: "{{lead.name}}", + state: "raw", + }); + }); + + it("returns the raw token when preview is explicitly cleared", () => { + expect(getTemplateVariablePreview("lead.email", null)).toEqual({ + text: "{{lead.email}}", + state: "raw", + }); + }); + + it("returns the resolved lead value when present", () => { + expect( + getTemplateVariablePreview("lead.name", { + placeId: "place-1", + name: "Acme Corp", + }) + ).toEqual({ + text: "Acme Corp", + state: "resolved", + }); + }); + + it("marks known lead variables as missing when empty", () => { + expect( + getTemplateVariablePreview("lead.email", { + placeId: "place-1", + name: "Acme Corp", + email: null, + }) + ).toEqual({ + text: "{{lead.email}}", + state: "missing", + }); + }); + + it("keeps custom variables as raw tokens", () => { + expect( + getTemplateVariablePreview("custom.industry", { + placeId: "place-1", + name: "Acme Corp", + }) + ).toEqual({ + text: "{{custom.industry}}", + state: "raw", + }); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts b/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts new file mode 100644 index 0000000..cd0507f --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-editor-commands.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import Underline from "@tiptap/extension-underline"; +import TextAlign from "@tiptap/extension-text-align"; +import Placeholder from "@tiptap/extension-placeholder"; +import { TemplateVariable } from "./tiptap-template-variable"; +import { EmailButton } from "./tiptap-email-button"; + +function createEditor(content = "Hello
") { + const element = document.createElement("div"); + document.body.appendChild(element); + + return new Editor({ + element, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + link: false, + underline: false, + }), + Underline, + Link, + TextAlign.configure({ + types: ["heading", "paragraph"], + }), + Placeholder, + TemplateVariable, + EmailButton, + ], + content, + }); +} + +describe("TipTap editor commands", () => { + it("inserts a template variable inline", () => { + const editor = createEditor(); + + editor.commands.focus("end"); + const inserted = editor.commands.insertTemplateVariable("lead.name"); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('data-type="template-variable"'); + expect(editor.getHTML()).toContain("{{lead.name}}"); + + editor.destroy(); + }); + + it("inserts an email button block", () => { + const editor = createEditor(); + + editor.commands.focus("end"); + const inserted = editor.commands.insertEmailButton({ + text: "Book a Call", + url: "https://example.com", + backgroundColor: "#c4520a", + textColor: "#ffffff", + }); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('data-type="email-button"'); + expect(editor.getHTML()).toContain("Book a Call"); + expect(editor.getHTML()).toContain("https://example.com"); + + editor.destroy(); + }); + + it("applies a link to selected text", () => { + const editor = createEditor("Hello world
"); + + editor.commands.setTextSelection({ from: 1, to: 6 }); + const inserted = editor.commands.setLink({ + href: "https://example.com", + }); + + expect(inserted).toBe(true); + expect(editor.getHTML()).toContain('href="https://example.com"'); + + editor.destroy(); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-email-button.ts b/apps/web/src/lib/components/editor/tiptap-email-button.ts new file mode 100644 index 0000000..adc0b7e --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-email-button.ts @@ -0,0 +1,175 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export type EmailButtonOptions = { + /** Extra HTML attributes added to every rendered node. */ + HTMLAttributes: RecordHello {{lead.name}}
"; + const result = preprocessTemplateHtml(input); + expect(result).toBe( + 'Hello {{lead.name}}
' + ); + }); + + it("wraps multiple template variables", () => { + const input = "{{lead.name}} — {{lead.email}}
"; + const result = preprocessTemplateHtml(input); + expect(result).toContain( + 'data-variable-id="lead.name">{{lead.name}}' + ); + expect(result).toContain( + 'data-variable-id="lead.email">{{lead.email}}' + ); + }); + + it("wraps custom field variables", () => { + const input = "{{custom.industry}}"; + const result = preprocessTemplateHtml(input); + expect(result).toBe( + '{{custom.industry}}' + ); + }); + + it("handles text with no variables", () => { + const input = "Hello world
"; + const result = preprocessTemplateHtml(input); + expect(result).toBe("Hello world
"); + }); + + it("handles empty string", () => { + expect(preprocessTemplateHtml("")).toBe(""); + }); + + it("handles variables with underscores", () => { + const input = "{{lead.google_maps_url}}"; + const result = preprocessTemplateHtml(input); + expect(result).toContain('data-variable-id="lead.google_maps_url"'); + }); + + it("does not wrap incomplete braces", () => { + const input = "{{incomplete"; + const result = preprocessTemplateHtml(input); + expect(result).toBe("{{incomplete"); + }); +}); + +describe("postprocessTemplateHtml", () => { + it("converts a span back to raw template token", () => { + const input = + 'Hello {{lead.name}}
'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("Hello {{lead.name}}
"); + }); + + it("converts multiple spans back", () => { + const input = + '{{lead.name}} and {{lead.email}}'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("{{lead.name}} and {{lead.email}}"); + }); + + it("handles HTML with no template variable spans", () => { + const input = "Hello world
"; + const result = postprocessTemplateHtml(input); + expect(result).toBe("Hello world
"); + }); + + it("handles empty string", () => { + expect(postprocessTemplateHtml("")).toBe(""); + }); + + it("handles custom field spans", () => { + const input = + '{{custom.industry}}'; + const result = postprocessTemplateHtml(input); + expect(result).toBe("{{custom.industry}}"); + }); +}); + +describe("round-trip: preprocess -> postprocess", () => { + it("preserves raw template HTML through round-trip", () => { + const original = + "Hello {{lead.name}}, your email is {{lead.email}}
"; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); + + it("preserves HTML with no variables through round-trip", () => { + const original = "No variables here
"; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); + + it("preserves mixed content through round-trip", () => { + const original = + 'Hi {{lead.name}},
Visit our site
{{custom.offer}}
'; + const preprocessed = preprocessTemplateHtml(original); + const postprocessed = postprocessTemplateHtml(preprocessed); + expect(postprocessed).toBe(original); + }); +}); + +describe("normalizeTemplateEditorHtml", () => { + it("treats empty string as empty content", () => { + expect(normalizeTemplateEditorHtml("")).toBe(""); + expect(normalizeTemplateEditorHtml(" ")).toBe(""); + }); + + it("normalizes TipTap empty paragraphs to empty string", () => { + expect(normalizeTemplateEditorHtml("")).toBe(""); + expect(normalizeTemplateEditorHtml("Hello
\n")).toBe( + "Hello
" + ); + }); +}); diff --git a/apps/web/src/lib/components/editor/tiptap-template-variable.ts b/apps/web/src/lib/components/editor/tiptap-template-variable.ts new file mode 100644 index 0000000..c852c0e --- /dev/null +++ b/apps/web/src/lib/components/editor/tiptap-template-variable.ts @@ -0,0 +1,146 @@ +import { Node, mergeAttributes } from "@tiptap/core"; + +export type TemplateVariableOptions = { + /** Extra HTML attributes added to every rendered node. */ + HTMLAttributes: Record- Preview will appear here as you type... -
- {/if} -- ⚠ Some placeholders have missing data +
+ Missing placeholder data
{token}
+ {token}
— empty for {missingCount} of {leads.length} lead{leads.length ===
1
? ""
@@ -352,8 +440,11 @@
{/if}
- Tip: type {{ in the subject or body to insert a variable (e.g. lead name, address).
+ >
+ in the body to insert a variable, or use the
+ {{
+ toolbar button.
Hello,
- -Your message here...
- -Best regards
" + placeholder="Start writing your email..." /> {#if errorMessage} diff --git a/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte b/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte index 8eac7e0..0e8ab31 100644 --- a/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte +++ b/apps/web/src/routes/(app)/projects/[id]/initiatives/new/initiative-email-form.svelte @@ -85,11 +85,7 @@ subjectName={createInitiativeEmail.fields.subject.as("text").name} {projectId} {leads} - placeholder="Hello,
- -Your message here...
- -Best regards
" + placeholder="Start writing your email..." /> {#if errorMessage} From bdc18b7e6b37a9ba9c21d5e0c1193c97fc67b061 Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:36:58 +0200 Subject: [PATCH 4/6] chore: add Jean app port metadata Record the web app port in Jean config so local tooling can discover the running app without bundling that setup change into the editor feature commits. --- jean.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/jean.json b/jean.json index 7d45c59..7a06f23 100644 --- a/jean.json +++ b/jean.json @@ -3,5 +3,11 @@ "setup": "bun install", "teardown": null, "run": "bun dev" - } + }, + "ports": [ + { + "port": 5173, + "label": "app" + } + ] } From 466495be33ba1ef1e3be592841836037dd1f931a Mon Sep 17 00:00:00 2001 From: Simon Prosen <38320875+simpros@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:40:20 +0200 Subject: [PATCH 5/6] fix: remove non-reactive maps from editor menus Keep the toolbar and variable popup grouping logic compatible with Svelte's reactivity lint rules without changing menu ordering. --- .../components/editor/editor-toolbar.svelte | 20 +------ .../editor/editor-variable-popup.svelte | 34 ++++++------ .../editor/group-items-by-group.test.ts | 52 +++++++++++++++++++ .../components/editor/group-items-by-group.ts | 28 ++++++++++ 4 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/lib/components/editor/group-items-by-group.test.ts create mode 100644 apps/web/src/lib/components/editor/group-items-by-group.ts diff --git a/apps/web/src/lib/components/editor/editor-toolbar.svelte b/apps/web/src/lib/components/editor/editor-toolbar.svelte index d1abd4c..95e8072 100644 --- a/apps/web/src/lib/components/editor/editor-toolbar.svelte +++ b/apps/web/src/lib/components/editor/editor-toolbar.svelte @@ -3,6 +3,7 @@ import type { Selection } from "@tiptap/pm/state"; import type { TemplateVariable } from "../template-variables.js"; import type { EmailButtonAttrs } from "./tiptap-email-button.js"; + import { groupItemsByGroup } from "./group-items-by-group.js"; // Side-effect imports to register TipTap command types import "@tiptap/starter-kit"; @@ -62,24 +63,7 @@ }; }); - // Group variables by group - type GroupEntry = { - group: string; - items: TemplateVariable[]; - }; - - const grouped = $derived.by((): GroupEntry[] => { - const groupMap = new Map