The outline and blocks are no longer tabs: one panel shows the outline tree, the new-block palette and a search over existing nodes. Outline rows drag to reorder (same snap engine, explicit gap targets), palette blocks and searched nodes drop onto the outline gaps as well as the page. Named slots (x-graph.connections, e.g. a pdp's info-section / 3d-accessories / model) render as groups under their block with allowed types, live counts and a slot-limited insert picker. Drops that fit no plain-children rule resolve to the first accepting slot; existing nodes link via graft tree.link. Adds Api.searchNodes, schema connections helpers, handle-aware controller ops (linkExisting, insertIntoSlot, dropExisting) and mock support for /api/nodes listing and graft links. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { act } from 'react'
|
|
import { byId, click, setup } from './harness'
|
|
import type { Harness } from './harness'
|
|
import { 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('Panel')
|
|
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('Panel')
|
|
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('Panel')
|
|
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'])
|
|
})
|
|
})
|