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>
151 lines
5.6 KiB
TypeScript
151 lines
5.6 KiB
TypeScript
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()
|
|
})
|
|
})
|