-
Notifications
You must be signed in to change notification settings - Fork 0
feat(editor): EmailBuilder canvas and lossless template serialization #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "key": "test.minimal", | ||
| "name": "Minimal", | ||
| "subject": "Hello", | ||
| "variables": [], | ||
| "schemaVersion": "1" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "root": { | ||
| "type": "EmailLayout", | ||
| "data": { | ||
| "backdropColor": "#F8F8F8", | ||
| "canvasColor": "#FFFFFF", | ||
| "textColor": "#242424", | ||
| "fontFamily": "MODERN_SANS", | ||
| "childrenIds": [] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "key": "test.nested", | ||
| "name": "Nested Blocks", | ||
| "subject": "Hello {{name}}", | ||
| "variables": ["name"], | ||
| "schemaVersion": "1" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| { "name": "Jane Doe" } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| { | ||
| "root": { | ||
| "type": "EmailLayout", | ||
| "data": { | ||
| "backdropColor": "#F8F8F8", | ||
| "canvasColor": "#FFFFFF", | ||
| "textColor": "#242424", | ||
| "fontFamily": "MODERN_SANS", | ||
| "childrenIds": ["block-container"] | ||
| } | ||
| }, | ||
| "block-container": { | ||
| "type": "Container", | ||
| "data": { | ||
| "style": { | ||
| "padding": { "top": 16, "bottom": 16, "right": 24, "left": 24 } | ||
| }, | ||
| "props": { | ||
| "childrenIds": ["block-heading", "block-text"] | ||
| } | ||
| } | ||
| }, | ||
| "block-heading": { | ||
| "type": "Heading", | ||
| "data": { | ||
| "props": { "text": "Welcome", "level": "h1" }, | ||
| "style": { | ||
| "padding": { "top": 16, "bottom": 8, "right": 24, "left": 24 } | ||
| } | ||
| } | ||
| }, | ||
| "block-text": { | ||
| "type": "Text", | ||
| "data": { | ||
| "style": { | ||
| "fontWeight": "normal", | ||
| "padding": { "top": 8, "bottom": 16, "right": 24, "left": 24 } | ||
| }, | ||
| "props": { | ||
| "text": "Hello {{name}}" | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "key": "test.unknown", | ||
| "name": "Unknown Fields", | ||
| "subject": "Test", | ||
| "variables": [], | ||
| "schemaVersion": "1", | ||
| "futureCatalogueKey": "reserved" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| { "unusedFutureKey": "kept" } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "root": { | ||
| "type": "EmailLayout", | ||
| "data": { | ||
| "backdropColor": "#F8F8F8", | ||
| "canvasColor": "#FFFFFF", | ||
| "childrenIds": ["block-text"] | ||
| } | ||
| }, | ||
| "block-text": { | ||
| "type": "Text", | ||
| "data": { | ||
| "style": { | ||
| "padding": { "top": 16, "bottom": 16, "right": 24, "left": 24 } | ||
| }, | ||
| "props": { | ||
| "text": "Hello", | ||
| "futureEditorFlag": true, | ||
| "experimentalLayout": { "version": 2, "enabled": true } | ||
| } | ||
| } | ||
| }, | ||
| "_editorMeta": { | ||
| "lastOpenedAt": "2026-01-01T00:00:00.000Z" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import React, { createContext, useContext } from 'react'; | ||
|
|
||
| import { EditorBlock as CoreEditorBlock } from './editor-core'; | ||
| import { useDocument } from './editor-context'; | ||
|
|
||
| const EditorBlockContext = createContext<string | null>(null); | ||
|
|
||
| export function useCurrentBlockId(): string { | ||
| const blockId = useContext(EditorBlockContext); | ||
| if (!blockId) { | ||
| throw new Error('useCurrentBlockId must be used within EditorBlock'); | ||
| } | ||
| return blockId; | ||
| } | ||
|
|
||
| type EditorBlockProps = { | ||
| id: string; | ||
| }; | ||
|
|
||
| export function EditorBlock({ id }: EditorBlockProps): JSX.Element { | ||
| const document = useDocument(); | ||
| const block = document[id]; | ||
| if (!block) { | ||
| throw new Error(`Could not find block "${id}"`); | ||
| } | ||
|
|
||
| return ( | ||
| <EditorBlockContext.Provider value={id}> | ||
| <CoreEditorBlock {...block} /> | ||
| </EditorBlockContext.Provider> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import React from 'react'; | ||
|
|
||
| import type { EmailBuilderDocument } from '../types'; | ||
| import { EDITOR_CLASS_PREFIX } from '../email-template-editor'; | ||
| import { CanvasEditorProvider } from './editor-context'; | ||
| import { EditorBlock } from './EditorBlock'; | ||
| import { Reader } from '@usewaypoint/email-builder'; | ||
|
|
||
| export interface EmailBuilderCanvasProps { | ||
| document: EmailBuilderDocument; | ||
| onChange: (document: EmailBuilderDocument) => void; | ||
| readOnly?: boolean; | ||
| } | ||
|
|
||
| export function EmailBuilderCanvas({ | ||
| document, | ||
| onChange, | ||
| readOnly = false, | ||
| }: EmailBuilderCanvasProps): JSX.Element { | ||
| if (readOnly) { | ||
| return ( | ||
| <div className={`${EDITOR_CLASS_PREFIX}canvas`} data-testid={`${EDITOR_CLASS_PREFIX}canvas`}> | ||
| <Reader document={document} rootBlockId="root" /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <CanvasEditorProvider document={document} onChange={onChange}> | ||
| <div className={`${EDITOR_CLASS_PREFIX}canvas`} data-testid={`${EDITOR_CLASS_PREFIX}canvas`}> | ||
| <EditorBlock id="root" /> | ||
| </div> | ||
| </CanvasEditorProvider> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import React from 'react'; | ||
|
|
||
| import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container'; | ||
|
|
||
| import { useCurrentBlockId } from '../EditorBlock'; | ||
| import { usePatchDocument, useSetSelectedBlockId } from '../editor-context'; | ||
| import EditorChildrenIds, { type EditorChildrenChange } from '../helpers/EditorChildrenIds'; | ||
|
|
||
| import ColumnsContainerPropsSchema, { | ||
| type ColumnsContainerProps, | ||
| } from './ColumnsContainerPropsSchema'; | ||
|
|
||
| const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }]; | ||
|
|
||
| export default function ColumnsContainerEditor({ | ||
| style, | ||
| props, | ||
| }: ColumnsContainerProps): JSX.Element { | ||
| const currentBlockId = useCurrentBlockId(); | ||
| const patchDocument = usePatchDocument(); | ||
| const setSelectedBlockId = useSetSelectedBlockId(); | ||
|
|
||
| const { columns, ...restProps } = props ?? {}; | ||
| const columnsValue = columns ?? EMPTY_COLUMNS; | ||
|
|
||
| const updateColumn = ( | ||
| columnIndex: 0 | 1 | 2, | ||
| { block, blockId, childrenIds }: EditorChildrenChange, | ||
| ) => { | ||
| const nextColumns = [...columnsValue]; | ||
| nextColumns[columnIndex] = { childrenIds }; | ||
| patchDocument({ | ||
| [blockId]: block, | ||
| [currentBlockId]: { | ||
| type: 'ColumnsContainer', | ||
| data: ColumnsContainerPropsSchema.parse({ | ||
| style, | ||
| props: { | ||
| ...restProps, | ||
| columns: nextColumns, | ||
| }, | ||
| }), | ||
| }, | ||
| }); | ||
| setSelectedBlockId(blockId); | ||
| }; | ||
|
|
||
| return ( | ||
| <BaseColumnsContainer | ||
| props={restProps} | ||
| style={style} | ||
| columns={[ | ||
| <EditorChildrenIds | ||
| key="col-0" | ||
| childrenIds={columns?.[0]?.childrenIds} | ||
| onChange={(change) => updateColumn(0, change)} | ||
| />, | ||
| <EditorChildrenIds | ||
| key="col-1" | ||
| childrenIds={columns?.[1]?.childrenIds} | ||
| onChange={(change) => updateColumn(1, change)} | ||
| />, | ||
| <EditorChildrenIds | ||
| key="col-2" | ||
| childrenIds={columns?.[2]?.childrenIds} | ||
| onChange={(change) => updateColumn(2, change)} | ||
| />, | ||
| ]} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container'; | ||
|
|
||
| const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape; | ||
|
|
||
| const ColumnsContainerPropsSchema = z.object({ | ||
| style: BaseColumnsContainerPropsSchema.shape.style, | ||
| props: z | ||
| .object({ | ||
| ...BasePropsShape, | ||
| columns: z.tuple([ | ||
| z.object({ childrenIds: z.array(z.string()) }), | ||
| z.object({ childrenIds: z.array(z.string()) }), | ||
| z.object({ childrenIds: z.array(z.string()) }), | ||
| ]), | ||
|
Comment on lines
+10
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/sh
set -eu
rg -n -C 4 'ColumnsContainerPropsSchema\.parse|ColumnsContainerPropsSchema' packages/post-kit-editor
rg -n -C 3 '\.passthrough\(\)|unknown|future' packages/post-kit-editor/srcRepository: singleton-sd/post-kit Length of output: 16539 🏁 Script executed: #!/bin/sh
set -eu
printf '%s\n' '--- scoped conventions and learnings ---'
find /tmp/coderabbit-repo-knowledge/singleton-sd-post-kit-c40aa8a1 \
-maxdepth 2 -type f -name '*.md' -print | sort
for f in /tmp/coderabbit-repo-knowledge/singleton-sd-post-kit-c40aa8a1/*/*.md; do
printf '%s\n' "--- $f ---"
head -80 "$f"
done
printf '%s\n' '--- targeted source ---'
cat -n packages/post-kit-editor/src/canvas/blocks/ColumnsContainerPropsSchema.ts
cat -n packages/post-kit-editor/src/canvas/blocks/ColumnsContainerEditor.tsx
printf '%s\n' '--- package and dependency declarations ---'
rg -n -C 3 '"zod"|"`@usewaypoint/block-columns-container`"|workspaces|packageManager' \
package.json packages/post-kit-editor package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true
printf '%s\n' '--- base schema candidates ---'
rg -n -C 5 'ColumnsContainerPropsSchema|childrenIds' packages blocks src 2>/dev/null | head -240 || trueRepository: singleton-sd/post-kit Length of output: 31565 🏁 Script executed: #!/bin/sh
set -eu
printf '%s\n' '--- resolved dependency entries ---'
rg -n -C 5 '(^|[[:space:]])zod([:@]|$)|`@usewaypoint/block-columns-container`' pnpm-lock.yaml package-lock.json yarn.lock 2>/dev/null || true
printf '%s\n' '--- relevant tests and editor call sites ---'
rg -n -C 5 'ColumnsContainer|updateColumn|futureEditorFlag|experimentalLayout|unknown field|unknown-field' \
packages/post-kit-editor/src --glob '*.{spec,test}.{ts,tsx}' --glob '*.tsx' --glob '*.ts' | head -260Repository: singleton-sd/post-kit Length of output: 24194 🌐 Web query:
💡 Result: In Zod, object schemas created with z.object have specific behaviors regarding unknown keys [1][2]. By default, Zod strips all unrecognized keys from the parsed result [1][3][2]. You can explicitly control this behavior using the following methods:.strip: This is the default behavior. Unrecognized keys are removed from the output [3][4][5]..passthrough: This allows unrecognized keys to be included in the parsed result without being stripped [1][3][5]..strict: This disallows unrecognized keys. If the input contains any keys not defined in the schema, Zod will throw a validation error [1][3][5]. Alternatively, you can use.catchall(schema) to define a schema that validates all unrecognized keys [3][2][5]. Using.catchall overrides any previous.strip,.passthrough, or.strict configuration, as all keys are then considered "known" [3][5]. In addition, Zod provides convenience wrappers: z.strictObject: Equivalent to z.object(...).strict [1][2][6]. z.looseObject: Equivalent to z.object(...).passthrough [1][2][6]. Note: Some documentation sources may refer to.loose as a modern alternative to.passthrough for allowing unrecognized keys [4]. Citations:
Preserve unknown fields during column edits.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| }) | ||
| .optional() | ||
| .nullable(), | ||
| }); | ||
|
|
||
| export type ColumnsContainerProps = z.infer<typeof ColumnsContainerPropsSchema>; | ||
| export default ColumnsContainerPropsSchema; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import React from 'react'; | ||
|
|
||
| import { Container as BaseContainer } from '@usewaypoint/block-container'; | ||
|
|
||
| import { useCurrentBlockId } from '../EditorBlock'; | ||
| import { useDocument, usePatchDocument, useSetSelectedBlockId } from '../editor-context'; | ||
| import EditorChildrenIds from '../helpers/EditorChildrenIds'; | ||
|
|
||
| import type { ContainerProps } from './ContainerPropsSchema'; | ||
|
|
||
| export default function ContainerEditor({ style, props }: ContainerProps): JSX.Element { | ||
| const childrenIds = props?.childrenIds ?? []; | ||
| const document = useDocument(); | ||
| const currentBlockId = useCurrentBlockId(); | ||
| const patchDocument = usePatchDocument(); | ||
| const setSelectedBlockId = useSetSelectedBlockId(); | ||
|
|
||
| return ( | ||
| <BaseContainer style={style}> | ||
| <EditorChildrenIds | ||
| childrenIds={childrenIds} | ||
| onChange={({ block, blockId, childrenIds: nextChildrenIds }) => { | ||
| const currentBlock = document[currentBlockId]; | ||
| if (!currentBlock || currentBlock.type !== 'Container') { | ||
| return; | ||
| } | ||
| patchDocument({ | ||
| [blockId]: block, | ||
| [currentBlockId]: { | ||
| type: 'Container', | ||
| data: { | ||
| ...currentBlock.data, | ||
| props: { childrenIds: nextChildrenIds }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve existing block fields when updating child IDs. Both handlers replace objects with partial known fields. This deletes unknown and forward-compatible source data after a canvas edit.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| }, | ||
| }, | ||
| }); | ||
| setSelectedBlockId(blockId); | ||
| }} | ||
| /> | ||
| </BaseContainer> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container'; | ||
|
|
||
| const ContainerPropsSchema = z.object({ | ||
| style: BaseContainerPropsSchema.shape.style, | ||
| props: z | ||
| .object({ | ||
| childrenIds: z.array(z.string()).optional().nullable(), | ||
| }) | ||
| .optional() | ||
| .nullable(), | ||
| }); | ||
|
|
||
| export default ContainerPropsSchema; | ||
|
|
||
| export type ContainerProps = z.infer<typeof ContainerPropsSchema>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: singleton-sd/post-kit
Length of output: 811
🏁 Script executed:
Repository: singleton-sd/post-kit
Length of output: 13401
🏁 Script executed:
Repository: singleton-sd/post-kit
Length of output: 3891
🌐 Web query:
pnpm package has the same package in dependencies and peerDependencies resolution behavior💡 Result:
In pnpm, when a package is listed in both
dependencies(ordevDependencies) andpeerDependencies, the package's behavior is explicitly handled to prioritize the standard dependency declaration [1][2]. 1. Prioritization: When computing dependencies and resolving versions, pnpm treats entries independencies(ordevDependencies) as the primary requirement [2]. If a package name appears in both, thedependenciesentry effectively takes precedence, and pnpm will not attempt to resolve the peer dependency from the parent graph for that specific name, as it is already satisfied by the localdependencies[1][3]. 2. Resolution Logic: During the internal resolution phase, pnpm'speerDependenciesWithoutOwnutility explicitly filters out any package names that exist in a package's owndependenciesoroptionalDependenciesfrom itspeerDependencieslist [1]. This ensures that the package does not treat its own explicit dependency as a peer dependency, avoiding redundant or conflicting resolutions [1]. 3. Practical Use Case: This pattern is commonly used by library authors to ensure that a required peer dependency is available during development or testing (by including it independenciesordevDependencies) while still declaring it as apeerDependencyto allow the consuming project to control the version [4]. When combined withpeerDependenciesMetaset tooptional: true, this allows users to install the package normally or omit it if they wish to provide their own version [4]. In summary, pnpm is designed to be intelligent about this overlap; it effectively "sheds" the peer dependency requirement if it is already satisfied by a standard dependency in the same package [5].Citations:
🌐 Web query:
React invalid hook call warning duplicate copies of React official documentation💡 Result:
The "Invalid Hook Call" warning caused by duplicate copies of React occurs when your application and a dependency (or multiple parts of your project) resolve the
reactpackage to different module instances [1][2]. For React Hooks to function correctly, thereactimport used by your application code must be the exact same module instance as thereactimport used internally byreact-dom[1][2]. If these imports resolve to different objects, React cannot properly manage the internal state, triggering the error [1]. Common causes and solutions include: 1. Improper Dependency Management: A library might incorrectly listreactas adependencyrather than apeerDependencyin itspackage.json[1][2]. This forces the library to include its own copy of React, which conflicts with your application's copy [3][4]. Ensure libraries usepeerDependenciesfor React so that they rely on the host application's version [3][4]. 2. Development Environment Setup: Usingnpm linkor similar tools can cause your bundler to resolve two different paths to thereactpackage (e.g., one in your app'snode_modulesand one in the library'snode_modules) [1][2]. To fix this, you can link the library'sreactdependency to your application'sreactfolder [1][2]. 3. Diagnosis: - Runnpm ls reactin your project folder to check for multiple versions [1][2]. - You can debug this by adding a log in your code or innode_modules/reactto verify ifrequire('react') === require('react-dom/node_modules/react')returnstrueorfalse[1][2]. If it returnsfalse, you have duplicate instances [1][2]. 4. Workarounds: If you cannot immediately fix an offending third-party library, you may use package manager features like Yarn resolutions or npm overrides to force all packages to use a single version/instance of React [1][2]. While React technically supports multiple instances on a single page in some scenarios, having duplicatereactpackages within the same dependency tree is considered a configuration error and will break Hooks [1][2][5].Citations:
Keep React packages as peers, not runtime dependencies.
Because pnpm prioritizes a package’s own dependency over its peer dependency, the exported components can load the package’s
reactinstance while the consumer renderer loads another instance. React Hooks can then fail with an invalid hook call. Movereactandreact-domtodevDependencies, and retain them inpeerDependencies.🤖 Prompt for AI Agents