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>
191 lines
8.4 KiB
TypeScript
191 lines
8.4 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 { 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')
|
|
})
|
|
})
|