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:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { AuthManager } from '../src/auth'
let h: Harness
afterEach(() => h?.cleanup())
const until = async (cond: () => boolean, ms = 1500) => {
const t0 = Date.now()
while (!cond()) {
if (Date.now() - t0 > ms) throw new Error('timed out waiting')
await act(async () => void (await new Promise((r) => setTimeout(r, 10))))
}
}
const status = () => h.editor.ctrl.store.get().auth.status
const signIn = async () => {
await until(() => status() === 'signed-out')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Sign in"] .cms-btn--primary')!.click())
await until(() => status() === 'pending')
h.cms.approve()
await until(() => status() === 'signed-in')
await h.settle()
}
describe('sign-in (device grant)', () => {
it('shows the login panel and blocks editing while signed out', async () => {
h = await setup({ auth: true })
await until(() => status() === 'signed-out')
expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull()
await act(async () => void click(byId('t1')))
expect(h.editor.ctrl.store.get().selected).toBeNull()
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
byId('b1').querySelector('a')!.dispatchEvent(ev)
expect(ev.defaultPrevented).toBe(false) // the page behaves like a normal page
})
it('walks through user code -> approval -> signed in, then editing works', async () => {
h = await setup({ auth: true })
await until(() => status() === 'signed-out')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Sign in"] .cms-btn--primary')!.click())
await until(() => status() === 'pending')
expect(h.shadow.querySelector('.cms-code')!.textContent).toBe('MOCK-1234')
expect(h.shadow.querySelector<HTMLAnchorElement>('.cms-login__link')!.href).toContain('/device?user_code=MOCK-1234')
h.cms.approve()
await until(() => status() === 'signed-in')
await h.settle()
expect(h.shadow.querySelector('[aria-label="Sign in"]')).toBeNull()
expect(h.editor.ctrl.store.get().toast!.text).toBe('Signed in as mock-user')
await act(async () => void click(byId('t1')))
expect(h.editor.ctrl.store.get().selected).toBe('t1')
})
it('refreshes transparently when the access token expires', async () => {
h = await setup({ auth: true })
await signIn()
const before = h.auth.headers().Authorization
h.cms.expireAccess()
await act(async () => void click(byId('t1')))
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1'])
expect(h.auth.headers().Authorization).not.toBe(before) // a new access token was minted via the refresh token
expect(status()).toBe('signed-in')
})
it('a revoked session drops back to the login panel', async () => {
h = await setup({ auth: true })
await signIn()
await act(async () => void click(byId('t1')))
h.cms.revoke()
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(status()).toBe('signed-out')
expect(h.editor.ctrl.store.get().selected).toBeNull()
expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull()
})
it('sign out clears the session; pasted tokens work', async () => {
h = await setup({ auth: true, token: 'static-token' })
await until(() => status() === 'signed-out')
await h.editor.ctrl.signInWithToken('static-token')
await h.settle()
expect(status()).toBe('signed-in')
h.editor.ctrl.signOut()
expect(status()).toBe('signed-out')
})
})
describe('AuthManager', () => {
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status })
const grant = { device_code: 'd', user_code: 'U', verification_uri: 'https://i/device', interval: 1, expires_in: 600 }
function manager(poll: Array<Response>, extra: Partial<ConstructorParameters<typeof AuthManager>[0]> = {}) {
const sleeps: number[] = []
const calls: string[] = []
const fetch = (async (url: string) => {
calls.push(String(url))
if (String(url).endsWith('/api/auth/device')) return json(200, grant)
if (String(url).endsWith('/device/token')) return poll.shift()!
throw new Error('unexpected ' + url)
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null, sleep: async (ms) => void sleeps.push(ms), ...extra })
return { auth, sleeps, calls }
}
it('honours slow_down by backing off 5s', async () => {
const { auth, sleeps } = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'slow_down' }), json(400, { error: 'authorization_pending' }), json(200, { access_token: 'a', refresh_token: 'r', expires_in: 900 })])
expect(await auth.login()).toBe(true)
expect(sleeps).toEqual([1000, 1000, 6000, 6000])
expect(auth.headers()).toEqual({ Authorization: 'Bearer a' })
})
it('runs the grant as the CMS client from /api/auth/config so the token audience matches', async () => {
const bodies: string[] = []
const fetch = (async (url: string, init?: RequestInit) => {
const u = String(url)
if (u === '/api/auth/config') return json(200, { issuer: 'https://i', clientId: 'k6n-admin' })
bodies.push(`${u.replace('https://i', '')} ${String(init?.body)}`)
if (u.endsWith('/api/auth/device')) return json(200, grant)
return json(200, { access_token: 'a', expires_in: 900 })
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, persistent: null, session: null, sleep: async () => {} })
expect(await auth.login()).toBe(true)
expect(bodies[0]).toContain('client_id=k6n-admin') // start
expect(bodies[1]).toContain('client_id=k6n-admin') // poll: must be the same client
})
it('surfaces the server reason (e.g. wrong audience) when a token is rejected', async () => {
const fetch = (async () => json(401, { error: 'unauthorized: oidc: wrong audience' })) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, persistent: null, session: null })
auth.useToken('t')
await auth.check(async () => {
throw Object.assign(new Error('unauthorized: oidc: wrong audience'), { status: 401 })
})
expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: expect.stringContaining('wrong audience') })
})
it('reports denial and expiry', async () => {
const denied = manager([json(400, { error: 'access_denied' })])
expect(await denied.auth.login()).toBe(false)
expect(denied.auth.state.get()).toMatchObject({ status: 'signed-out', error: 'sign-in was denied' })
const expired = manager([json(400, { error: 'expired_token' })])
await expired.auth.login()
expect(expired.auth.state.get().error).toMatch(/expired/)
})
it('cancel stops polling quietly', async () => {
let auth!: AuthManager
const m = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'authorization_pending' })], { sleep: async () => auth.cancel() })
auth = m.auth
expect(await auth.login()).toBe(false)
expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: undefined })
})
it('production keeps the refresh token out of localStorage', async () => {
const mk = () => {
const m = new Map<string, string>()
return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v), removeItem: (k: string) => void m.delete(k), data: m } as unknown as Storage & { data: Map<string, string> }
}
for (const persistRefresh of [false, true]) {
const local = mk()
const session = mk()
const { auth } = manager([json(200, { access_token: 'a', refresh_token: 'secret-refresh', expires_in: 900 })], { persistent: local, session, persistRefresh })
await auth.login()
expect([...local.data.values()].join('').includes('secret-refresh')).toBe(persistRefresh)
expect([...session.data.values()].join('').includes('secret-refresh')).toBe(!persistRefresh)
}
})
it('shares one refresh between concurrent callers (refresh tokens rotate)', async () => {
let refreshes = 0
const fetch = (async (url: string) => {
if (String(url).endsWith('/device/refresh')) {
refreshes++
return json(200, { access_token: 'new', refresh_token: 'r2', expires_in: 900 })
}
return json(200, grant)
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null })
auth.useToken('old')
;(auth as any).tokens = { accessToken: 'old', refreshToken: 'r1', expiresAt: 0 }
await Promise.all([auth.refresh(), auth.refresh(), auth.ensureFresh()])
expect(refreshes).toBe(1)
expect(auth.headers()).toEqual({ Authorization: 'Bearer new' })
})
})
+150
View File
@@ -0,0 +1,150 @@
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 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 dialogs = () => qa('.cms-dialog')
const z = (el: HTMLElement) => Number(el.style.zIndex)
describe('one dialog per thing: opening again focuses the existing one', () => {
it('Properties opened twice for the same block is one dialog, raised, pulsing and focused', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
expect(dialogs()).toHaveLength(1)
const first = dialogs()[0]
const z0 = z(first)
await act(async () => void (document.activeElement as HTMLElement | null)?.blur?.())
await h.press('Properties') // again
expect(dialogs()).toHaveLength(1)
expect(dialogs()[0]).toBe(first) // the same element, not a replacement (form state is kept)
expect(z(first)).toBeGreaterThan(z0)
expect(first.className).toContain('is-pulsing')
expect(h.shadow.activeElement).toBe(first.querySelector('input'))
})
it('keeps unsaved edits when the dialog is asked for again', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
const input = q('[data-path="title"] input') as HTMLInputElement
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Unsaved draft')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await h.press('Properties')
expect((q('[data-path="title"] input') as HTMLInputElement).value).toBe('Unsaved draft')
})
it('a double click before the first dialog has loaded still opens only one', async () => {
h = await setup()
await h.settle()
await Promise.all([h.editor.ctrl.openEditor('properties', 's1'), h.editor.ctrl.openEditor('properties', 's1')])
await h.settle()
expect(dialogs()).toHaveLength(1)
})
it('different blocks or editors get their own dialogs; reopening the older one raises it above the newer', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
await select('l1')
await h.press('Properties')
await h.press('Style')
expect(dialogs()).toHaveLength(3)
const [s1Props, l1Props, l1Style] = dialogs()
expect(z(l1Style)).toBeGreaterThan(z(l1Props))
await select('s1')
await h.press('Properties') // the oldest dialog comes forward
expect(dialogs()).toHaveLength(3)
expect(z(s1Props)).toBeGreaterThan(z(l1Style))
})
it('clicking a dialog brings it to the front; Escape closes the topmost', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
await select('t1')
await h.press('JSON').catch(() => {}) // under the … menu: open via the controller instead
await h.editor.ctrl.openEditor('raw-json', 't1')
await h.settle()
const [a, b] = dialogs()
expect(z(b)).toBeGreaterThan(z(a))
await act(async () => void a.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, composed: true })))
expect(z(a)).toBeGreaterThan(z(b))
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
expect(dialogs()).toHaveLength(1)
expect(dialogs()[0]).toBe(b) // a was on top, so a closed
})
it('the same insert slot is one picker; another slot opens another', async () => {
h = await setup()
await h.settle()
await select('s1')
await act(async () => void qa('.cms-slot')[1].click())
await h.settle()
await act(async () => void qa('.cms-slot')[1].click())
await h.settle()
expect(dialogs()).toHaveLength(1)
await act(async () => void qa('.cms-slot')[2].click())
await h.settle()
expect(dialogs()).toHaveLength(2)
})
it('an attribute chip opens one editor per property', async () => {
h = await setup()
await h.settle()
await select('f1')
const chip = () => qa('[aria-label="Edit href"]')[0]
await act(async () => void chip().click())
await h.settle()
await act(async () => void chip().click())
await h.settle()
expect(dialogs()).toHaveLength(1)
await act(async () => void qa('[aria-label="Edit href"]')[1].click())
await h.settle()
expect(dialogs()).toHaveLength(2)
})
it('after closing, the same dialog can be opened again', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
await h.press('Close')
expect(dialogs()).toHaveLength(0)
await h.press('Properties')
expect(dialogs()).toHaveLength(1)
})
})
describe('the property editor uses icons, not text glyphs', () => {
it('array, map and group controls are svg icons', async () => {
h = await setup()
await h.settle()
await select('f1')
await h.press('Properties')
const form = q('.cms-dialog').textContent!
expect(form).not.toMatch(/[↑↓⧉✕+⠿▸↔↺]/)
for (const label of ['Move item 1 down', 'Duplicate item 1', 'Remove item 1', 'Add item']) {
expect(q(`[aria-label="${label}"] svg path`), label).not.toBeNull()
}
expect(q('[aria-label="Add entry"] svg')).not.toBeNull()
expect(q('.cms-group > summary svg.cms-chev')).not.toBeNull()
expect(q('.cms-actions .cms-btn--primary svg')).not.toBeNull()
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { dropTarget } from '../src/page/dnd'
import type { DndBlock } from '../src/page/dnd'
const r = (y: number, h = 100, x = 0, w = 600) => ({ x, y, w, h })
// page: A (container: a1, a2) , B (container, empty), C (leaf)
const blocks: DndBlock[] = [
{ id: 'A', container: true, depth: 0, rect: r(0, 300) },
{ id: 'a1', parentId: 'A', container: false, depth: 1, rect: r(10, 100, 10, 580) },
{ id: 'a2', parentId: 'A', container: false, depth: 1, rect: r(120, 100, 10, 580) },
{ id: 'B', container: true, depth: 0, rect: r(310, 100) },
{ id: 'C', container: false, depth: 0, rect: r(420, 100) },
]
describe('dropTarget', () => {
it('picks the index from the pointer relative to sibling midpoints', () => {
expect(dropTarget(blocks, { x: 50, y: 20 }, 'C')).toMatchObject({ parentId: 'A', index: 0 })
expect(dropTarget(blocks, { x: 50, y: 100 }, 'C')).toMatchObject({ parentId: 'A', index: 1 })
expect(dropTarget(blocks, { x: 50, y: 250 }, 'C')).toMatchObject({ parentId: 'A', index: 2 })
})
it('excludes the dragged block from the index maths', () => {
// dragging a1 within A: pointer below a2's midpoint -> after a2 = index 1 among [a2]
expect(dropTarget(blocks, { x: 50, y: 200 }, 'a1')).toMatchObject({ parentId: 'A', index: 1 })
})
it('drops into an empty container', () => {
const t = dropTarget(blocks, { x: 50, y: 350 }, 'C')!
expect(t).toMatchObject({ parentId: 'B', index: 0 })
expect(t.line.y).toBeGreaterThanOrEqual(310)
})
it('never targets the dragged block or its descendants', () => {
expect(dropTarget(blocks, { x: 50, y: 20 }, 'A')?.parentId).toBe('') // A is not a drop target for itself -> page level
})
it('page level uses the root sentinel and refuses far-away points', () => {
expect(dropTarget(blocks, { x: 50, y: 460 }, 'a1')).toMatchObject({ parentId: '', index: 2 })
expect(dropTarget(blocks, { x: 50, y: 500 }, 'a1')).toMatchObject({ parentId: '', index: 3 })
expect(dropTarget(blocks, { x: 50, y: 900 }, 'a1')).toBeNull()
})
})
+226
View File
@@ -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)
})
})
+213
View File
@@ -0,0 +1,213 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, within } from '@testing-library/react'
import { useState } from 'react'
import { SchemaForm } from '../src/form/Form'
import { validate } from '../src/form/validate'
import { defaultFor, pickVariant, resolveSchema } from '../src/form/resolve'
import { schemas } from '../mock/fixtures'
afterEach(cleanup)
/** Controlled harness; `onValue` sees every change so tests can assert the exact data written. */
function Harness({ schema, initial, onValue }: { schema: any; initial: any; onValue(v: any): void }) {
const [v, setV] = useState(initial)
return <SchemaForm schema={schema} value={v} onChange={(n) => (setV(n), onValue(n))} node={{ id: 'n', dataType: 't', data: initial }} ctrl={null as never} />
}
function mount(schema: any, initial: any = {}) {
let last = initial
const r = render(<Harness schema={schema} initial={initial} onValue={(v) => (last = v)} />)
return { ...r, value: () => last, field: (path: string) => r.container.querySelector<HTMLElement>(`[data-path="${path}"]`)! }
}
const type = (el: Element, v: string) => fireEvent.change(el, { target: { value: v } })
const footer = schemas.footer
const data = () => structuredClone({ companyName: 'Mock AB', links: [{ text: 'About', href: 'https://x.test/a' }, { text: 'Careers', href: 'https://x.test/b' }], usps: ['Fast', 'Friendly'], social: { github: 'https://g.test' }, badges: ['gdpr'], cta: { kind: 'link', href: '/c' } })
describe('array of objects ($ref items)', () => {
it('shows summaries, edits nested fields, and writes them back', () => {
const m = mount(footer, data())
const links = m.field('links')
expect(links.textContent).toContain('About')
expect(links.textContent).toContain('Careers')
type(within(links).getAllByRole('textbox')[0], 'About us') // items <= 2 start expanded
expect(m.value().links[0]).toMatchObject({ text: 'About us', href: 'https://x.test/a' })
})
it('adds an item with schema defaults, respecting maxItems', () => {
const m = mount(footer, data())
for (let i = 0; i < 6; i++) fireEvent.click(within(m.field('links')).getByLabelText('Add item'))
expect(m.value().links).toHaveLength(6)
expect(m.value().links[5]).toEqual({ text: '' }) // required props seeded
expect((within(m.field('links')).getByLabelText('Add item') as HTMLButtonElement).disabled).toBe(true)
})
it('reorders (buttons), duplicates and removes', () => {
const m = mount(footer, data())
const l = () => within(m.field('links'))
fireEvent.click(l().getByLabelText('Move item 1 down'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'About'])
fireEvent.click(l().getByLabelText('Duplicate item 1'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'Careers', 'About'])
fireEvent.click(l().getByLabelText('Remove item 2'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'About'])
})
it('keeps open/closed state with the item when reordering', () => {
const d = data()
d.links.push({ text: 'Third', href: 'https://x.test/c' }) // 3 items -> all start collapsed
const m = mount(footer, d)
const l = () => within(m.field('links'))
fireEvent.click(l().getByText('Third')) // expand it
expect(l().queryAllByRole('textbox')).toHaveLength(2) // text + href of the one open card
fireEvent.click(l().getByLabelText('Move item 3 up'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['About', 'Third', 'Careers'])
expect(l().getAllByRole('textbox')[0].getAttribute('value')).toBe('Third') // still the open one
})
it('an optional array emptied is removed from the data', () => {
const m = mount(footer, { companyName: 'x', links: [{ text: 'Only' }] })
fireEvent.click(within(m.field('links')).getByLabelText('Remove item 1'))
expect('links' in m.value()).toBe(false)
})
})
describe('primitive arrays', () => {
it('edit in place, add and remove rows', () => {
const m = mount(footer, data())
const u = () => within(m.field('usps'))
type(u().getAllByRole('textbox')[1], 'Very friendly')
expect(m.value().usps).toEqual(['Fast', 'Very friendly'])
fireEvent.click(u().getByLabelText('Add item'))
expect(m.value().usps).toEqual(['Fast', 'Very friendly', ''])
fireEvent.click(u().getByLabelText('Remove item 1'))
expect(m.value().usps).toEqual(['Very friendly', ''])
})
it('uniqueItems + enum renders a checkbox set', () => {
const m = mount(footer, data())
const boxes = within(m.field('badges')).getAllByRole('checkbox') as HTMLInputElement[]
expect(boxes.map((b) => b.checked)).toEqual([false, true, false])
fireEvent.click(boxes[0])
expect(m.value().badges).toEqual(['gdpr', 'iso9001'])
fireEvent.click(boxes[1])
expect(m.value().badges).toEqual(['iso9001'])
})
})
describe('object maps (additionalProperties)', () => {
it('adds, renames (keeping order), edits and removes keys; blocks duplicates', () => {
const m = mount(footer, data())
const s = () => within(m.field('social'))
type(s().getByLabelText('New key'), 'mastodon')
fireEvent.click(s().getByLabelText('Add entry'))
expect(Object.keys(m.value().social)).toEqual(['github', 'mastodon'])
const key = s().getByLabelText('Key github')
type(key, 'gh')
fireEvent.blur(key)
expect(Object.keys(m.value().social)).toEqual(['gh', 'mastodon'])
type(s().getByLabelText('New key'), 'gh')
fireEvent.click(s().getByLabelText('Add entry'))
expect(s().getByText('That key already exists')).toBeTruthy()
fireEvent.click(s().getByLabelText('Remove gh'))
expect(Object.keys(m.value().social)).toEqual(['mastodon'])
})
it('x-dict-keys is a fixed vocabulary: one row per key, blank removes', () => {
const schema = { type: 'object', properties: { m: { type: 'object', 'x-editor': 'dictionary', 'x-dict-keys': ['höjd', 'bredd'], additionalProperties: { type: 'integer' } } } }
const m = mount(schema, { m: { höjd: 5 } })
const inputs = within(m.field('m')).getAllByRole('spinbutton')
expect(inputs).toHaveLength(2)
type(inputs[1], '7')
expect(m.value().m).toEqual({ höjd: 5, bredd: 7 })
type(inputs[0], '')
expect(m.value().m).toEqual({ bredd: 7 })
})
})
describe('variants and refs', () => {
it('oneOf: picks the branch from the data and switching sets the discriminator', () => {
const m = mount(footer, data())
const sel = within(m.field('cta')).getByLabelText('Variant') as HTMLSelectElement
expect(sel.options[sel.selectedIndex].text).toBe('Link')
fireEvent.change(sel, { target: { value: '1' } })
expect(m.value().cta).toEqual({ kind: 'phone', number: '' })
type(within(m.field('cta')).getAllByRole('textbox')[0], '08-1234')
expect(m.value().cta).toEqual({ kind: 'phone', number: '08-1234' })
})
it('nullable unions unwrap to the real type', () => {
const root = { $defs: { s: { type: 'string' } } }
expect(resolveSchema(root, { anyOf: [{ $ref: '#/$defs/s' }, { type: 'null' }] })).toMatchObject({ nullable: true, schema: { type: 'string' } })
expect(resolveSchema(root, { type: ['integer', 'null'] })).toMatchObject({ nullable: true, schema: { type: 'integer' } })
})
it('resolves $ref chains and merges allOf', () => {
const root = { $defs: { a: { properties: { x: { type: 'string' } }, required: ['x'] }, b: { allOf: [{ $ref: '#/$defs/a' }, { properties: { y: { type: 'number' } } }] } } }
const { schema } = resolveSchema(root, { $ref: '#/$defs/b', title: 'B' })
expect(Object.keys(schema.properties)).toEqual(['x', 'y'])
expect(schema.required).toEqual(['x'])
expect(schema.title).toBe('B')
})
it('pickVariant scores by discriminator, type and required keys', () => {
const b = footer.properties.cta.oneOf
expect(pickVariant(footer, b, { kind: 'phone', number: '1' })).toBe(1)
expect(pickVariant(footer, b, undefined)).toBe(-1)
expect(pickVariant({}, [{ type: 'string' }, { type: 'number' }], 3)).toBe(1)
})
it('defaultFor seeds required props, consts and enums', () => {
expect(defaultFor(footer, footer.properties.links.items)).toEqual({ text: '' })
expect(defaultFor(footer, footer.properties.cta.oneOf[1])).toEqual({ kind: 'phone', number: '' })
expect(defaultFor({}, { type: 'string', enum: ['a', 'b'] })).toBe('a')
})
})
describe('field behaviour', () => {
it('hidden fields are not rendered; readOnly are shown but not editable', () => {
const m = mount(footer, { companyName: 'x', internal: 'abc', secret: 's' })
expect(m.field('secret')).toBeNull()
expect(m.field('internal').querySelector('input,textarea')).toBeNull()
expect(m.field('internal').textContent).toContain('abc')
})
it('x-group becomes a collapsible section', () => {
const m = mount(footer, data())
const groups = [...m.container.querySelectorAll('details.cms-group')] as HTMLDetailsElement[]
expect(groups.map((g) => g.querySelector('summary')!.textContent!.replace(/\s*\(\d+\)/, ''))).toEqual(['Contact', 'Social'])
expect(groups.map((g) => g.open)).toEqual([true, false]) // collapsedByDefault
})
it('clearing an optional string removes the key; a required one keeps ""', () => {
const m = mount(footer, { companyName: 'Acme', tagline: 'hi' })
type(m.field('tagline').querySelector('input')!, '')
expect('tagline' in m.value()).toBe(false)
type(m.field('companyName').querySelector('input')!, '')
expect(m.value().companyName).toBe('')
})
it('data not in the schema stays editable under "not in the schema"', () => {
const m = mount({ type: 'object', properties: { a: { type: 'string' } } }, { a: '1', legacy: 'old' })
expect(m.container.querySelector('.cms-extra')!.textContent).toContain('1 field')
type(m.container.querySelector('.cms-extra input')!, 'new')
expect(m.value()).toEqual({ a: '1', legacy: 'new' })
})
})
describe('validation', () => {
it('reports required/minLength/format inside array items with form paths', () => {
const e = validate(footer, footer, { companyName: '', links: [{ text: 'x' }, { text: 'ok' }], email: 'nope' })
expect(e).toMatchObject({ companyName: 'Required', 'links.0.text': 'At least 2 characters', email: 'Not a valid email address' })
expect(e['links.1.text']).toBeUndefined()
})
it('checks minItems/maxItems/number bounds and validates the chosen variant only', () => {
const s = { type: 'object', properties: { l: { type: 'array', minItems: 2, items: { type: 'integer', maximum: 5 } }, v: footer.properties.cta } }
expect(validate(s, s, { l: [9], v: { kind: 'phone' } })).toMatchObject({ l: 'Add at least 2 items', 'l.0': 'Must be at most 5', 'v.number': 'Required' })
})
it('shows messages inline and flags the field', () => {
const m = mount(footer, { companyName: 'ok', links: [{ text: 'x' }] })
const row = m.container.querySelector('[data-path="links.0.text"]')!
expect(row.className).toContain('has-error')
expect(row.querySelector('[role=alert]')!.textContent).toBe('At least 2 characters')
})
})
+58
View File
@@ -0,0 +1,58 @@
// Test harness: boots the real editor against the mock CMS inside jsdom.
import { act } from 'react'
import { Api } from '../src/api'
import { AuthManager } from '../src/auth'
import { boot } from '../src/boot'
import type { EditorHandle } from '../src/boot'
import { createMockCms } from '../mock/server'
import { renderPage } from '../mock/fixtures'
export interface Harness {
cms: ReturnType<typeof createMockCms>
auth: AuthManager
editor: EditorHandle
/** Wait for async work (fetches, patch application, React). */
settle(): Promise<void>
shadow: ShadowRoot
/** Click something inside the editor UI by accessible label. */
press(label: string): Promise<void>
cleanup(): void
}
/** `stale`: the DOM is the *published* page, and the store has moved on since (the base-page problem). */
export async function setup(opts: { token?: string; auth?: boolean; stale?: boolean; noPreviewApi?: boolean } = {}): Promise<Harness> {
const cms = createMockCms({ token: opts.token, auth: opts.auth })
document.head.innerHTML = ''
window.open = () => null
document.body.innerHTML = renderPage(cms.graph).match(/<body>([\s\S]*)<\/body>/)![1]
if (opts.stale) cms.graph.edit([{ op: 'set', id: 'h1', data: { text: 'Latest from the store' } }])
if (opts.noPreviewApi) cms.disablePreviewApi()
const auth = new AuthManager({ fetch: cms.fetch, persistent: null, session: null, sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms, 5))) })
const api = new Api({ fetch: cms.fetch, auth })
let editor!: EditorHandle
await act(async () => {
editor = boot({ api })
})
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 25))))
const shadow = document.getElementById('cms-onpage-editor')!.shadowRoot!
return {
cms,
auth,
editor,
settle,
shadow,
async press(label) {
const el = shadow.querySelector<HTMLElement>(`[aria-label="${label}"], [title="${label}"]`)
if (!el) throw new Error(`no control "${label}" in editor UI`)
await act(async () => el.click())
await settle()
},
cleanup() {
editor.destroy()
document.body.innerHTML = ''
},
}
}
export const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }))
export const byId = (id: string) => document.querySelector<HTMLElement>(`[data-cms="${id}"]`)!
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { collectStrings, getIn, inferBindings, markInferred, parsePath, patchForPath } from '../src/page/infer'
import { acceptsChild, allowedChildren, typeName } from '../src/schema'
import { schemas } from '../mock/fixtures'
import { dropTarget } from '../src/page/dnd'
const dom = (html: string) => {
document.body.innerHTML = `<section data-cms="b">${html}</section>`
const block = document.querySelector<HTMLElement>('[data-cms="b"]')!
return { block, owns: (el: Element) => el.closest('[data-cms]') === block }
}
describe('collectStrings', () => {
it('walks nested arrays and objects with paths, skipping lifecycle, enums, markdown and read-only', () => {
const schema = { type: 'object', properties: { title: { type: 'string' }, kind: { type: 'string', enum: ['a', 'b'] }, body: { type: 'string', 'x-editor': 'markdown' }, ro: { type: 'string', readOnly: true }, items: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' } } } } } }
const data = { title: 'Hi', kind: 'a', body: '# x', ro: 'r', status: 'published', items: [{ text: 'One' }, { text: 'Two' }], blank: ' ' }
expect(collectStrings(data, schema).map((s) => [s.path.join('.'), s.value])).toEqual([['title', 'Hi'], ['items.0.text', 'One'], ['items.1.text', 'Two']])
})
it('works without a schema (everything is a candidate)', () => {
expect(collectStrings({ a: { b: ['x'] } }).map((s) => s.path.join('.'))).toEqual(['a.b.0'])
})
})
describe('inferBindings', () => {
it('binds a unique leaf text to its property, nested paths included', () => {
const { block, owns } = dom('<h2>Hello</h2><ul><li><a href="/a">About</a></li><li><a href="/b">Careers</a></li></ul>')
const r = inferBindings(block, owns, collectStrings({ title: 'Hello', links: [{ text: 'About', href: '/a' }, { text: 'Careers', href: '/b' }] }))
expect(r.text.map((t) => t.path)).toEqual(['title', 'links.0.text', 'links.1.text'])
expect(r.attrs.map((a) => [a.path, a.attr])).toEqual([['links.0.href', 'href'], ['links.1.href', 'href']])
})
it('skips ambiguity instead of guessing: same text twice, or one value on two properties', () => {
const twice = dom('<p>Same</p><p>Same</p>')
expect(inferBindings(twice.block, twice.owns, collectStrings({ a: 'Same' })).text).toEqual([])
const shared = dom('<p>Both</p>')
expect(inferBindings(shared.block, shared.owns, collectStrings({ a: 'Both', b: 'Both' })).text).toEqual([])
})
it('only binds text leaves; whitespace differences do not matter; nested blocks bind their own data', () => {
const { block, owns } = dom('<div><span>Mixed</span> text</div><p> Spaced \n out </p><section data-cms="child"><p>Child text</p></section>')
const r = inferBindings(block, owns, collectStrings({ x: 'Mixed text', y: 'Spaced out', z: 'Child text' }))
expect(r.text.map((t) => t.path)).toEqual(['y']) // x is not a leaf; z belongs to the nested block
})
it('leaves elements with an explicit data-cms-field alone', () => {
const { block, owns } = dom('<h1 data-cms-field="heading" data-cms-editor="text">Title</h1>')
expect(inferBindings(block, owns, collectStrings({ heading: 'Title' })).text).toEqual([])
})
it('is idempotent: re-inferring a block that already carries inferred markers keeps the bindings', () => {
const { block, owns } = dom('<h2>Hello</h2>')
const strings = collectStrings({ a: 'Hello' })
markInferred(block, inferBindings(block, owns, strings).text)
const again = inferBindings(block, owns, strings)
expect(again.text.map((t) => t.path)).toEqual(['a'])
})
it('markInferred tags leaves for the normal inline editor, and re-marking cleans up first', () => {
const { block, owns } = dom('<h2>Hello</h2><p>World</p>')
markInferred(block, inferBindings(block, owns, collectStrings({ a: 'Hello', b: 'World' })).text)
expect(block.querySelector('h2')!.getAttribute('data-cms-field')).toBe('a')
expect(block.querySelector('h2')!.getAttribute('data-cms-editor')).toBe('text')
markInferred(block, inferBindings(block, owns, collectStrings({ b: 'World' })).text)
expect(block.querySelector('h2')!.hasAttribute('data-cms-field')).toBe(false)
expect(block.querySelector('p')!.getAttribute('data-cms-field')).toBe('b')
})
})
describe('paths', () => {
it('parse / get / patchForPath (top-level key rebuilt, input untouched)', () => {
expect(parsePath('links.0.text')).toEqual(['links', 0, 'text'])
const data = { title: 't', links: [{ text: 'A', href: '/a' }, { text: 'B' }] }
expect(getIn(data, parsePath('links.1.text'))).toBe('B')
expect(patchForPath(data, ['title'], 'x')).toEqual({ title: 'x' })
expect(patchForPath(data, parsePath('links.0.text'), 'Z')).toEqual({ links: [{ text: 'Z', href: '/a' }, { text: 'B' }] })
expect(data.links[0].text).toBe('A')
expect(patchForPath({}, parsePath('a.b.c'), 'v')).toEqual({ a: { b: { c: 'v' } } })
expect(patchForPath({}, parsePath('a.0'), 'v')).toEqual({ a: ['v'] })
})
})
describe('what a container accepts', () => {
it('x-graph.children: true means anything (the shape the real schemas use); lists and * work too', () => {
expect(allowedChildren({ 'x-graph': { children: true } }, ['a', 'b'])).toEqual(['a', 'b'])
expect(allowedChildren({ 'x-graph': { children: ['a'] } }, ['a', 'b'])).toEqual(['a'])
expect(allowedChildren({ 'x-graph': { children: ['*'] } }, ['a', 'b'])).toEqual(['a', 'b'])
expect(allowedChildren({ 'x-graph': {} }, ['a'])).toEqual([])
expect(allowedChildren(undefined, ['a'])).toEqual([])
})
it('acceptsChild is optimistic for unknown schemas, strict for known ones', () => {
expect(acceptsChild(undefined, 'x', [])).toBe(true)
expect(acceptsChild(schemas.section, 'heading', [])).toBe(true)
expect(acceptsChild(schemas.section, 'page', [])).toBe(false)
expect(acceptsChild(schemas.heading, 'text', [])).toBe(false) // a leaf type
})
it('typeName tolerates the shapes /api/types might use', () => {
expect([typeName('a'), typeName({ type: 'b' }), typeName({ name: 'c' }), typeName({ dataType: 'd' }), typeName(null)]).toEqual(['a', 'b', 'c', 'd', ''])
})
it('dropTarget refuses containers that do not accept, falling back to an ancestor that does', () => {
const r = (y: number, h = 100) => ({ x: 0, y, w: 600, h })
const blocks = [
{ id: 'outer', container: true, depth: 0, rect: r(0, 400) },
{ id: 'inner', parentId: 'outer', container: true, depth: 1, rect: r(50, 200) },
]
expect(dropTarget(blocks, { x: 10, y: 100 }, null)!.parentId).toBe('inner')
expect(dropTarget(blocks, { x: 10, y: 100 }, null, (p) => p !== 'inner')!.parentId).toBe('outer')
expect(dropTarget(blocks, { x: 10, y: 100 }, null, () => false)!.parentId).toBe('') // page level
expect(dropTarget(blocks, { x: 900, y: 100 }, null, () => false)).toBeNull() // beside the page: over an editor panel
})
})
import { moveTarget, rootExtent, sortTarget } from '../src/page/dnd'
describe('sort snapping between siblings', () => {
const r = (y: number, h: number, x = 0, w = 600) => ({ x, y, w, h })
// four root blocks (A, B, C are containers - the case that used to nest instead of sort), with children
const blocks = [
{ id: 'A', container: true, depth: 0, rect: r(0, 300) },
{ id: 'a1', parentId: 'A', container: false, depth: 1, rect: r(10, 100, 10, 580) },
{ id: 'B', container: true, depth: 0, rect: r(320, 100) },
{ id: 'C', container: true, depth: 0, rect: r(440, 100) },
{ id: 'D', container: false, depth: 0, rect: r(560, 50) },
]
it('snaps to the nearest boundary between root nodes, centred in the gap', () => {
const t = sortTarget(blocks, { x: 50, y: 500 }, 'B', '')! // pointer is deep inside container C
// siblings without B: A(0-300) C(440-540) D(560-610) -> boundaries 0, 370, 550, 610
expect(t.snaps!.map((s) => s.y + 1.5)).toEqual([0, 370, 550, 610])
expect(t).toMatchObject({ parentId: '', index: 2 }) // nearest is 550 (between C and D)
expect(t.line.y + 1.5).toBe(550)
expect(t.line.w).toBe(600)
})
it('snaps by distance to boundaries, not by sibling midpoints (stable near the edges)', () => {
expect(sortTarget(blocks, { x: 50, y: -40 }, 'B', '')!.index).toBe(0)
expect(sortTarget(blocks, { x: 50, y: 5000 }, 'B', '')!.index).toBe(3)
expect(sortTarget(blocks, { x: 50, y: 372 }, 'B', '')!.index).toBe(1)
})
it('excludes the dragged block, and a row of siblings sorts left to right', () => {
const row = [
{ id: 'p', container: true, depth: 0, rect: r(0, 100, 0, 600) },
{ id: 'c1', parentId: 'p', container: false, depth: 1, rect: r(0, 100, 0, 150) },
{ id: 'c2', parentId: 'p', container: false, depth: 1, rect: r(0, 100, 200, 150) },
{ id: 'c3', parentId: 'p', container: false, depth: 1, rect: r(0, 100, 400, 150) },
]
const t = sortTarget(row, { x: 380, y: 50 }, 'c1', 'p')! // siblings: c2, c3
expect(t.snaps).toHaveLength(3)
expect(t.line.h).toBe(100) // a vertical line
expect(t.line.w).toBe(3)
expect(t).toMatchObject({ parentId: 'p', index: 1 }) // between c2 and c3
expect(t.line.x + 1.5).toBe(375) // centred in the 50px gap (350..400)
})
it('moveTarget: dragging a root block sorts by default and nests only with Alt', () => {
const sort = moveTarget(blocks, { x: 50, y: 500 }, { id: 'B' })!
expect(sort).toMatchObject({ parentId: '', index: 2 })
expect(sort.snaps).toBeDefined()
const nest = moveTarget(blocks, { x: 50, y: 500 }, { id: 'B' }, { nest: true })!
expect(nest.parentId).toBe('C') // the container under the pointer
expect(nest.snaps).toBeUndefined()
})
it('moveTarget: a nested block sorts inside its parent, and leaving the parent switches to general drops', () => {
const withSibs = [...blocks, { id: 'a2', parentId: 'A', container: false, depth: 1, rect: r(120, 100, 10, 580) }]
expect(moveTarget(withSibs, { x: 50, y: 250 }, { id: 'a1', parentId: 'A' })).toMatchObject({ parentId: 'A', index: 1 })
expect(moveTarget(withSibs, { x: 50, y: 380 }, { id: 'a1', parentId: 'A' })!.parentId).toBe('B') // left A: dropped into B
})
it('beside the page (over editor chrome) a root drag has no target', () => {
expect(rootExtent(blocks)).toMatchObject({ x: -24, w: 648 })
expect(moveTarget(blocks, { x: 900, y: 100 }, { id: 'B' })).toBeNull()
})
})
+340
View File
@@ -0,0 +1,340 @@
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'])
})
})
+85
View File
@@ -0,0 +1,85 @@
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')
})
})
+10
View File
@@ -0,0 +1,10 @@
// jsdom has no layout: give the few APIs the editor touches harmless defaults.
if (!('scrollIntoView' in Element.prototype)) (Element.prototype as any).scrollIntoView = () => {}
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
// Store updates arrive from fetch/timers outside act(); the harness settles them explicitly.
const error = console.error
console.error = (...a: unknown[]) => {
if (typeof a[0] === 'string' && a[0].includes('not wrapped in act')) return
error(...a)
}
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { fireEvent } from '@testing-library/react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const data = (id: string) => h.cms.graph.node(id).data
const editOps = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').map((l) => (l.request as any).ops)
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const chip = (field: string, label: string) => [...h.shadow.querySelectorAll<HTMLElement>(`[data-style-field="${field}"] .cms-chip`)].find((c) => c.textContent!.includes(label))!
describe('Style panel (schema-driven)', () => {
it('loads schemas in the background and matches editors on the schema, not the node type', async () => {
h = await setup()
await h.settle()
expect(h.editor.ctrl.schemaOf('layout')).toBeDefined()
expect(h.editor.ctrl.wrapperType()).toBe('layout')
await select('l1')
expect(h.shadow.querySelector('[aria-label="Style"]')).not.toBeNull()
await select('t1') // a text block has no style fields
expect(h.shadow.querySelector('[aria-label="Style"]')).toBeNull()
})
it('chips write one token through set; reset clears it; the page re-renders with the new class', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
expect(q('[data-style-field="gap"] .cms-chip.is-on').textContent).toBe('md')
await act(async () => void chip('gap', 'lg').click())
await h.settle()
expect(data('l1').gap).toBe('lg')
expect(byId('l1').className).toContain('k6n-gap-lg')
expect(editOps().at(-1)[0]).toMatchObject({ op: 'set', id: 'l1', data: { gap: 'lg' } })
await act(async () => void q('[aria-label="Reset Gap"]').click())
await h.settle()
expect('gap' in data('l1')).toBe(false)
})
it('flow chips hide legacy aliases; columns shows only for grid-like flows', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
const labels = [...h.shadow.querySelectorAll('[data-style-field="flow"] .cms-chip')].map((c) => c.textContent)
expect(labels).toEqual(['Row', 'Stack', 'Actions'])
expect(h.shadow.querySelector('[data-style-field="columns"]')).not.toBeNull()
await act(async () => void chip('flow', 'Stack').click())
await h.settle()
expect(h.shadow.querySelector('[data-style-field="columns"]')).toBeNull()
})
it('box model: per-side selects, overrides show the inherited value, shared fields appear once', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
const sel = (label: string) => h.shadow.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`)
// l1: padding sm, paddingTop lg (own), margin sm
expect(sel('padding top')!.value).toBe('lg')
expect(sel('padding bottom')!.value).toBe('') // inherits
expect(sel('padding bottom')!.options[0].text).toBe('sm (padding)')
expect(sel('padding all sides')!.value).toBe('sm') // one select for left+right, other edge shows a link
expect(h.shadow.querySelectorAll('.cms-box__link').length).toBeGreaterThan(0)
await act(async () => void fireEvent.change(sel('padding bottom')!, { target: { value: 'xl' } }))
await h.settle()
expect(data('l1').paddingBottom).toBe('xl')
await act(async () => void fireEvent.change(sel('padding top')!, { target: { value: '' } }))
await h.settle()
expect('paddingTop' in data('l1')).toBe(false)
expect(sel('padding top')!.options[0].text).toBe('sm (padding)')
})
it('breakpoint tabs edit responsive.<bp>.* and leave the base alone', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click())
expect(h.shadow.querySelector('[data-style-field="gap"]')).toBeNull() // spacing has no per-breakpoint form
await act(async () => void chip('flow', 'Stack').click())
await h.settle()
expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } })
expect(data('l1').flow).toBe('row')
expect(byId('l1').className).toContain('k6n-layout--t-stack')
await act(async () => void q('[aria-label="Reset Flow"]').click())
await h.settle()
expect('responsive' in data('l1')).toBe(false)
})
it('rapid clicks on one field coalesce into a single undo step', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
for (const t of ['sm', 'lg', 'xl']) await act(async () => void chip('gap', t).click())
await h.settle()
expect(data('l1').gap).toBe('xl')
await h.press('Undo (Ctrl+Z)')
expect(data('l1').gap).toBe('md') // back to before the first click
})
})
describe('wrap in layout (give any block spacing)', () => {
it('wraps in one atomic batch, opens the Style panel, and undoes in one step', async () => {
h = await setup()
await h.settle()
await select('t2')
const edits = editOps().length
await h.press('Wrap in layout')
await h.settle()
const wrapper = byId('t2').closest('[data-cms-type="layout"]')!
expect(wrapper).not.toBeNull()
expect(wrapper.parentElement!.closest('[data-cms]')!.getAttribute('data-cms')).toBe('s2') // took t2's place
expect(editOps().length).toBe(edits + 1)
expect(editOps().at(-1).map((o: any) => o.op)).toEqual(['insert', 'move'])
expect(h.editor.ctrl.store.get().selected).toBe(wrapper.getAttribute('data-cms'))
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('Style')
await h.press('Undo (Ctrl+Z)')
expect(byId('t2').closest('[data-cms-type="layout"]')).toBeNull()
expect(byId('t2').parentElement!.getAttribute('data-cms')).toBe('s2')
})
it('is not offered for blocks that already have style fields', async () => {
h = await setup()
await h.settle()
await select('l1')
expect(h.shadow.querySelector('[aria-label="Wrap in layout"]')).toBeNull()
})
})
describe('on-page spacing handles', () => {
const grips = () => [...h.shadow.querySelectorAll<HTMLElement>('.cms-grip-h')]
const ptr = (el: Element, type: string, x: number, y: number) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y })))
it('draws grips only for what the selected block can express', async () => {
h = await setup()
await h.settle()
await select('l1')
const labels = grips().map((g) => g.getAttribute('aria-label')!.split(':')[0])
expect(labels).toEqual(expect.arrayContaining(['padding top', 'padding bottom', 'padding (all sides)', 'margin top', 'margin bottom', 'margin (all sides)']))
await select('t1')
expect(grips()).toHaveLength(0)
})
it('dragging snaps to a token, previews live, and commits a single coalesced set', async () => {
h = await setup()
await h.settle()
// jsdom has no layout/CSS vars: give the page a measured scale and a computed padding-top of 32px.
const px: Record<string, number> = { none: 0, xs: 8, sm: 16, md: 32, lg: 64, xl: 96 }
h.editor.ctrl.measure = (v) => px[v.replace('--k6n-sp-', '')] ?? null
byId('l1').style.paddingTop = '32px'
await select('l1')
const top = () => grips().find((g) => g.getAttribute('aria-label')!.startsWith('padding top'))!
expect(top().getAttribute('aria-label')).toBe('padding top: 32px')
await ptr(top(), 'pointerdown', 50, 100)
await ptr(top(), 'pointermove', 50, 140) // +40px -> 72 -> nearest lg (64)
expect(byId('l1').style.getPropertyValue('padding-top')).toBe('64px') // live preview
expect(h.shadow.querySelector('.cms-tip')!.textContent).toContain('lg')
await ptr(top(), 'pointerup', 50, 140)
await h.settle()
expect(data('l1').paddingTop).toBe('lg') // paddingTop, not the shared padding
expect(data('l1').padding).toBe('sm')
expect(byId('l1').className).toContain('k6n-pt-lg')
expect(byId('l1').style.getPropertyValue('padding-top')).toBe('') // preview removed
})
it('a click without movement changes nothing; Escape/cancel removes the preview', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.measure = () => 10
await select('l1')
const before = editOps().length
const g = grips()[0]
await ptr(g, 'pointerdown', 10, 10)
await ptr(g, 'pointerup', 10, 10)
await h.settle()
expect(editOps().length).toBe(before)
})
})
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { schemas } from '../mock/fixtures'
import { describeStyle, effective, fieldForSide, isWrapperSchema, patchFor } from '../src/style/model'
import { discoverSpacingVar, measureScale, snap } from '../src/style/tokens'
import type { Scale } from '../src/style/tokens'
import { computeHandles, dragPx, gapsBetween, previewProperty } from '../src/style/handles'
const layout = schemas.layout
const fields = describeStyle(layout)
const byName = (n: string) => fields.find((f) => f.name === n)!
/** The same schema with every x-style removed: what an un-annotated deployment serves. */
const stripped = () => {
const s = structuredClone(layout)
for (const p of Object.values<any>(s.properties)) delete p['x-style']
for (const p of Object.values<any>(s.$defs.breakpoint.properties)) delete p['x-style']
delete s['x-style']
return s
}
describe('describeStyle', () => {
it('reads x-style annotations: kinds, spacing box/side/overrides, token, breakpoints', () => {
expect(fields.map((f) => f.name)).toEqual(['flow', 'columns', 'gap', 'padding', 'paddingTop', 'paddingBottom', 'margin', 'marginTop', 'marginBottom', 'align', 'justify', 'tone', 'border'])
expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false })
expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false })
expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop'])
expect(byName('gap').responsive).toEqual([]) // spacing is not a breakpoint override (the template has no classes for it)
expect(isWrapperSchema(layout)).toBe(true)
})
it('infers the same fields from names + enums when the schema is not annotated (and says so)', () => {
const inferred = describeStyle(stripped())
expect(inferred.map((f) => [f.name, f.kind, f.box, f.side])).toEqual(fields.map((f) => [f.name, f.kind, f.box, f.side]))
expect(inferred.every((f) => f.inferred)).toBe(true)
expect(inferred.find((f) => f.name === 'marginBottom')).toMatchObject({ overrides: 'margin' })
expect(isWrapperSchema(stripped())).toBe(false) // the wrapper tag is never guessed
})
it('does not mistake ordinary fields for style (enum without a scale, unrelated names)', () => {
const s = { type: 'object', properties: { padding: { type: 'string' }, gap: { type: 'string', enum: ['a', 'b'] }, title: { type: 'string' }, variant: { enum: ['x', 'y'] } } }
expect(describeStyle(s)).toEqual([])
})
it('an annotation wins over naming (any property can be tagged)', () => {
const s = { type: 'object', properties: { inset: { type: 'string', enum: ['none', 'xs', 'sm', 'md'], 'x-style': { kind: 'spacing', box: 'padding', side: 'x' } } } }
expect(describeStyle(s)).toMatchObject([{ name: 'inset', kind: 'spacing', box: 'padding', side: 'x', inferred: false }])
})
const real = '/home/mats/git.k6n.net/ecommerce/data/k6n/schemas/layout.json'
it.skipIf(!existsSync(real))('the real k6n layout schema is annotated and consistent with the mock', () => {
const s = JSON.parse(readFileSync(real, 'utf8'))
const f = describeStyle(s)
expect(f.map((x) => x.name)).toEqual(expect.arrayContaining(fields.map((x) => x.name)))
expect(f.map((x) => x.name)).toContain('reveal')
expect(f.filter((x) => x.inferred)).toEqual([]) // fully annotated
expect(isWrapperSchema(s)).toBe(true)
expect(f.find((x) => x.name === 'paddingTop')).toMatchObject({ overrides: 'padding', token: '--k6n-sp-{value}' })
})
})
describe('values', () => {
it('effective() falls back from an override to its base field', () => {
const d = { padding: 'sm' }
expect(effective(d, fields, byName('paddingTop'))).toMatchObject({ value: 'sm', from: { name: 'padding' } })
expect(effective({ padding: 'sm', paddingTop: 'xl' }, fields, byName('paddingTop'))).toMatchObject({ value: 'xl', from: { name: 'paddingTop' } })
expect(effective({}, fields, byName('paddingTop')).value).toBeUndefined()
})
it('patchFor: base is flat (undefined clears), breakpoints rebuild responsive and prune empties', () => {
expect(patchFor({}, byName('gap'), 'lg')).toEqual({ gap: 'lg' })
expect(patchFor({}, byName('gap'), undefined)).toEqual({ gap: null })
const d = { responsive: { tablet: { columns: 2 }, mobile: { flow: 'stack' } } }
expect(patchFor(d, byName('columns'), 3, 'tablet')).toEqual({ responsive: { tablet: { columns: 3 }, mobile: { flow: 'stack' } } })
expect(patchFor(d, byName('columns'), undefined, 'tablet')).toEqual({ responsive: { mobile: { flow: 'stack' } } })
expect(patchFor({ responsive: { tablet: { columns: 2 } } }, byName('columns'), undefined, 'tablet')).toEqual({ responsive: null })
expect(d.responsive.tablet.columns).toBe(2) // input untouched
})
it('fieldForSide: exact side, then axis, then all', () => {
expect(fieldForSide(fields, 'padding', 'top')!.name).toBe('paddingTop')
expect(fieldForSide(fields, 'padding', 'left')!.name).toBe('padding')
expect(fieldForSide(fields, 'margin', 'bottom')!.name).toBe('marginBottom')
expect(fieldForSide(describeStyle({ type: 'object', properties: { gap: layout.properties.gap } }), 'padding', 'top')).toBeUndefined()
})
})
describe('token scales', () => {
const scale: Scale = { values: ['none', 'xs', 'sm', 'md', 'lg', 'xl'], px: [0, 8, 16, 32, 64, 96] }
it('snaps to the nearest measured size', () => {
expect(snap(scale, 'md', 32, 0)).toBe('md')
expect(snap(scale, 'md', 32, 12)).toBe('md') // 44 -> md(32) vs lg(64): md
expect(snap(scale, 'md', 32, 20)).toBe('lg') // 52 -> lg
expect(snap(scale, 'md', 32, -100)).toBe('none') // clamps at 0
expect(snap(scale, undefined, 0, 500)).toBe('xl')
})
it('steps ordinally when sizes cannot be measured', () => {
const blind: Scale = { values: scale.values, px: scale.values.map(() => null) }
expect(snap(blind, 'sm', 0, 0)).toBe('sm')
expect(snap(blind, 'sm', 0, 60)).toBe('lg') // 2 steps of 28px
expect(snap(blind, 'sm', 0, -500)).toBe('none')
})
it('measureScale resolves each value through the field token template', () => {
const asked: string[] = []
const sc = measureScale(byName('gap'), (v) => (asked.push(v), asked.length * 10))
expect(asked[0]).toBe('--k6n-sp-none')
expect(asked).toContain('--k6n-sp-md')
expect(sc.px).toEqual([10, 20, 30, 40, 50, 60])
})
it('discovers the spacing variable prefix from the page stylesheets', () => {
const style = { length: 2, 0: '--acme-sp-sm', 1: '--acme-sp-md' }
const doc = { styleSheets: [{ cssRules: [{ style }] }] } as unknown as Document
expect(discoverSpacingVar(doc)).toBe('--acme-sp-{value}')
expect(discoverSpacingVar({ styleSheets: [] } as unknown as Document)).toBeUndefined()
})
})
describe('handles', () => {
const rect = { x: 100, y: 200, w: 400, h: 300 }
const box = (t: number, r: number, b: number, l: number) => ({ top: t, right: r, bottom: b, left: l })
const base = { rect, padding: box(40, 20, 10, 20), margin: box(16, 0, 16, 0), children: [], fields }
it('creates a handle per side the schema can express, with the right field, axis and sign', () => {
const hs = computeHandles(base)
const get = (id: string) => hs.find((h) => h.id === id)!
expect(hs.filter((h) => h.kind === 'padding')).toHaveLength(4)
expect(get('padding-top')).toMatchObject({ axis: 'y', sign: 1, px: 40, shared: false, field: { name: 'paddingTop' } })
expect(get('padding-bottom')).toMatchObject({ axis: 'y', sign: -1, field: { name: 'paddingBottom' } })
expect(get('padding-left')).toMatchObject({ axis: 'x', sign: 1, shared: true, field: { name: 'padding' } }) // only "all" covers left/right
expect(get('padding-right')).toMatchObject({ axis: 'x', sign: -1, shared: true })
expect(get('margin-top')).toMatchObject({ axis: 'y', sign: -1 }) // margin grows outwards
expect(get('margin-bottom')).toMatchObject({ axis: 'y', sign: 1 })
})
it('places bands inside the box for padding and outside for margin, grips on the free edge', () => {
const hs = computeHandles(base)
const pt = hs.find((h) => h.id === 'padding-top')!
expect(pt.band).toEqual({ x: 100, y: 200, w: 400, h: 40 })
expect(pt.grip.y + pt.grip.h / 2).toBe(240) // centred on where the 40px ends
const mt = hs.find((h) => h.id === 'margin-top')!
expect(mt.band).toEqual({ x: 100, y: 184, w: 400, h: 16 })
expect(mt.grip.y + mt.grip.h / 2).toBe(184)
})
it('a zero-size value still gets a grabbable grip', () => {
const h = computeHandles({ ...base, padding: box(0, 0, 0, 0) }).find((x) => x.id === 'padding-top')!
expect(h.band.h).toBe(0)
expect(h.grip.h).toBeGreaterThanOrEqual(14)
})
it('no spacing fields -> no handles', () => {
expect(computeHandles({ ...base, fields: fields.filter((f) => f.kind === 'flow' || f.kind === 'columns') })).toEqual([])
})
it('gap bands come from the space between neighbouring children (rows and columns)', () => {
const c = (x: number, y: number) => ({ x, y, w: 100, h: 50 })
// 2x2 grid with a 20px column gap and a 10px row gap
const g = gapsBetween([c(0, 0), c(120, 0), c(0, 60), c(120, 60)])
expect(g.filter((x) => x.axis === 'x').map((x) => x.rect.w)).toEqual([20, 20])
expect(g.filter((x) => x.axis === 'y').map((x) => x.rect.h)).toEqual([10, 10])
expect(gapsBetween([c(0, 0)])).toEqual([])
const hs = computeHandles({ ...base, children: [c(0, 0), c(120, 0)] })
expect(hs.filter((h) => h.kind === 'gap')).toMatchObject([{ axis: 'x', px: 20, field: { name: 'gap' } }])
})
it('dragPx follows the sign and never goes negative', () => {
expect(dragPx({ axis: 'y', sign: 1, px: 40 }, 0, 25)).toBe(65)
expect(dragPx({ axis: 'y', sign: -1, px: 40 }, 0, 25)).toBe(15)
expect(dragPx({ axis: 'x', sign: 1, px: 10 }, -50, 99)).toBe(0)
})
it('previewProperty maps a field to the CSS property to preview', () => {
expect(previewProperty(byName('paddingTop'), 'top')).toBe('padding-top')
expect(previewProperty(byName('margin'), 'left')).toBe('margin')
expect(previewProperty(byName('gap'), 'gap')).toBe('gap')
expect(previewProperty({ ...byName('padding'), side: 'x' }, 'left')).toBe('padding-inline')
})
})
+190
View File
@@ -0,0 +1,190 @@
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')
})
})