Files
cms-onpageeditor/test/titles.test.tsx
T
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

191 lines
8.4 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 { OTHER_GROUP, groupOf, labelKey, sortGroups } from '../src/schema'
import { schemas } from '../mock/fixtures'
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 label = (id: string) => h.editor.ctrl.labelFor(id)
describe('x-graph label / group helpers', () => {
it('read the label property and the group from the schema; sort groups with the catch-all last', () => {
expect(labelKey({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('title')
expect(groupOf({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('Commerce')
expect(labelKey({ 'x-graph': {} })).toBeUndefined()
expect(labelKey(undefined)).toBeUndefined()
expect(groupOf({ 'x-graph': { group: 3 } })).toBeUndefined()
expect(sortGroups(['Layout', OTHER_GROUP, 'Commerce', 'Layout', 'Content'])).toEqual(['Commerce', 'Content', 'Layout', OTHER_GROUP])
expect(labelKey(schemas.footer)).toBe('companyName')
})
})
describe('node titles (x-graph.label)', () => {
it('loads every title with ONE tree request, not one request per block', async () => {
h = await setup()
await h.settle()
await h.settle()
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/tree/')).length).toBe(1)
expect(h.cms.requests.filter((r) => /^GET \/api\/nodes\/[^/]+$/.test(r))).toEqual([])
expect(['s1', 'h1', 'b1', 'f1', 'l1'].map(label)).toEqual(['Welcome', 'Edit this headline', 'Get in touch', 'Mock AB', 'Two columns'])
expect(label('t1')).toBeUndefined() // the text type declares no label
})
it('shows the title in the outline (type as the secondary line), falling back to type + text', async () => {
h = await setup()
await h.settle()
await h.settle()
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')
const t1 = items.find((b) => b.querySelector('.cms-outline__type')!.textContent === 'text')!
expect(t1.querySelector('.cms-outline__text')!.textContent).toContain('A paragraph')
})
it('titles the toolbar chip (type and group in its tooltip), the hover tag and the dialog', async () => {
h = await setup()
await h.settle()
await h.settle()
await select('s1')
expect(q('.cms-bar__name').textContent).toBe('Welcome')
expect(q('.cms-bar__name').getAttribute('title')).toBe('section · Layout')
h.editor.ctrl.store.set({ hovered: 'f1' })
await h.settle()
const tag = q('.cms-frame__tag')
expect(tag.textContent).toContain('Mock AB')
expect(tag.textContent).toContain('footer')
expect(tag.querySelector('em')!.textContent).toBe('Layout')
await h.press('Properties')
expect(q('.cms-dialog__head').textContent).toContain('Properties · Welcome')
await select('t1')
expect(q('.cms-bar__name').textContent).toBe('text') // no label key: the type
})
it('titles follow edits live, and new nodes get theirs lazily', async () => {
h = await setup()
await h.settle()
await h.settle()
await select('s1')
await h.editor.ctrl.setData('s1', { title: 'Renamed' })
await h.settle()
await h.settle()
expect(label('s1')).toBe('Renamed')
expect(q('.cms-bar__name').textContent).toBe('Renamed')
const res = await h.editor.ctrl.insert('s2', 'button', { data: { label: 'Click me' } })
await h.settle()
const id = res!.results[0].node!
label(id) // first ask kicks off the fetch ...
await h.settle()
await h.settle()
expect(label(id)).toBe('Click me') // ... and it is there once loaded
})
it('long titles are shortened', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.setData('h1', { text: 'x'.repeat(80) })
await h.settle()
await h.settle()
expect(label('h1')!.length).toBe(40)
expect(label('h1')!.endsWith('…')).toBe(true)
})
})
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('Panel')
await h.settle()
await h.settle()
const secs = qa('.cms-group-sec')
expect(secs.map((s) => s.querySelector('summary')!.textContent!.replace(/\s*\d+$/, '').trim())).toEqual(['Content', 'Layout', 'Blueprints'])
const inGroup = (g: string) => [...secs.find((s) => s.querySelector('summary')!.textContent!.startsWith(g))!.querySelectorAll<HTMLElement>('.cms-block')].map((x) => x.dataset.type)
expect(inGroup('Content')).toEqual(['heading', 'text', 'button'])
expect(inGroup('Layout')).toEqual(expect.arrayContaining(['section', 'layout', 'footer']))
expect(secs.every((s) => (s as HTMLDetailsElement).open)).toBe(true)
await act(async () => void ((secs[0].querySelector('summary') as HTMLElement).click()))
expect((secs[0] as HTMLDetailsElement).open).toBe(false)
})
it('the insert picker groups the allowed types too', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Add inside')
await h.settle()
await h.settle()
expect(qa('.cms-dialog h4').map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints'])
expect(qa('.cms-dialog .cms-tile').map((x) => x.dataset.type)).toEqual(['heading', 'text', 'button', 'section', 'layout', undefined]) // + the blueprint tile
})
})
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('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
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/schemas/page'))).toEqual([])
expect(h.cms.requests.filter((r) => r === 'GET /api/types').length).toBe(1)
expect(h.editor.ctrl.schemaOf('page')).toBeDefined()
expect(h.editor.ctrl.groupOfType('page')).toBe('Pages')
})
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('Panel')
await h.settle()
const heading = q('.cms-block[data-type="heading"]')
expect(heading.querySelector('span')!.textContent).toBe('Heading')
expect(heading.querySelector('small')!.textContent).toBe('heading')
expect(heading.getAttribute('title')).toBe('Add Heading')
expect(h.editor.ctrl.typeTitle('footer')).toBe('Footer')
expect(h.editor.ctrl.typeTitle('unknown-type')).toBe('unknown-type')
})
it('page-root (staticRoot) types are not offered, even where a container would accept anything', async () => {
h = await setup()
await h.settle()
expect(h.editor.ctrl.isStaticRoot('page')).toBe(true)
expect(h.editor.ctrl.isStaticRoot('heading')).toBe(false)
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')
})
it('the insert picker shows titles too', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Add inside')
await h.settle()
const tile = q('.cms-dialog .cms-tile[data-type="layout"]')
expect(tile.querySelector('span')!.textContent).toBe('Layout')
expect(tile.querySelector('small')!.textContent).toBe('layout')
})
it('ingestTypes tolerates entries without a schema (they stay "Other" until loaded)', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.ingestTypes([{ type: 'mystery' }, 'bare-string', { type: 'with', schema: { title: 'With', 'x-graph': { group: 'Odd' } } }])
expect(h.editor.ctrl.groupOfType('mystery')).toBe('Other')
expect(h.editor.ctrl.groupOfType('with')).toBe('Odd')
expect(h.editor.ctrl.typeTitle('with')).toBe('With')
})
})