Palette blueprints, searched nodes and moving blocks shared one engine but only the single current target line was visible, so there was no way to see where a dragged item could land. While a drag is active the overlay now outlines every valid dropzone with the engine's own predicates (accepts / acceptsInSlot, blueprint refusal, self-exclusion): containers in green, fitting slots in blue with a name tag, refused slots dimmed red with a name tag so they read as "exists but not for this". Tests: palette fit + slot-fallback, blueprint container/refused-slot, node fit/refuse on the same slot, move self-exclusion; typecheck clean. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
483 lines
22 KiB
TypeScript
483 lines
22 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'])
|
|
})
|
|
|
|
it('a slot that refuses the type names what it takes (drop still falls back to the container)', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
place('s1', 0, 0, 600, 300)
|
|
// The section schema declares a `sidebar` slot taking text/button/footer; the template marks it.
|
|
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
|
|
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
|
|
h.editor.ctrl.scan()
|
|
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
|
|
await ptr('pointermove', 80, 200)
|
|
expect(h.editor.ctrl.store.get().drag).toMatchObject({ slot: { parentId: 's1', handle: 'sidebar' }, target: { parentId: 's1' } })
|
|
expect(h.shadow.querySelector('.cms-slotref')?.textContent).toContain('sidebar takes: text, button, footer')
|
|
expect(h.shadow.querySelector('.cms-dragchip')?.className).toContain('is-refused')
|
|
await ptr('pointerup', 80, 200)
|
|
await h.settle()
|
|
expect(h.editor.ctrl.store.get().drag).toBeNull()
|
|
})
|
|
|
|
it('a slot that accepts the type shows no refusal', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
place('s1', 0, 0, 600, 300)
|
|
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
|
|
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
|
|
h.editor.ctrl.scan()
|
|
h.editor.ctrl.beginPaletteDrag({ type: 'button', label: 'button' })
|
|
await ptr('pointermove', 80, 200)
|
|
expect(h.editor.ctrl.store.get().drag).toMatchObject({
|
|
slot: { parentId: 's1', handle: 'sidebar' },
|
|
target: { parentId: 's1', handle: 'sidebar' },
|
|
})
|
|
expect(h.shadow.querySelector('.cms-slotref')).toBeNull()
|
|
await ptr('pointerup', 80, 200)
|
|
await h.settle()
|
|
})
|
|
|
|
it('holding the pointer at the viewport edge scrolls the page while dragging', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
place('s1', 0, 0, 600, 300)
|
|
h.editor.ctrl.scan()
|
|
const scrolled: Array<[number, number]> = []
|
|
const realScrollBy = window.scrollBy
|
|
window.scrollBy = ((dx: number, dy: number) => void scrolled.push([dx, dy])) as typeof window.scrollBy
|
|
try {
|
|
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
|
|
await ptr('pointermove', 400, window.innerHeight - 4)
|
|
await act(async () => void (await new Promise((r) => setTimeout(r, 40)))) // let the rAF autoscroll step run
|
|
expect(scrolled.length).toBeGreaterThan(0)
|
|
expect(scrolled[0]).toEqual([0, expect.any(Number)])
|
|
expect(scrolled[0][1]).toBeGreaterThan(0)
|
|
await ptr('pointerup', 400, window.innerHeight - 4)
|
|
await h.settle()
|
|
} finally {
|
|
window.scrollBy = realScrollBy
|
|
}
|
|
expect(h.editor.ctrl.store.get().drag).toBeNull()
|
|
})
|
|
})
|
|
|
|
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'])
|
|
})
|
|
})
|
|
|
|
describe('dropzone highlights', () => {
|
|
const zones = () => qa('.cms-dropzone:not(.cms-dropzone--slot)').map((el) => el.getAttribute('data-dropzone'))
|
|
const slots = () => qa('.cms-dropzone--slot')
|
|
const stop = async () => {
|
|
await act(async () => void h.editor.ctrl.cancelDrag())
|
|
await h.settle()
|
|
}
|
|
|
|
it('a palette drag outlines every container the type fits, and clears on drop/cancel', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
expect(qa('.cms-dropzone').length).toBe(0) // idle: no highlights
|
|
h.editor.ctrl.beginPaletteDrag({ type: 'text', label: 'Text' })
|
|
await h.settle()
|
|
const ids = zones()
|
|
expect(ids).toContain('s1') // section takes text as a plain child
|
|
expect(ids).toContain('s2')
|
|
expect(ids).toContain('l1') // layout takes text too
|
|
expect(ids).not.toContain('t1') // leaves are never dropzones
|
|
expect(slots().length).toBe(0) // the fixture page has no tagged slots
|
|
await stop()
|
|
expect(qa('.cms-dropzone').length).toBe(0)
|
|
})
|
|
|
|
it('a type that only fits through a slot still lights up the container', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
expect(h.editor.ctrl.accepts('s1', 'footer')).toBe(false) // not a plain child ...
|
|
expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar') // ... but fits the slot
|
|
h.editor.ctrl.beginPaletteDrag({ type: 'footer', label: 'Footer' })
|
|
await h.settle()
|
|
expect(zones()).toContain('s1') // the drop lands in its slot, so it glows
|
|
await stop()
|
|
})
|
|
|
|
it('a blueprint drag lights up containers and marks every slot refused', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
h.editor.ctrl.scan()
|
|
h.editor.ctrl.beginPaletteDrag({ blueprint: 'hero', label: 'Hero' })
|
|
await h.settle()
|
|
expect(zones()).toContain('s1') // typeless: every container fits
|
|
const s = slots()
|
|
expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar'])
|
|
expect(s[0].className).toContain('is-refused') // blueprints cannot land in slots
|
|
expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar')
|
|
await stop()
|
|
})
|
|
|
|
it('a node drag accepts a fitting slot and refuses one that does not take the type', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
|
|
h.editor.ctrl.scan()
|
|
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' })
|
|
await h.settle()
|
|
expect(slots().length).toBe(1)
|
|
expect(slots()[0].className).not.toContain('is-refused') // sidebar takes text
|
|
await stop()
|
|
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'heading', label: 'headline' })
|
|
await h.settle()
|
|
expect(slots().length).toBe(1)
|
|
expect(slots()[0].className).toContain('is-refused') // sidebar takes text/button/footer
|
|
expect(zones()).toContain('s1') // ... while the section itself still takes it
|
|
await stop()
|
|
})
|
|
|
|
it('moving a block excludes itself (and never highlights leaves)', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
h.editor.ctrl.beginDrag('s1')
|
|
await h.settle()
|
|
const ids = zones()
|
|
expect(ids).not.toContain('s1') // cannot drop into itself
|
|
expect(ids).toEqual(expect.arrayContaining(['s2', 'l1'])) // sections nest, so siblings still take it
|
|
expect(ids).not.toContain('h1') // leaves are never dropzones
|
|
await stop()
|
|
})
|
|
})
|