Files
cms-onpageeditor/test/form.test.tsx
T
matsandClaude Sonnet 5.5 1af9ec3172 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>
2026-10-31 22:34:48 +01:00

214 lines
11 KiB
TypeScript

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')
})
})