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>
This commit is contained in:
mats committed 2026-11-06 20:38:31 +01:00
1 parent a4ed99f13e
commit 33bd5269fd
16 files changed
+852 -110

No files matched your search

+3 -3
View File
@@ -117,7 +117,7 @@ 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.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']))
@@ -133,7 +133,7 @@ describe('block palette and drag & drop', () => {
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 h.press('Panel')
await select('s1') // container
await act(async () => void q('.cms-block[data-type="button"]').click())
await h.settle()
@@ -147,7 +147,7 @@ describe('block palette and drag & drop', () => {
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')
await h.press('Panel')
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
+154
View File
@@ -0,0 +1,154 @@
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')
})
})
+6 -6
View File
@@ -43,8 +43,8 @@ describe('node titles (x-graph.label)', () => {
h = await setup()
await h.settle()
await h.settle()
await h.press('Outline')
const items = qa('.cms-outline li button')
await h.press('Panel')
const items = qa('.cms-orow__main')
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')
@@ -105,7 +105,7 @@ 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.press('Panel')
await h.settle()
await h.settle()
const secs = qa('.cms-group-sec')
@@ -134,7 +134,7 @@ 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.press('Panel')
await h.settle()
await h.settle()
// /api/types carries each type's schema: types that are not on the page are never fetched individually
@@ -147,7 +147,7 @@ describe('the block list shows titles and groups straight from /api/types', () =
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.press('Panel')
await h.settle()
const heading = q('.cms-block[data-type="heading"]')
expect(heading.querySelector('span')!.textContent).toBe('Heading')
@@ -162,7 +162,7 @@ describe('the block list shows titles and groups straight from /api/types', () =
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.press('Panel')
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')