Files
matsandClaude Sonnet 5.5 1af9ec3172 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>
2026-10-31 22:34:48 +01:00

86 lines
3.7 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { attachDialog, registerEditor, registerFieldEditor } from '../src/registry'
import { diffData } from '../src/diff'
import { allowedChildren, fieldKind } from '../src/schema'
let h: Harness
afterEach(() => h?.cleanup())
describe('extension points', () => {
it('registerEditor adds a toolbar button for matching types only, and disposes cleanly', async () => {
h = await setup()
const off = registerEditor({ id: 't-heading', label: 'Heading tools', icon: 'H', match: 'heading', component: () => <p>heading dialog</p> })
await act(async () => void click(byId('h1')))
await h.settle()
expect(h.shadow.querySelector('[aria-label="Heading tools"]')).not.toBeNull()
await act(async () => void click(byId('t1')))
await h.settle()
expect(h.shadow.querySelector('[aria-label="Heading tools"]')).toBeNull()
off()
await act(async () => void click(byId('h1')))
await h.settle()
expect(h.shadow.querySelector('[aria-label="Heading tools"]')).toBeNull()
})
it('attachDialog attaches by selector and opens with the live node + schema', async () => {
h = await setup()
let seen: any
const off = attachDialog('.prose', ({ node, schema }) => ((seen = { id: node.id, type: node.dataType, props: Object.keys(schema.properties) }), <p>custom body editor</p>), { id: 'body-ed', label: 'Body' })
await act(async () => void click(byId('t1')))
await h.settle()
await h.press('Body')
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('custom body editor')
expect(seen).toEqual({ id: 't1', type: 'text', props: ['body'] })
off()
})
it('registerEditor with the same id replaces a built-in', async () => {
h = await setup()
registerEditor({ id: 'properties', label: 'Properties', match: '*', priority: 100, onDoubleClick: true, component: () => <p>my properties</p> })
await act(async () => void click(byId('t1')))
await h.press('Properties')
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('my properties')
})
it('field editors override form widgets by schema', async () => {
h = await setup()
const off = registerFieldEditor({
id: 'tone-swatches',
match: (s, path) => path === 'tone',
component: ({ value, onChange }) => (
<div className="swatches">
{['light', 'dark'].map((t) => (
<button type="button" key={t} aria-pressed={value === t} onClick={() => onChange(t)}>{t}</button>
))}
</div>
),
})
await act(async () => void click(byId('s1')))
await h.press('Properties')
expect(h.shadow.querySelector('.swatches')).not.toBeNull()
off()
})
})
describe('helpers', () => {
it('diffData: changed → value, removed → null, equal → omitted', () => {
expect(diffData({ a: 1, b: 2, c: { x: 1 } }, { a: 1, b: 3, c: { x: 1 } })).toEqual({ b: 3 })
expect(diffData({ a: 1, b: 2 }, { a: 1 })).toEqual({ b: null })
expect(diffData({}, { n: { x: 1 } })).toEqual({ n: { x: 1 } })
})
it('allowedChildren honours x-graph and "*"', () => {
expect(allowedChildren({ 'x-graph': { children: ['a'] } }, ['a', 'b'])).toEqual(['a'])
expect(allowedChildren({ 'x-graph': { children: ['*'] } }, ['a', 'b'])).toEqual(['a', 'b'])
expect(allowedChildren({}, ['a'])).toEqual([])
})
it('fieldKind picks widgets', () => {
expect(fieldKind({ type: 'string', format: 'markdown' })).toBe('markdown')
expect(fieldKind({ type: 'string', enum: ['a'] })).toBe('enum')
expect(fieldKind({ type: 'array' })).toBe('json')
expect(fieldKind({ type: ['string', 'null'] })).toBe('string')
})
})