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:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user