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>
341 lines
15 KiB
TypeScript
341 lines
15 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 { Icon, TypeIcon, registerTypeIcon } from '../src/ui/Icon'
|
|
import { createRoot } from 'react-dom/client'
|
|
|
|
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 kids = (id: string) => h.cms.graph.children(id).map((e) => e.to)
|
|
/** jsdom has no layout: give elements rects. */
|
|
const place = (id: string, x: number, y: number, w: number, hh: number) => {
|
|
byId(id).getBoundingClientRect = () => ({ x, y, left: x, top: y, right: x + w, bottom: y + hh, width: w, height: hh, toJSON() {} }) as DOMRect
|
|
}
|
|
const ptr = (type: string, x: number, y: number, el: EventTarget = document, altKey = false) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, altKey })))
|
|
|
|
describe('icons', () => {
|
|
it('Icon renders an inline svg path; unknown names fall back; types get a guessed or registered icon', async () => {
|
|
const host = document.createElement('div')
|
|
const root = createRoot(host)
|
|
await act(async () => root.render(<><Icon name="edit" /><Icon name="nope" /><TypeIcon type="consulting-heading" /><TypeIcon type="weird" /><TypeIcon type="custom-x" /></>))
|
|
expect(host.querySelectorAll('svg path')).toHaveLength(5)
|
|
const d = [...host.querySelectorAll('path')].map((p) => p.getAttribute('d'))
|
|
expect(d[0]).not.toBe(d[1]) // edit vs fallback
|
|
expect(d[1]).toBe(d[3]) // unknown name and unknown type share the generic shape
|
|
expect(d[2]).not.toBe(d[3]) // "heading" guessed from the name
|
|
registerTypeIcon('custom-x', 'M0 0h24v24H0z')
|
|
await act(async () => root.render(<TypeIcon type="custom-x" />))
|
|
expect(host.querySelector('path')!.getAttribute('d')).toBe('M0 0h24v24H0z')
|
|
})
|
|
})
|
|
|
|
describe('toolbar overflow', () => {
|
|
it('JSON lives under the … menu, not in the toolbar', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('t1')
|
|
expect(h.shadow.querySelector('[role=toolbar] [aria-label="JSON"]')).toBeNull()
|
|
expect(h.shadow.querySelector('.cms-menu')).toBeNull()
|
|
await h.press('More actions')
|
|
expect(qa('.cms-menu [role=menuitem]').map((b) => b.textContent!.trim())).toEqual(['JSON', 'Copy node id'])
|
|
await act(async () => void qa('.cms-menu [role=menuitem]')[0].click())
|
|
await h.settle()
|
|
expect(h.shadow.querySelector('.cms-menu')).toBeNull() // closes on choice
|
|
expect(q('.cms-dialog').textContent).toContain('JSON')
|
|
})
|
|
})
|
|
|
|
describe('every property editable where it is rendered', () => {
|
|
it('binds nested properties to rendered text on selection and edits them in place with one click', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('f1')
|
|
const marked = [...byId('f1').querySelectorAll('[data-cms-inferred]')].map((e) => [e.getAttribute('data-cms-field'), e.textContent])
|
|
expect(marked).toEqual([['companyName', 'Mock AB'], ['links.0.text', 'About'], ['links.1.text', 'Careers']])
|
|
const about = byId('f1').querySelectorAll('a')[0] as HTMLElement
|
|
await act(async () => void click(about)) // block already selected: edits instead of selecting
|
|
await h.settle()
|
|
expect(about.getAttribute('contenteditable')).toBeTruthy()
|
|
about.textContent = 'About us'
|
|
about.dispatchEvent(new Event('input', { bubbles: true }))
|
|
h.editor.ctrl.stopInline()
|
|
await h.settle()
|
|
const links = h.cms.graph.node('f1').data.links
|
|
expect(links).toEqual([{ text: 'About us', href: 'https://example.com/about' }, { text: 'Careers', href: 'https://example.com/jobs', external: true }]) // siblings untouched
|
|
await h.press('Undo (Ctrl+Z)')
|
|
expect(h.cms.graph.node('f1').data.links[0].text).toBe('About')
|
|
})
|
|
|
|
it('markers follow the selection and are removed from the page when it moves on', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('f1')
|
|
expect(document.querySelectorAll('[data-cms-inferred]').length).toBe(3)
|
|
await select('t1')
|
|
expect(byId('f1').querySelectorAll('[data-cms-inferred]').length).toBe(0)
|
|
})
|
|
|
|
it('attribute-bound properties get a chip that edits them (href), nested paths included', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('f1')
|
|
const chips = qa('[aria-label="Edit href"]')
|
|
expect(chips).toHaveLength(2)
|
|
await act(async () => void chips[1].click())
|
|
await h.settle()
|
|
const input = q('.cms-dialog input') as HTMLInputElement
|
|
expect(input.value).toBe('https://example.com/jobs')
|
|
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
|
await act(async () => {
|
|
set.call(input, 'https://example.com/careers')
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
})
|
|
await act(async () => void q('.cms-dialog .cms-btn--primary').click())
|
|
await h.settle()
|
|
expect(h.cms.graph.node('f1').data.links[1]).toEqual({ text: 'Careers', href: 'https://example.com/careers', external: true })
|
|
expect(byId('f1').querySelectorAll('a')[1].getAttribute('href')).toBe('https://example.com/careers')
|
|
})
|
|
|
|
it('explicit template markers keep working (and are not double-bound)', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('h1')
|
|
expect(byId('h1').getAttribute('data-cms-inferred')).toBeNull()
|
|
expect(byId('h1').getAttribute('data-cms-field')).toBe('text')
|
|
})
|
|
})
|
|
|
|
describe('block palette and drag & drop', () => {
|
|
it('lists the types that fit the page, searchable', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await h.press('Blocks')
|
|
await h.settle()
|
|
const names = qa('.cms-block[data-type]').map((b) => b.dataset.type)
|
|
expect(names).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'layout', 'footer']))
|
|
expect(names).not.toContain('page') // a page root is not a block you insert
|
|
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
|
await act(async () => {
|
|
set.call(q('[aria-label="Search blocks"]'), 'head')
|
|
q('[aria-label="Search blocks"]').dispatchEvent(new Event('input', { bubbles: true }))
|
|
})
|
|
expect(qa('.cms-block > span').map((s) => s.textContent)).toEqual(['Heading', 'Hero (heading + text + button)']) // title shown; the blueprint matches too
|
|
})
|
|
|
|
it('click adds to the selection: inside a container, otherwise after the block', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await h.press('Blocks')
|
|
await select('s1') // container
|
|
await act(async () => void q('.cms-block[data-type="button"]').click())
|
|
await h.settle()
|
|
expect(kids('s1')).toHaveLength(4)
|
|
await select('t2') // leaf: goes after it, in s2
|
|
await act(async () => void q('.cms-block[data-type="heading"]').click())
|
|
await h.settle()
|
|
expect(h.cms.graph.node(kids('s2')[1]).dataType).toBe('heading')
|
|
})
|
|
|
|
it('drag from the palette shows a ghost at the target and inserts at the drop index', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await h.press('Blocks')
|
|
place('s1', 0, 0, 600, 300)
|
|
place('h1', 10, 10, 580, 50)
|
|
place('t1', 10, 70, 580, 100)
|
|
place('b1', 10, 180, 580, 40)
|
|
h.editor.ctrl.scan()
|
|
const item = q('.cms-block[data-type="heading"]')
|
|
await ptr('pointerdown', 50, 400, item)
|
|
await ptr('pointermove', 50, 400) // under the threshold: nothing yet
|
|
expect(h.editor.ctrl.store.get().drag).toBeNull()
|
|
await ptr('pointermove', 80, 100) // past it: drag starts (this event only arms it) ...
|
|
await ptr('pointermove', 80, 100) // ... and the next one targets s1
|
|
const drag = h.editor.ctrl.store.get().drag!
|
|
expect(drag).toMatchObject({ source: 'palette', type: 'heading', target: { parentId: 's1', index: 1 } })
|
|
expect(q('.cms-ghost').textContent).toContain('New Heading')
|
|
await ptr('pointerup', 80, 100)
|
|
await h.settle()
|
|
const order = kids('s1')
|
|
expect(order).toHaveLength(4)
|
|
expect(h.cms.graph.node(order[1]).dataType).toBe('heading')
|
|
expect(h.editor.ctrl.store.get().drag).toBeNull()
|
|
expect(h.shadow.querySelector('.cms-ghost')).toBeNull()
|
|
})
|
|
|
|
it('Escape cancels a drag; a container that does not accept the type refuses it', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
|
|
expect(h.editor.ctrl.store.get().drag).not.toBeNull()
|
|
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
|
|
expect(h.editor.ctrl.store.get().drag).toBeNull()
|
|
expect(h.editor.ctrl.accepts('s1', 'heading')).toBe(true)
|
|
expect(h.editor.ctrl.accepts('s1', 'page')).toBe(false)
|
|
expect(h.editor.ctrl.accepts('t1', 'heading')).toBe(false) // a leaf block holds nothing
|
|
expect(h.editor.ctrl.accepts('', 'anything')).toBe(true) // page root
|
|
})
|
|
|
|
it('moving an existing block uses the same engine and respects acceptance', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
place('s1', 0, 0, 600, 300)
|
|
place('h1', 10, 10, 580, 50)
|
|
place('t1', 10, 70, 580, 100)
|
|
place('b1', 10, 180, 580, 40)
|
|
h.editor.ctrl.scan()
|
|
h.editor.ctrl.beginDrag('b1')
|
|
await ptr('pointermove', 80, 20)
|
|
expect(h.editor.ctrl.store.get().drag).toMatchObject({ source: 'block', id: 'b1', target: { parentId: 's1', index: 0 } })
|
|
await ptr('pointerup', 80, 20)
|
|
await h.settle()
|
|
expect(kids('s1')).toEqual(['b1', 'h1', 't1'])
|
|
})
|
|
})
|
|
|
|
describe('insertion slots (virtual "+" elements)', () => {
|
|
it('a selected container offers a slot between each child and at the end; a slot opens the picker at that index', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('s1')
|
|
expect(qa('.cms-slot').map((s) => s.getAttribute('aria-label'))).toEqual(['Add block at position 1', 'Add block at position 2', 'Add block at position 3', 'Add block at position 4'])
|
|
await act(async () => void qa('.cms-slot')[1].click())
|
|
await h.settle()
|
|
expect([...h.shadow.querySelectorAll('.cms-dialog h4')].map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints']) // grouped by x-graph.group
|
|
await act(async () => void q('.cms-dialog .cms-tile[data-type="button"]').click())
|
|
await h.settle()
|
|
expect(h.cms.graph.node(kids('s1')[1]).dataType).toBe('button')
|
|
expect(kids('s1')).toHaveLength(4)
|
|
})
|
|
|
|
it('an empty container shows one big "Add block" slot', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await h.editor.ctrl.insert('s2', 'section')
|
|
await h.settle()
|
|
expect(qa('.cms-slot--empty')).toHaveLength(1)
|
|
expect(q('.cms-slot--empty').textContent).toContain('Add block')
|
|
})
|
|
|
|
it('leaf blocks show no slots', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('t1')
|
|
expect(qa('.cms-slot')).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('sorting root nodes (snap between siblings)', () => {
|
|
const roots = () => {
|
|
place('s1', 0, 0, 600, 300)
|
|
place('h1', 10, 10, 580, 50)
|
|
place('t1', 10, 70, 580, 100)
|
|
place('b1', 10, 180, 580, 40)
|
|
place('s2', 0, 320, 600, 100)
|
|
place('t2', 10, 330, 580, 80)
|
|
place('l1', 0, 440, 600, 100) // a container: used to swallow the drop
|
|
place('x1', 10, 450, 280, 80)
|
|
place('x2', 310, 450, 280, 80)
|
|
place('f1', 0, 560, 600, 50)
|
|
h.editor.ctrl.scan()
|
|
}
|
|
const order = () => kids('p1')
|
|
|
|
it('dragging a root block over another container snaps between roots instead of nesting', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
roots()
|
|
h.editor.ctrl.beginDrag('s2')
|
|
await ptr('pointermove', 80, 500) // deep inside the l1 container
|
|
const drag = h.editor.ctrl.store.get().drag!
|
|
expect(drag.target).toMatchObject({ parentId: '', index: 2 }) // siblings s1, l1, f1 -> between l1 and f1
|
|
expect(drag.target!.snaps).toHaveLength(4)
|
|
expect(h.shadow.querySelectorAll('.cms-snap')).toHaveLength(4)
|
|
expect(q('.cms-dragchip').textContent).toContain('sorting')
|
|
expect(q('.cms-dropline').style.top).toBe('548.5px') // centred in the 540..560 gap
|
|
await ptr('pointerup', 80, 500)
|
|
await h.settle()
|
|
expect(order()).toEqual(['s1', 'l1', 's2', 'f1'])
|
|
expect(kids('l1')).toEqual(['x1', 'x2']) // nothing was nested
|
|
})
|
|
|
|
it('the snap marker follows the nearest boundary as the pointer moves', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
roots()
|
|
h.editor.ctrl.beginDrag('f1')
|
|
const idx = () => h.editor.ctrl.store.get().drag!.target!.index
|
|
await ptr('pointermove', 80, 20)
|
|
expect(idx()).toBe(0)
|
|
await ptr('pointermove', 80, 330)
|
|
expect(idx()).toBe(1)
|
|
await ptr('pointermove', 80, 450)
|
|
expect(idx()).toBe(2)
|
|
await ptr('pointerup', 80, 20)
|
|
await h.settle()
|
|
expect(order()).toEqual(['f1', 's1', 's2', 'l1'])
|
|
})
|
|
|
|
it('holding Alt nests into the container under the pointer; pressing/releasing Alt re-targets without a move', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
roots()
|
|
h.editor.ctrl.beginDrag('s2')
|
|
await ptr('pointermove', 80, 500)
|
|
expect(h.editor.ctrl.store.get().drag!.target!.parentId).toBe('')
|
|
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true })))
|
|
expect(h.editor.ctrl.store.get().drag!.target).toMatchObject({ parentId: 'l1' })
|
|
expect(q('.cms-dragchip').textContent).toContain('nesting')
|
|
expect(h.shadow.querySelectorAll('.cms-snap')).toHaveLength(0)
|
|
await act(async () => void document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true })))
|
|
expect(h.editor.ctrl.store.get().drag!.target!.parentId).toBe('')
|
|
await ptr('pointermove', 80, 500, document, true) // moving with Alt held nests
|
|
await ptr('pointerup', 80, 500, document, true)
|
|
await h.settle()
|
|
expect(kids('l1')).toContain('s2')
|
|
expect(order()).toEqual(['s1', 'l1', 'f1'])
|
|
})
|
|
|
|
it('dropping a root block where it already is changes nothing; over editor chrome there is no target', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
roots()
|
|
const edits = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').length
|
|
const before = edits()
|
|
h.editor.ctrl.beginDrag('s2')
|
|
await ptr('pointermove', 80, 300) // nearest boundary is its own slot
|
|
await ptr('pointerup', 80, 300)
|
|
await h.settle()
|
|
expect(edits()).toBe(before)
|
|
h.editor.ctrl.beginDrag('s2')
|
|
await ptr('pointermove', 900, 300) // beside the page: over the rail / panels
|
|
expect(h.editor.ctrl.store.get().drag!.target).toBeNull()
|
|
expect(q('.cms-dragchip').textContent).toContain('not here')
|
|
await ptr('pointerup', 900, 300)
|
|
await h.settle()
|
|
expect(edits()).toBe(before)
|
|
})
|
|
|
|
it('nested blocks sort among their siblings the same way, in a row too', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
roots()
|
|
h.editor.ctrl.beginDrag('x1')
|
|
await ptr('pointermove', 520, 480) // right half of the row, over x2
|
|
const t = h.editor.ctrl.store.get().drag!.target!
|
|
expect(t).toMatchObject({ parentId: 'l1', index: 1 }) // after x2 (siblings without x1: x2 only)
|
|
expect(t.line.w).toBe(3) // a vertical line: the siblings are side by side
|
|
await ptr('pointerup', 520, 480)
|
|
await h.settle()
|
|
expect(kids('l1')).toEqual(['x2', 'x1'])
|
|
})
|
|
})
|