Add on-page editor for the go-graph-cms
Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -0,0 +1,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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}"]`)!
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user