Files
mats 33bd5269fd Single panel: outline tree with slots, node search with type filter
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>
2026-11-06 20:38:31 +01:00

155 lines
7.0 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
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)
const setInput = async (el: HTMLInputElement, v: string) => {
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(el, v)
el.dispatchEvent(new Event('input', { bubbles: true }))
})
await h.settle()
}
describe('node search (existing nodes, type filter)', () => {
it('lists nodes and filters by type and text', async () => {
h = await setup()
await h.settle()
const all = await h.editor.ctrl.api.searchNodes({})
expect(all.total).toBeGreaterThan(5)
expect(all.items.map((n) => n.id)).toContain('orphan1')
const texts = await h.editor.ctrl.api.searchNodes({ type: 'text' })
expect(texts.items.length).toBeGreaterThan(0)
expect(texts.items.every((n) => n.dataType === 'text')).toBe(true)
const q1 = await h.editor.ctrl.api.searchNodes({ q: 'sparrow' })
expect(q1.items.map((n) => n.id).sort()).toEqual(['orphan1', 'orphan2'])
const both = await h.editor.ctrl.api.searchNodes({ type: 'button', q: 'sparrow' })
expect(both.items.map((n) => n.id)).toEqual(['orphan2'])
})
it('the panel shows a Nodes section with a type filter; linking adds a shared block', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
await h.settle()
expect(q('[aria-label="Search nodes"]')).not.toBeNull()
expect(q('[aria-label="Filter by type"]')).not.toBeNull()
const rows = () => qa('.cms-noderow')
expect(rows().length).toBeGreaterThan(0)
await setInput(q('[aria-label="Search nodes"]') as HTMLInputElement, 'sparrow')
await new Promise((r) => setTimeout(r, 400)) // search debounce (bare: act() holds component timers)
await h.settle()
expect(qa('.cms-noderow').map((r) => r.getAttribute('title'))).toEqual([expect.stringContaining('orphan1'), expect.stringContaining('orphan2')])
// filter to buttons: only orphan2 stays
const sel = q('[aria-label="Filter by type"]') as HTMLSelectElement
await act(async () => {
sel.value = 'button'
sel.dispatchEvent(new Event('change', { bubbles: true }))
})
await new Promise((r) => setTimeout(r, 400)) // debounce again (bare: act() holds component timers)
await h.settle()
expect(qa('.cms-noderow').map((r) => r.getAttribute('title'))).toEqual([expect.stringContaining('orphan2')])
// link it after the selected block: select t2 (child of s2), link lands in s2
await act(async () => void byId('t2').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true })))
await h.settle()
const before = kids('s2')
await act(async () => void qa('.cms-noderow button')[0].click())
await h.settle()
await h.settle()
expect(kids('s2')).toHaveLength(before.length + 1)
expect(h.cms.graph.node('orphan2')).toBeDefined() // shared: the node itself survives
})
})
describe('slots / dropzones (x-graph.connections)', () => {
it('section exposes its sidebar slot; plain children still win when they accept the type', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.fillHandles()
const slots = h.editor.ctrl.slotsFor('s1')
expect(slots.map((s) => s.name)).toEqual(['sidebar'])
expect(slots[0].def.to).toEqual(['text', 'button', 'footer'])
expect(h.editor.ctrl.acceptsInSlot('s1', 'sidebar', 'footer')).toBe(true)
expect(h.editor.ctrl.acceptsInSlot('s1', 'sidebar', 'section')).toBe(false)
expect(h.editor.ctrl.acceptsInSlot('s1', 'nope', 'text')).toBe(false)
// footer fits no plain-children rule, so drops resolve to the slot
expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar')
// heading fits plain children: no slot needed
expect(h.editor.ctrl.handleForDrop('s1', 'heading')).toBeUndefined()
})
it('inserting into a slot lands the block there and the outline shows it grouped', async () => {
h = await setup()
await h.settle()
const res = await h.editor.ctrl.insertIntoSlot('s1', 'sidebar', 'footer', 0)
expect(res).not.toBeNull()
await h.settle()
await h.editor.ctrl.fillHandles()
await h.settle()
expect(h.editor.ctrl.handleOf(res!.results[0].node!)).toBe('sidebar')
expect(h.editor.ctrl.slotChildren('s1', 'sidebar').map((b) => b.id)).toEqual([res!.results[0].node!])
await h.press('Panel')
await h.settle()
const heads = qa('.cms-oslot__head').map((x) => x.textContent)
expect(heads.some((t) => t!.includes('sidebar'))).toBe(true)
})
it('linking an existing node into a slot shares it (no copy)', async () => {
h = await setup()
await h.settle()
const res = await h.editor.ctrl.linkExisting('s1', 'orphan1', { handle: 'sidebar' })
expect(res).not.toBeNull()
expect(kids('s1')).toContain('orphan1')
await h.editor.ctrl.fillHandles()
expect(h.editor.ctrl.handleOf('orphan1')).toBe('sidebar')
// still reachable as its own node (shared, not copied)
expect(h.cms.graph.node('orphan1').data.body).toContain('sparrows')
})
it('outline drops commit through the same engine: reorder, insert, link', async () => {
h = await setup()
await h.settle()
const line = { x: 0, y: 0, w: 0, h: 0 }
// reorder b1 before h1 within s1
await h.editor.ctrl.dropBlock('b1', { parentId: 's1', index: 0, line })
expect(kids('s1')).toEqual(['b1', 'h1', 't1'])
// insert a new heading at index 1
await h.editor.ctrl.dropNew({ parentId: 's1', index: 1, line }, { type: 'heading' })
expect(kids('s1')).toHaveLength(4)
expect(h.cms.graph.node(kids('s1')[1]).dataType).toBe('heading')
// link the orphan into the sidebar slot
await h.editor.ctrl.dropExisting({ parentId: 's1', handle: 'sidebar', index: 0, line }, 'orphan2')
await h.editor.ctrl.fillHandles()
expect(h.editor.ctrl.handleOf('orphan2')).toBe('sidebar')
})
it('the slot add button opens an insert picker limited to the slot types', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
await h.settle()
const add = qa('[aria-label="Add to sidebar"]')
expect(add.length).toBeGreaterThan(0)
await act(async () => void add[0].click())
await h.settle()
await h.settle()
const tiles = qa('.cms-dialog .cms-tile').map((x) => x.getAttribute('data-type'))
expect(tiles.sort()).toEqual(['button', 'footer', 'text'])
await act(async () => void q('.cms-dialog .cms-tile[data-type="footer"]').click())
await h.settle()
await h.editor.ctrl.fillHandles()
const footers = h.editor.ctrl.slotChildren('s1', 'sidebar')
expect(footers).toHaveLength(1)
expect(h.cms.graph.node(footers[0].id).dataType).toBe('footer')
})
})