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:
1 parent
a4ed99f13e
commit
33bd5269fd
16 files changed
+852
-110
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user