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

+190
View File
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { OTHER_GROUP, groupOf, labelKey, sortGroups } from '../src/schema'
import { schemas } from '../mock/fixtures'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
const label = (id: string) => h.editor.ctrl.labelFor(id)
describe('x-graph label / group helpers', () => {
it('read the label property and the group from the schema; sort groups with the catch-all last', () => {
expect(labelKey({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('title')
expect(groupOf({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('Commerce')
expect(labelKey({ 'x-graph': {} })).toBeUndefined()
expect(labelKey(undefined)).toBeUndefined()
expect(groupOf({ 'x-graph': { group: 3 } })).toBeUndefined()
expect(sortGroups(['Layout', OTHER_GROUP, 'Commerce', 'Layout', 'Content'])).toEqual(['Commerce', 'Content', 'Layout', OTHER_GROUP])
expect(labelKey(schemas.footer)).toBe('companyName')
})
})
describe('node titles (x-graph.label)', () => {
it('loads every title with ONE tree request, not one request per block', async () => {
h = await setup()
await h.settle()
await h.settle()
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/tree/')).length).toBe(1)
expect(h.cms.requests.filter((r) => /^GET \/api\/nodes\/[^/]+$/.test(r))).toEqual([])
expect(['s1', 'h1', 'b1', 'f1', 'l1'].map(label)).toEqual(['Welcome', 'Edit this headline', 'Get in touch', 'Mock AB', 'Two columns'])
expect(label('t1')).toBeUndefined() // the text type declares no label
})
it('shows the title in the outline (type as the secondary line), falling back to type + text', async () => {
h = await setup()
await h.settle()
await h.settle()
await h.press('Outline')
const items = qa('.cms-outline li button')
const s1 = items.find((b) => b.textContent!.includes('Welcome'))!
expect(s1.querySelector('.cms-outline__type')!.textContent).toBe('Welcome')
expect(s1.querySelector('.cms-outline__text')!.textContent).toBe('section')
const t1 = items.find((b) => b.querySelector('.cms-outline__type')!.textContent === 'text')!
expect(t1.querySelector('.cms-outline__text')!.textContent).toContain('A paragraph')
})
it('titles the toolbar chip (type and group in its tooltip), the hover tag and the dialog', async () => {
h = await setup()
await h.settle()
await h.settle()
await select('s1')
expect(q('.cms-bar__name').textContent).toBe('Welcome')
expect(q('.cms-bar__name').getAttribute('title')).toBe('section · Layout')
h.editor.ctrl.store.set({ hovered: 'f1' })
await h.settle()
const tag = q('.cms-frame__tag')
expect(tag.textContent).toContain('Mock AB')
expect(tag.textContent).toContain('footer')
expect(tag.querySelector('em')!.textContent).toBe('Layout')
await h.press('Properties')
expect(q('.cms-dialog__head').textContent).toContain('Properties · Welcome')
await select('t1')
expect(q('.cms-bar__name').textContent).toBe('text') // no label key: the type
})
it('titles follow edits live, and new nodes get theirs lazily', async () => {
h = await setup()
await h.settle()
await h.settle()
await select('s1')
await h.editor.ctrl.setData('s1', { title: 'Renamed' })
await h.settle()
await h.settle()
expect(label('s1')).toBe('Renamed')
expect(q('.cms-bar__name').textContent).toBe('Renamed')
const res = await h.editor.ctrl.insert('s2', 'button', { data: { label: 'Click me' } })
await h.settle()
const id = res!.results[0].node!
label(id) // first ask kicks off the fetch ...
await h.settle()
await h.settle()
expect(label(id)).toBe('Click me') // ... and it is there once loaded
})
it('long titles are shortened', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.setData('h1', { text: 'x'.repeat(80) })
await h.settle()
await h.settle()
expect(label('h1')!.length).toBe(40)
expect(label('h1')!.endsWith('…')).toBe(true)
})
})
describe('groups in the UI', () => {
it('the palette is grouped by x-graph.group (collapsible), blueprints last', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
await h.settle()
const secs = qa('.cms-group-sec')
expect(secs.map((s) => s.querySelector('summary')!.textContent!.replace(/\s*\d+$/, '').trim())).toEqual(['Content', 'Layout', 'Blueprints'])
const inGroup = (g: string) => [...secs.find((s) => s.querySelector('summary')!.textContent!.startsWith(g))!.querySelectorAll<HTMLElement>('.cms-block')].map((x) => x.dataset.type)
expect(inGroup('Content')).toEqual(['heading', 'text', 'button'])
expect(inGroup('Layout')).toEqual(expect.arrayContaining(['section', 'layout', 'footer']))
expect(secs.every((s) => (s as HTMLDetailsElement).open)).toBe(true)
await act(async () => void ((secs[0].querySelector('summary') as HTMLElement).click()))
expect((secs[0] as HTMLDetailsElement).open).toBe(false)
})
it('the insert picker groups the allowed types too', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Add inside')
await h.settle()
await h.settle()
expect(qa('.cms-dialog h4').map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints'])
expect(qa('.cms-dialog .cms-tile').map((x) => x.dataset.type)).toEqual(['heading', 'text', 'button', 'section', 'layout', undefined]) // + the blueprint tile
})
})
describe('the block list shows titles and groups straight from /api/types', () => {
it('seeds every schema from the one types request - no per-type schema fetches for the palette', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
await h.settle()
// /api/types carries each type's schema: types that are not on the page are never fetched individually
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/schemas/page'))).toEqual([])
expect(h.cms.requests.filter((r) => r === 'GET /api/types').length).toBe(1)
expect(h.editor.ctrl.schemaOf('page')).toBeDefined()
expect(h.editor.ctrl.groupOfType('page')).toBe('Pages')
})
it('palette items show the schema title with the type id underneath (disambiguates look-alikes)', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
const heading = q('.cms-block[data-type="heading"]')
expect(heading.querySelector('span')!.textContent).toBe('Heading')
expect(heading.querySelector('small')!.textContent).toBe('heading')
expect(heading.getAttribute('title')).toBe('Add Heading')
expect(h.editor.ctrl.typeTitle('footer')).toBe('Footer')
expect(h.editor.ctrl.typeTitle('unknown-type')).toBe('unknown-type')
})
it('page-root (staticRoot) types are not offered, even where a container would accept anything', async () => {
h = await setup()
await h.settle()
expect(h.editor.ctrl.isStaticRoot('page')).toBe(true)
expect(h.editor.ctrl.isStaticRoot('heading')).toBe(false)
await h.press('Blocks')
await h.settle()
expect(qa('.cms-block[data-type="page"]')).toHaveLength(0)
expect(qa('.cms-group-sec summary').map((s) => s.textContent)).not.toContain('Pages')
})
it('the insert picker shows titles too', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Add inside')
await h.settle()
const tile = q('.cms-dialog .cms-tile[data-type="layout"]')
expect(tile.querySelector('span')!.textContent).toBe('Layout')
expect(tile.querySelector('small')!.textContent).toBe('layout')
})
it('ingestTypes tolerates entries without a schema (they stay "Other" until loaded)', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.ingestTypes([{ type: 'mystery' }, 'bare-string', { type: 'with', schema: { title: 'With', 'x-graph': { group: 'Odd' } } }])
expect(h.editor.ctrl.groupOfType('mystery')).toBe('Other')
expect(h.editor.ctrl.groupOfType('with')).toBe('Odd')
expect(h.editor.ctrl.typeTitle('with')).toBe('With')
})
})