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>
86 lines
3.7 KiB
TypeScript
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')
|
|
})
|
|
})
|