Add on-page editor for the go-graph-cms

Injectable React editor that is side-loaded onto the statically rendered
page and edits through the CMS HTTP API (POST /api/edit).

- Shadow-root UI over server HTML; patches morphed in, undo/redo from
  server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor)
- Sign-in via go-auth device grant as the CMS client (aud matches), token
  refresh + 401 retry; live preview swapped in on load and on tab focus
- Schema-driven property form (arrays, maps, variants, $ref, validation)
- Every string property editable where it is rendered (value-bound inline
  fields and attribute chips), markdown WYSIWYG for marked fields
- Block palette + drag & drop with ghost drop targets, insertion slots,
  sort snapping between siblings (Alt to nest)
- x-style annotations: on-page padding/margin/gap handles, Style panel,
  wrap-in-layout; x-graph label/group for titles and grouping
- Dev workflow: Vite proxy to the live site (token injected server-side)
  or an offline in-memory mock CMS; jsdom test suite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
const selectBlock = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const order = (parent: string) => [...byId(parent).querySelectorAll(':scope > [data-cms]')].map((e) => e.getAttribute('data-cms'))
describe('on-page editor against the mock CMS', () => {
it('scans blocks and selects on click without navigating', async () => {
h = await setup()
expect(h.editor.ctrl.store.get().blocks.map((b) => b.id)).toEqual(['s1', 'h1', 't1', 'b1', 's2', 't2', 'l1', 'x1', 'x2', 'f1'])
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
await act(async () => void byId('b1').querySelector('a')!.dispatchEvent(ev))
expect(ev.defaultPrevented).toBe(true)
expect(h.editor.ctrl.store.get().selected).toBe('b1')
})
it('browse mode leaves the page alone', async () => {
h = await setup()
await h.press('Toggle editing (links work in browse mode)')
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
await act(async () => void byId('b1').querySelector('a')!.dispatchEvent(ev))
expect(ev.defaultPrevented).toBe(false)
expect(h.editor.ctrl.store.get().selected).toBeNull()
})
it('resolves the edge through the API (no data-cms-edge in the markup) and moves a block', async () => {
h = await setup()
await selectBlock('t1')
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(order('s1')).toEqual(['h1', 'b1', 't1']) // morphed from the server patch
expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1'])
})
it('undo/redo replay the server inverse and re-morph the page', async () => {
h = await setup()
await selectBlock('t1')
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
await h.press('Undo (Ctrl+Z)')
expect(order('s1')).toEqual(['h1', 't1', 'b1'])
await h.press('Redo (Ctrl+Shift+Z)')
expect(order('s1')).toEqual(['h1', 'b1', 't1'])
})
it('delete + undo restores the subtree and DOM', async () => {
h = await setup()
await h.editor.ctrl.remove('s1')
await h.settle()
expect(document.querySelector('[data-cms="s1"]')).toBeNull()
await h.press('Undo (Ctrl+Z)')
expect(byId('h1').textContent).toBe('Edit this headline')
})
it('insert goes through the picker, uses x-graph children, and selects the new block', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Add inside')
const tiles = [...h.shadow.querySelectorAll<HTMLElement>('.cms-tile')].map((t) => t.dataset.type)
expect(tiles).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'section']))
expect(tiles).not.toContain('page')
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-tile[data-type="button"]')!.click())
await h.settle()
expect(h.cms.graph.children('s1')).toHaveLength(4)
expect(h.editor.ctrl.store.get().selected).toBe(h.cms.graph.children('s1')[3].to)
})
it('page-level insert resolves the root from the top block, and a root patch error falls back to a soft reload', async () => {
h = await setup()
await selectBlock('s1')
const root = await h.editor.ctrl.parentIdOf('s1')
expect(root).toBe('p1')
await h.editor.ctrl.insert(root, 'heading', { data: { text: 'Top-level' }, index: 0 })
await h.settle()
// The server could not render the page node standalone: the editor re-fetched and morphed.
expect(document.querySelector('[data-cms-type="heading"]')!.textContent).toBe('Top-level')
})
it('properties dialog edits through the schema form and saves only the diff', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Properties')
const input = h.shadow.querySelector<HTMLInputElement>('[data-path="title"] input')!
expect(input.value).toBe('Welcome')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Hello')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-btn--primary')!.click())
await h.settle()
expect(h.cms.graph.node('s1').data).toMatchObject({ title: 'Hello', tone: 'dark', padded: true })
expect(byId('s1').querySelector('.sec__title')!.textContent).toBe('Hello')
const edit = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')!
expect((edit.request as any).ops[0]).toMatchObject({ op: 'set', id: 's1', data: { title: 'Hello' }, version: 1 })
})
it('array editors save through the dialog as a minimal diff, and invalid data blocks Save', async () => {
h = await setup()
await selectBlock('f1')
await h.press('Properties')
const links = h.shadow.querySelector('[data-path="links"]')!
await act(async () => void links.querySelector<HTMLElement>('[aria-label="Add item"]')!.click())
const save = () => h.shadow.querySelector<HTMLButtonElement>('.cms-btn--primary')!
expect(save().disabled).toBe(true) // new link has an empty required "text", and nothing else changed yet
expect(h.shadow.querySelector('.cms-actions__note')!.textContent).toContain('1 problem')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
const text = h.shadow.querySelector<HTMLInputElement>('[data-path="links.2.text"] input')!
await act(async () => {
set.call(text, 'Contact')
text.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(save().disabled).toBe(false)
await act(async () => void save().click())
await h.settle()
expect(h.cms.graph.node('f1').data.links.map((l: any) => l.text)).toEqual(['About', 'Careers', 'Contact'])
const op = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')!.request as any
expect(Object.keys(op.ops[0].data)).toEqual(['links']) // companyName, usps, social... untouched
})
it('a stale version shows a conflict instead of overwriting', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Properties')
h.cms.graph.edit([{ op: 'set', id: 's1', data: { title: 'Someone else' } }]) // concurrent editor
const input = h.shadow.querySelector<HTMLInputElement>('[data-path="title"] input')!
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Mine')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-btn--primary')!.click())
await h.settle()
expect(h.cms.graph.node('s1').data.title).toBe('Someone else')
expect(h.editor.ctrl.store.get().toast?.text).toMatch(/Someone else changed/)
})
it('inline text editing sends debounced, coalesced sets (one undo step)', async () => {
h = await setup()
const field = byId('h1')
await act(async () => void field.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, composed: true })))
await h.settle()
expect(field.getAttribute('contenteditable')).toBeTruthy()
for (const t of ['Hel', 'Hello', 'Hello world']) {
field.textContent = t
field.dispatchEvent(new Event('input', { bubbles: true }))
}
h.editor.ctrl.stopInline() // flushes
await h.settle()
expect(h.cms.graph.node('h1').data.text).toBe('Hello world')
expect(field.hasAttribute('contenteditable')).toBe(false)
await h.press('Undo (Ctrl+Z)')
expect(h.cms.graph.node('h1').data.text).toBe('Edit this headline')
expect(byId('h1').textContent).toBe('Edit this headline')
})
it('delete offers an Undo toast that restores the block', async () => {
h = await setup()
await h.editor.ctrl.remove('t1')
await h.settle()
const toast = h.editor.ctrl.store.get().toast!
expect(toast.text).toBe('Block deleted')
toast.action!.run()
await h.settle()
expect(byId('t1')).not.toBeNull()
})
it('dropBlock re-parents with one move op and the page follows', async () => {
h = await setup()
await h.editor.ctrl.dropBlock('t1', { parentId: 's2', index: 0, line: { x: 0, y: 0, w: 0, h: 0 } })
await h.settle()
expect(order('s2')).toEqual(['t1', 't2'])
expect(order('s1')).toEqual(['h1', 'b1'])
// dropping where it already is does nothing
const before = h.editor.ctrl.store.get().log.length
await h.editor.ctrl.dropBlock('t1', { parentId: 's2', index: 0, line: { x: 0, y: 0, w: 0, h: 0 } })
expect(h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit')).toHaveLength(1)
expect(h.editor.ctrl.store.get().log.length).toBeGreaterThanOrEqual(before)
})
it('the base page is swapped for the latest preview on load, via /api/preview', async () => {
h = await setup({ stale: true })
await h.settle()
expect(byId('h1').textContent).toBe('Latest from the store')
expect(h.cms.requests.some((r) => r.startsWith('GET /api/preview?path=%2F'))).toBe(true)
})
it('falls back to the page URL with the preview header when /api/preview is missing', async () => {
h = await setup({ stale: true, noPreviewApi: true })
await h.settle()
expect(byId('h1').textContent).toBe('Latest from the store')
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/preview')).length).toBeGreaterThan(0)
})
it('does not touch the page while signed out (visitors keep the published file)', async () => {
h = await setup({ stale: true, auth: true })
await h.settle()
expect(byId('h1').textContent).toBe('Edit this headline')
expect(h.cms.requests.some((r) => r.includes('/api/preview'))).toBe(false)
})
it('refreshes when the tab becomes visible again (other editors\' changes)', async () => {
h = await setup()
await h.settle()
h.cms.graph.edit([{ op: 'set', id: 'h1', data: { text: 'Edited elsewhere' } }])
;(h.editor.ctrl as any).lastPreview = 0 // skip the 5s throttle
document.dispatchEvent(new Event('visibilitychange'))
await h.settle()
expect(byId('h1').textContent).toBe('Edited elsewhere')
})
it('exposes window.__cmsEditor and is idempotent', async () => {
h = await setup()
expect((window as any).__cmsEditor).toBe(h.editor)
const { boot } = await import('../src/boot')
expect(boot()).toBe(h.editor)
})
})