Rail sections (Outline/Blocks/Nodes) collapse via their headers like Experiments already did, so long pages stay navigable. Rail open state and section toggles persist in localStorage across reloads (the QA preview flow reloads constantly). The assistant panel shifts left while the dev panel is open instead of stacking exactly underneath it, and the rail/dev/assistant widths cap at the viewport on small screens. Tests: rail-sections harness (collapse, reload persistence, shifted assistant); harness boots with a fresh profile unless keepStorage is passed; in-memory localStorage shim for the origin-less jsdom. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
734 lines
32 KiB
TypeScript
734 lines
32 KiB
TypeScript
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { InsertPicker } from '../editors/builtin'
|
|
import type { CmsNode, EvaluateContext, ExperimentAnalytics, ExperimentAssignment, ExperimentRule, TypeInfo } from '../api'
|
|
import { allowedChildren, connectionsOf, labelKey, sortGroups, typeName } from '../schema'
|
|
import type { DropTarget } from '../page/dnd'
|
|
import { Icon, TypeIcon } from './Icon'
|
|
import { Summary } from './Summary'
|
|
import { useCtrl, useEditorState, usePersistentState } from './hooks'
|
|
import type { Block, Controller } from '../controller'
|
|
|
|
/**
|
|
* Left panel: outline + blocks + nodes in one place (no tabs).
|
|
*
|
|
* - Outline is a tree with its slots (e.g. a pdp's `info-section` /
|
|
* `3d-accessories` connections): drag any row by its grip to reorder, drop
|
|
* palette blocks and searched nodes onto the gaps between rows or into slots.
|
|
* - Blocks are the new types that fit this page (drag onto the page or the outline).
|
|
* - Nodes finds *existing* nodes (type filter + text) to link into the page.
|
|
*/
|
|
export function Rail() {
|
|
const ctrl = useCtrl()
|
|
const open = useEditorState((s) => s.rail)
|
|
useEffect(() => {
|
|
if (!open) return
|
|
// Types carry every schema (titles, groups, slots): one request seeds them all.
|
|
void ctrl.api
|
|
.types()
|
|
.then((ts) => {
|
|
ctrl.ingestTypes(ts)
|
|
void ctrl.loadSchemas(ts.map(typeName))
|
|
})
|
|
.catch(() => {})
|
|
void ctrl.fillHandles()
|
|
}, [open, ctrl])
|
|
if (!open) return null
|
|
return (
|
|
<aside className="cms-rail" aria-label="Page panel">
|
|
<header>
|
|
<span className="cms-rail__title">
|
|
<Icon name="outline" size={16} /> Page
|
|
</span>
|
|
<button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel">
|
|
<Icon name="close" />
|
|
</button>
|
|
</header>
|
|
<div className="cms-rail__body">
|
|
<OutlineTree />
|
|
<Palette />
|
|
<NodeSearch />
|
|
<ExperimentsPanel />
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
// --- outline -----------------------------------------------------------------
|
|
|
|
type Gap = { parentId: string; handle?: string; index: number }
|
|
|
|
function OutlineTree() {
|
|
const ctrl = useCtrl()
|
|
const blocks = useEditorState((s) => s.blocks)
|
|
const selected = useEditorState((s) => s.selected)
|
|
const drag = useEditorState((s) => s.drag)
|
|
useEditorState((s) => s.tick)
|
|
useEditorState((s) => s.schemasVersion)
|
|
useEditorState((s) => s.labelsVersion)
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
|
const [over, setOver] = useState<Gap | null>(null)
|
|
const dragging = !!drag
|
|
|
|
// Our pointerup runs before the page engine's (registered at mount, theirs at
|
|
// drag start): a drop on a gap commits to the outline, anywhere else on the
|
|
// panel just cancels the page ghost.
|
|
useEffect(() => {
|
|
const onUp = (e: PointerEvent) => {
|
|
const d = ctrl.store.get().drag
|
|
if (!d) return
|
|
const path = e.composedPath() as Element[]
|
|
const inRail = path.some((el) => el instanceof Element && el.classList?.contains('cms-rail'))
|
|
if (!inRail) return
|
|
e.stopImmediatePropagation()
|
|
e.preventDefault()
|
|
const gapEl = path.find((el) => el instanceof Element && el.hasAttribute?.('data-ogap'))
|
|
const target: DropTarget | null = gapEl
|
|
? {
|
|
parentId: gapEl.getAttribute('data-parent') ?? '',
|
|
handle: gapEl.getAttribute('data-handle') || undefined,
|
|
index: Number(gapEl.getAttribute('data-index') ?? 0),
|
|
line: { x: 0, y: 0, w: 0, h: 0 },
|
|
}
|
|
: null
|
|
setOver(null)
|
|
ctrl.cancelDrag()
|
|
if (!target) return
|
|
if (d.source === 'block' && d.id) void ctrl.dropBlock(d.id, target)
|
|
else if (d.source === 'existing' && d.nodeId) void ctrl.dropExisting(target, d.nodeId)
|
|
else if (d.source === 'palette' && d.type && !d.blueprint) void ctrl.dropNew(target, { type: d.type })
|
|
else if (d.source === 'palette' && d.blueprint && !target.handle) void ctrl.dropNew(target, { blueprint: d.blueprint })
|
|
else if (d.source === 'palette' && d.blueprint && target.handle) ctrl.toast('info', 'Blueprints cannot go straight into a slot — drop it on the page first, then move it')
|
|
}
|
|
ctrl.doc.addEventListener('pointerup', onUp, true)
|
|
return () => ctrl.doc.removeEventListener('pointerup', onUp, true)
|
|
}, [ctrl])
|
|
|
|
useEffect(() => {
|
|
if (!drag) setOver(null)
|
|
}, [drag])
|
|
|
|
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true)
|
|
if (!blocks.length) return null
|
|
return (
|
|
<section aria-label="Outline">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Outline" onClick={() => setOpen((o) => !o)}>
|
|
Outline ({blocks.length})
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<>
|
|
<ul className="cms-outline cms-outline--tree">
|
|
{roots.map((b) => (
|
|
<OutlineNode key={b.id} block={b} level={0} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
|
))}
|
|
</ul>
|
|
{dragging && <OutlineGap parentId="" index={roots.length} over={over} setOver={setOver} />}
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function OutlineGap({ parentId, handle, index, over, setOver }: { parentId: string; handle?: string; index: number; over: Gap | null; setOver: (g: Gap | null) => void }) {
|
|
const ctrl = useCtrl()
|
|
const drag = useEditorState((s) => s.drag)
|
|
if (!drag) return null
|
|
const active = over?.parentId === parentId && (over?.handle ?? '') === (handle ?? '') && over?.index === index
|
|
// Blueprints cannot land in slots; slot gaps refuse them.
|
|
const refused = !!drag.blueprint && !!handle
|
|
// Page drops onto a type the slot does not take are refused too.
|
|
const typeRefused = !!drag.type && !drag.blueprint && parentId !== '' && !ctrl.accepts(parentId, drag.type) && !(handle ? ctrl.acceptsInSlot(parentId, handle, drag.type) : ctrl.handleForDrop(parentId, drag.type))
|
|
return (
|
|
<div
|
|
className={`cms-ogap${active && !refused ? ' is-over' : ''}${refused || typeRefused ? ' is-refused' : ''}`}
|
|
data-ogap=""
|
|
data-parent={parentId}
|
|
data-handle={handle ?? ''}
|
|
data-index={index}
|
|
onPointerEnter={() => (refused ? setOver(null) : setOver({ parentId, handle, index }))}
|
|
onPointerLeave={() => setOver(null)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function OutlineNode({ block: b, level, collapsed, setCollapsed, over, setOver, dragging, selected }: {
|
|
block: Block
|
|
level: number
|
|
collapsed: Set<string>
|
|
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
|
|
over: Gap | null
|
|
setOver: (g: Gap | null) => void
|
|
dragging: boolean
|
|
selected: string | null
|
|
}) {
|
|
const ctrl = useCtrl()
|
|
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === b.id && !ctrl.handleOf(x.id)))
|
|
const slots = useMemo(() => ctrl.slotsFor(b.id), [ctrl, b.id, b.schema, kids.length])
|
|
useEditorState((s) => s.tick)
|
|
const isCollapsed = collapsed.has(b.id)
|
|
const isSel = b.id === selected
|
|
const toggle = () => setCollapsed((s) => {
|
|
const n = new Set(s)
|
|
if (n.has(b.id)) n.delete(b.id)
|
|
else n.add(b.id)
|
|
return n
|
|
})
|
|
const title = ctrl.labelFor(b.id)
|
|
const hasKids = kids.length > 0 || slots.some((sl) => ctrl.slotChildren(b.id, sl.name).length > 0)
|
|
return (
|
|
<li>
|
|
<div className={`cms-orow${isSel ? ' is-sel' : ''}`} style={{ paddingLeft: 4 + level * 14 }}>
|
|
{(b.container || slots.length > 0) && hasKids ? (
|
|
<button className="cms-ico cms-orow__chev" onClick={toggle} aria-label={isCollapsed ? 'Expand' : 'Collapse'} title={isCollapsed ? 'Expand' : 'Collapse'}>
|
|
<span className={`cms-chev${isCollapsed ? '' : ' is-open'}`} style={{ display: 'inline-flex' }}>
|
|
<Icon name="chevron" size={14} />
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<span className="cms-orow__spacer" />
|
|
)}
|
|
<span
|
|
className="cms-orow__grip"
|
|
title="Drag to reorder"
|
|
aria-label={`Drag ${title ?? b.type} to reorder`}
|
|
onPointerDown={(e) => {
|
|
if (e.button !== 0) return
|
|
e.stopPropagation()
|
|
const x0 = e.clientX
|
|
const y0 = e.clientY
|
|
const doc = e.currentTarget.ownerDocument
|
|
const move = (m: PointerEvent) => {
|
|
if (Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
ctrl.setMode('edit')
|
|
ctrl.beginDrag(b.id)
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}}
|
|
>
|
|
<Icon name="grip" size={14} />
|
|
</span>
|
|
<button
|
|
className="cms-orow__main"
|
|
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
|
|
onMouseLeave={() => ctrl.store.set({ hovered: null })}
|
|
onClick={() => {
|
|
ctrl.setMode('edit')
|
|
ctrl.select(b.id, { scroll: true })
|
|
}}
|
|
title={`${b.type}${ctrl.handleOf(b.id) ? ` · in slot ${ctrl.handleOf(b.id)}` : ''}`}
|
|
>
|
|
<TypeIcon type={b.type} size={15} />
|
|
{title ? (
|
|
<>
|
|
<span className="cms-outline__type">{title}</span>
|
|
<span className="cms-outline__text">{b.type}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="cms-outline__type">{b.type || '?'}</span>
|
|
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
|
|
</>
|
|
)}
|
|
{ctrl.handleOf(b.id) && <em className="cms-oslot-tag">{ctrl.handleOf(b.id)}</em>}
|
|
</button>
|
|
</div>
|
|
{!isCollapsed && (
|
|
<ul className="cms-outline cms-outline--nested">
|
|
{dragging && <OutlineGap parentId={b.id} index={0} over={over} setOver={setOver} />}
|
|
{kids.map((k, i) => (
|
|
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
|
))}
|
|
{dragging && kids.length > 0 && <OutlineGap parentId={b.id} index={kids.length} over={over} setOver={setOver} />}
|
|
{slots.map((sl) => (
|
|
<SlotGroup key={sl.name} parentId={b.id} level={level + 1} slot={sl} over={over} setOver={setOver} dragging={dragging} collapsed={collapsed} setCollapsed={setCollapsed} selected={selected} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/** A plain (non-slot) child: its own row, followed by the gap after it. */
|
|
function OutlineNodeInner(props: { block: Block; index: number; level: number } & Omit<Parameters<typeof OutlineNode>[0], 'block' | 'level'>) {
|
|
const { block, index, level, ...rest } = props
|
|
return (
|
|
<>
|
|
<OutlineNode block={block} level={level} {...rest} />
|
|
{rest.dragging && <OutlineGap parentId={block.parentId ?? ''} index={index + 1} over={rest.over} setOver={rest.setOver} />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SlotGroup({ parentId, level, slot, over, setOver, dragging, collapsed, setCollapsed, selected }: {
|
|
parentId: string
|
|
level: number
|
|
slot: { name: string; def: { to: string[]; kind: string; description?: string }; count: number; accepts: (t: string) => boolean }
|
|
over: Gap | null
|
|
setOver: (g: Gap | null) => void
|
|
dragging: boolean
|
|
collapsed: Set<string>
|
|
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
|
|
selected: string | null
|
|
}) {
|
|
const ctrl = useCtrl()
|
|
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === parentId && ctrl.handleOf(x.id) === slot.name))
|
|
const add = () => {
|
|
let key = 0
|
|
key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) })
|
|
}
|
|
return (
|
|
<li className="cms-oslot" style={{ marginLeft: level * 14 }}>
|
|
<div className="cms-oslot__head" title={slot.def.description ?? `Slot: ${slot.name}`}>
|
|
<Icon name="shape" size={13} />
|
|
<span>{slot.name}</span>
|
|
<span className="cms-muted">{slot.def.to.length ? slot.def.to.join(', ') : 'any'}</span>
|
|
<button className="cms-ico" onClick={add} title={`Add to ${slot.name}`} aria-label={`Add to ${slot.name}`}>
|
|
<Icon name="add" size={14} />
|
|
</button>
|
|
</div>
|
|
<ul className="cms-outline cms-outline--nested">
|
|
{dragging && <OutlineGap parentId={parentId} handle={slot.name} index={0} over={over} setOver={setOver} />}
|
|
{kids.map((k, i) => (
|
|
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
|
))}
|
|
{dragging && kids.length > 0 && <OutlineGap parentId={parentId} handle={slot.name} index={kids.length} over={over} setOver={setOver} />}
|
|
</ul>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
// --- palette (new blocks) -------------------------------------------------------
|
|
|
|
const DRAG_THRESHOLD = 5
|
|
|
|
type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string }
|
|
|
|
function Palette() {
|
|
const ctrl = useCtrl()
|
|
const [types, setTypes] = useState<TypeInfo[] | null>(null)
|
|
const [blueprints, setBlueprints] = useState<import('../api').Blueprint[]>([])
|
|
const [q, setQ] = useState('')
|
|
const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks }))
|
|
const schemasVersion = useEditorState((s) => s.schemasVersion)
|
|
const suppressClick = useRef(false)
|
|
|
|
useEffect(() => {
|
|
void ctrl.api
|
|
.types()
|
|
.then((ts) => {
|
|
ctrl.ingestTypes(ts) // /api/types includes every schema: titles + groups with no further requests
|
|
setTypes(ts)
|
|
void ctrl.loadSchemas(ts.map(typeName)) // only for entries that arrived without a schema
|
|
})
|
|
.catch(() => setTypes([]))
|
|
void ctrl.api.blueprints().then(setBlueprints).catch(() => {})
|
|
}, [ctrl])
|
|
|
|
// Types some container on the page (or the page itself) can hold - the rest would only ever be refused.
|
|
const items = useMemo<Item[]>(() => {
|
|
if (!types) return []
|
|
const all = types.map(typeName).filter(Boolean)
|
|
const allowed = new Set<string>()
|
|
for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t))
|
|
// Slots accept more than plain children (e.g. a pdp's info-section): include slot types too.
|
|
for (const b of blocks) for (const h of Object.keys(connectionsOf(b.schema))) for (const t of connectionsOf(b.schema)[h]?.to ?? []) allowed.add(t)
|
|
const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing
|
|
return [
|
|
...types
|
|
.map((t) => ({ t, name: typeName(t) }))
|
|
.filter(({ name }) => name && !ctrl.isStaticRoot(name) && (rootAllowed || allowed.has(name)))
|
|
.map(({ name }) => {
|
|
const title = ctrl.typeTitle(name)
|
|
return { key: `t:${name}`, label: title, sub: title !== name ? name : undefined, type: name, group: ctrl.groupOfType(name), description: ctrl.schemaOf(name)?.description as string | undefined }
|
|
}),
|
|
...blueprints.map((b) => ({ key: `b:${b.id}`, label: b.name ?? b.id, blueprint: b.id, group: 'Blueprints', description: b.description })),
|
|
]
|
|
}, [types, blueprints, blocks, ctrl, schemasVersion])
|
|
|
|
const needle = q.toLowerCase()
|
|
const shown = items.filter((i) => i.label.toLowerCase().includes(needle) || (i.type ?? '').toLowerCase().includes(needle))
|
|
const groups = sortGroups(shown.map((i) => i.group).filter((g) => g !== 'Blueprints'))
|
|
if (shown.some((i) => i.group === 'Blueprints')) groups.push('Blueprints')
|
|
|
|
const addToSelection = async (item: Item) => {
|
|
const sel = ctrl.block(selected)
|
|
try {
|
|
let parent: string
|
|
let index: number | undefined
|
|
let handle: string | undefined
|
|
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type) || (handle = ctrl.handleForDrop(sel.id, item.type)))) parent = sel.id
|
|
else if (sel) {
|
|
parent = await ctrl.parentIdOf(sel.id)
|
|
handle = ctrl.handleOf(sel.id)
|
|
index = (await ctrl.position(sel.id)).index + 1
|
|
} else parent = await ctrl.pageRoot()
|
|
if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index)
|
|
else if (item.type) await ctrl.insert(parent, item.type, { ...(index !== undefined ? { index } : {}), ...(handle ? { handle } : {}) })
|
|
} catch (e) {
|
|
ctrl.toast('error', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
const onPointerDown = (e: React.PointerEvent, item: Item) => {
|
|
if (e.button !== 0) return
|
|
const x0 = e.clientX
|
|
const y0 = e.clientY
|
|
const doc = e.currentTarget.ownerDocument
|
|
const move = (m: PointerEvent) => {
|
|
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
|
|
stop()
|
|
suppressClick.current = true
|
|
ctrl.setMode('edit')
|
|
ctrl.beginPaletteDrag({ type: item.type, blueprint: item.blueprint, label: item.label })
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}
|
|
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true)
|
|
return (
|
|
<section aria-label="Blocks">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Blocks" onClick={() => setOpen((o) => !o)}>
|
|
Blocks
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<>
|
|
<div className="cms-pad">
|
|
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
<p className="cms-muted cms-hint">Drag onto the page or the outline, or click to add to the selection.</p>
|
|
</div>
|
|
{!types && <p className="cms-muted cms-pad">Loading…</p>}
|
|
{groups.map((g) => (
|
|
<details key={g} className="cms-group-sec" open>
|
|
<Summary>
|
|
{g} <span className="cms-muted">{shown.filter((i) => i.group === g).length}</span>
|
|
</Summary>
|
|
<div className="cms-palette__grid">
|
|
{shown
|
|
.filter((i) => i.group === g)
|
|
.map((i) => (
|
|
<button
|
|
key={i.key}
|
|
className="cms-block"
|
|
data-type={i.type}
|
|
title={i.description ?? `Add ${i.label}`}
|
|
onPointerDown={(e) => onPointerDown(e, i)}
|
|
onClick={() => {
|
|
if (suppressClick.current) return void (suppressClick.current = false)
|
|
void addToSelection(i)
|
|
}}
|
|
>
|
|
<TypeIcon type={i.type ?? i.label} size={22} />
|
|
<span>{i.label}</span>
|
|
{i.sub && <small>{i.sub}</small>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</details>
|
|
))}
|
|
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- node search (existing nodes) -----------------------------------------------
|
|
|
|
function nodeTitle(ctrl: Controller, n: CmsNode): string | undefined {
|
|
const key = labelKey(ctrl.schemaOf(n.dataType))
|
|
const v = key ? (n.data as Record<string, unknown>)?.[key] : undefined
|
|
const text = typeof v === 'string' || typeof v === 'number' ? String(v).replace(/\s+/g, ' ').trim() : ''
|
|
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text
|
|
const first = Object.values(n.data ?? {}).find((x) => typeof x === 'string' && x.trim())
|
|
const fb = typeof first === 'string' ? first.trim().slice(0, 40) : ''
|
|
return fb || undefined
|
|
}
|
|
|
|
function NodeSearch() {
|
|
const ctrl = useCtrl()
|
|
const [typeNames, setTypeNames] = useState<string[]>([])
|
|
const [type, setType] = useState('')
|
|
const [q, setQ] = useState('')
|
|
const [results, setResults] = useState<CmsNode[] | null>(null)
|
|
const [total, setTotal] = useState(0)
|
|
const [busy, setBusy] = useState(false)
|
|
const selected = useEditorState((s) => s.selected)
|
|
useEditorState((s) => s.schemasVersion)
|
|
|
|
useEffect(() => {
|
|
void ctrl.api
|
|
.types()
|
|
.then((ts) => {
|
|
ctrl.ingestTypes(ts)
|
|
setTypeNames(ts.map(typeName).filter(Boolean).sort())
|
|
})
|
|
.catch(() => {})
|
|
}, [ctrl])
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setBusy(true)
|
|
void ctrl.api
|
|
.searchNodes({ type: type || undefined, q: q || undefined, limit: 20 })
|
|
.then((r) => {
|
|
setResults(r.items)
|
|
setTotal(r.total)
|
|
void ctrl.loadSchemas([...new Set(r.items.map((n) => n.dataType))])
|
|
})
|
|
.catch(() => setResults([]))
|
|
.finally(() => setBusy(false))
|
|
}, q ? 250 : 0)
|
|
return () => clearTimeout(t)
|
|
}, [q, type, ctrl])
|
|
|
|
const linkToSelection = async (node: CmsNode) => {
|
|
const sel = ctrl.block(selected)
|
|
try {
|
|
if (sel && sel.container && (ctrl.accepts(sel.id, node.dataType) || ctrl.handleForDrop(sel.id, node.dataType))) {
|
|
await ctrl.linkExisting(sel.id, node.id, { handle: ctrl.handleForDrop(sel.id, node.dataType) })
|
|
} else if (sel) {
|
|
const parent = await ctrl.parentIdOf(sel.id)
|
|
const { index } = await ctrl.position(sel.id)
|
|
await ctrl.linkExisting(parent, node.id, { handle: ctrl.handleOf(sel.id), index: index + 1 })
|
|
} else {
|
|
await ctrl.linkExisting(await ctrl.pageRoot(), node.id, {})
|
|
}
|
|
} catch (e) {
|
|
ctrl.toast('error', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
const onPointerDown = (e: React.PointerEvent, node: CmsNode) => {
|
|
if (e.button !== 0) return
|
|
const x0 = e.clientX
|
|
const y0 = e.clientY
|
|
const doc = e.currentTarget.ownerDocument
|
|
const move = (m: PointerEvent) => {
|
|
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
|
|
stop()
|
|
ctrl.setMode('edit')
|
|
ctrl.beginExistingDrag({ id: node.id, type: node.dataType, label: nodeTitle(ctrl, node) ?? node.dataType })
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}
|
|
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true)
|
|
return (
|
|
<section aria-label="Nodes">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Nodes" onClick={() => setOpen((o) => !o)}>
|
|
Nodes
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<>
|
|
<div className="cms-pad cms-nodefind">
|
|
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
<select className="cms-input" aria-label="Filter by type" value={type} onChange={(e) => setType(e.target.value)}>
|
|
<option value="">All types</option>
|
|
{typeNames.map((t) => (
|
|
<option key={t} value={t}>
|
|
{ctrl.typeTitle(t)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="cms-muted cms-hint">Link a shared block into the page — drag it onto the outline, or click + to add to the selection.</p>
|
|
</div>
|
|
{busy && !results && <p className="cms-muted cms-pad">Searching…</p>}
|
|
{results && !results.length && <p className="cms-muted cms-pad">No nodes match.</p>}
|
|
{!!results?.length && (
|
|
<ul className="cms-nodelist">
|
|
{results.map((n) => (
|
|
<li key={n.id} className="cms-noderow" onPointerDown={(e) => onPointerDown(e, n)} title={`${n.dataType} · ${n.id}`}>
|
|
<TypeIcon type={n.dataType} size={15} />
|
|
<span className="cms-noderow__main">
|
|
<span className="cms-outline__type">{nodeTitle(ctrl, n) ?? n.dataType}</span>
|
|
<span className="cms-outline__text">
|
|
{n.dataType} · {n.id.slice(0, 8)}
|
|
</span>
|
|
</span>
|
|
<button
|
|
className="cms-ico"
|
|
aria-label={`Link ${nodeTitle(ctrl, n) ?? n.id}`}
|
|
title="Link into the page"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void linkToSelection(n)
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Icon name="add" size={15} />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{!!results?.length && total > results.length && <p className="cms-muted cms-pad">Showing {results.length} of {total} — refine the search.</p>}
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- experiments: QA + eligibility on the page (plan-onpage-experiments-personalisation.md P0) ---------
|
|
|
|
const expCookie = (name: string): string | null => {
|
|
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
|
return m ? decodeURIComponent(m[1]) : null
|
|
}
|
|
const setExpCookie = (name: string, value: string | null) => {
|
|
document.cookie = value === null ? `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` : `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax`
|
|
}
|
|
const targetingSummary = (rules?: ExperimentRule['targeting']): string =>
|
|
!rules?.length ? 'everyone' : rules.map((r) => `${r.field} ${r.op} ${r.value ?? (r.values ?? []).join('|')}`).join(' · ')
|
|
const armNames = (rule: ExperimentRule): string[] => Object.keys(rule.variant_weights ?? {})
|
|
|
|
function ExperimentsPanel() {
|
|
const ctrl = useCtrl()
|
|
const [rules, setRules] = useState<ExperimentRule[] | null>(null)
|
|
const [adminDenied, setAdminDenied] = useState(false)
|
|
const [assign, setAssign] = useState<Record<string, ExperimentAssignment>>({})
|
|
const [stats, setStats] = useState<Record<string, ExperimentAnalytics>>({})
|
|
const [confirmOff, setConfirmOff] = useState<string | null>(null)
|
|
const [tick, setTick] = useState(0) // re-read override cookies after set/clear
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.experiments', false)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let live = true
|
|
const ctx: EvaluateContext = { pagePath: location.pathname, referrer: document.referrer || undefined, language: navigator.language || undefined }
|
|
void ctrl.api
|
|
.trackingConfig()
|
|
.then((cfg) => {
|
|
if (!live) return
|
|
const rs = cfg.experiment_rules ?? []
|
|
setRules(rs)
|
|
if (!rs.length) return
|
|
void ctrl.api.evaluateExperiments(rs.map((r) => r.experiment_id), ctx).then(
|
|
(res) => live && setAssign(Object.fromEntries((res.assignments ?? []).map((a) => [a.experimentId, a]))),
|
|
() => {}, // evaluate is public; a failure just leaves assignments blank
|
|
)
|
|
void ctrl.api.experimentAnalytics().then(
|
|
(rows) => live && setStats(Object.fromEntries((rows ?? []).map((s) => [s.experiment_id, s]))),
|
|
() => live && setAdminDenied(true), // admin-gated: eligibility still works, stats hide
|
|
)
|
|
})
|
|
.catch(() => live && (setRules([]), setAdminDenied(true)))
|
|
return () => void (live = false)
|
|
}, [open, ctrl])
|
|
|
|
const toggleDisabled = async (rule: ExperimentRule) => {
|
|
if (confirmOff !== rule.experiment_id) return void setConfirmOff(rule.experiment_id)
|
|
setConfirmOff(null)
|
|
try {
|
|
const res = await ctrl.api.setExperimentDisabled(rule.experiment_id, !rule.disabled)
|
|
setRules((rs) => rs?.map((r) => (r.experiment_id === rule.experiment_id ? { ...r, disabled: res.disabled } : r)) ?? rs)
|
|
ctrl.toast('info', `${rule.experiment_id} ${res.disabled ? 'paused' : 'resumed'}`)
|
|
} catch (e) {
|
|
ctrl.toast('error', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-label="Experiments panel">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Experiments" onClick={() => setOpen((o) => !o)}>
|
|
Experiments{rules ? ` (${rules.length})` : ''}
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<div className="cms-pad">
|
|
{rules === null && <p className="cms-muted">Loading experiments…</p>}
|
|
{rules !== null && !rules.length && <p className="cms-muted">No experiments configured in tracking.</p>}
|
|
{adminDenied && !!rules?.length && <p className="cms-muted cms-hint">Tracking admin refused — eligibility still works; stats and pause need an admin credential.</p>}
|
|
{!!rules?.length && (
|
|
<ul className="cms-explist">
|
|
{rules.map((rule) => {
|
|
const a = assign[rule.experiment_id]
|
|
const s = stats[rule.experiment_id]
|
|
const arms = armNames(rule)
|
|
const cookie = expCookie(a?.cookieName ?? `exp_${rule.experiment_id}`)
|
|
void tick
|
|
return (
|
|
<li key={rule.experiment_id} className="cms-exp">
|
|
<div className="cms-exp__head">
|
|
<strong>{rule.experiment_id}</strong>
|
|
<span className={`cms-pill${rule.disabled ? ' is-off' : ''}`}>{rule.disabled ? 'paused' : 'live'}</span>
|
|
</div>
|
|
<div className="cms-muted cms-exp__meta">
|
|
traffic {Math.round((rule.traffic_fraction ?? 1) * 100)}% · arms {arms.length ? arms.join(' / ') : 'a / b'} · {targetingSummary(rule.targeting)}
|
|
</div>
|
|
<div className="cms-exp__assign">
|
|
{a ? (a.eligible ? <>you: <b>{a.variant || '—'}</b></> : 'you: not eligible') : 'you: …'}
|
|
{cookie && cookie !== a?.variant && <span className="cms-muted"> (override: {cookie})</span>}
|
|
</div>
|
|
{s && (
|
|
<div className="cms-muted cms-exp__meta">
|
|
{s.total_sessions} sessions · winner <b>{s.winner || '—'}</b>
|
|
{s.winner ? ` (${Math.round((s.winner_probability ?? 0) * 100)}%)` : ''}
|
|
{s.significant ? ' · significant' : ''} — {s.conclusion || 'collecting data'}
|
|
</div>
|
|
)}
|
|
<div className="cms-exp__actions">
|
|
<label className="cms-muted">
|
|
QA arm{' '}
|
|
<select
|
|
className="cms-input"
|
|
aria-label={`Force variant for ${rule.experiment_id}`}
|
|
value={cookie ?? ''}
|
|
onChange={(e) => {
|
|
setExpCookie(a?.cookieName ?? `exp_${rule.experiment_id}`, e.target.value || null)
|
|
setTick((t) => t + 1)
|
|
}}
|
|
>
|
|
<option value="">session default</option>
|
|
{arms.map((v) => (
|
|
<option key={v} value={v}>
|
|
{v}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className={`cms-ico${confirmOff === rule.experiment_id ? ' is-on' : ''}`}
|
|
aria-label={rule.disabled ? `Resume ${rule.experiment_id}` : `Pause ${rule.experiment_id}`}
|
|
title={rule.disabled ? 'Resume (re-buckets new sessions)' : 'Pause (kill-switch: new sessions see control)'}
|
|
onClick={() => void toggleDisabled(rule)}
|
|
>
|
|
<Icon name={confirmOff === rule.experiment_id ? 'check' : rule.disabled ? 'play' : 'pause'} size={15} />
|
|
{confirmOff === rule.experiment_id && <span>confirm</span>}
|
|
</button>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
<p className="cms-muted cms-hint">Overrides set the storefront variant cookie — reload the page to preview the arm.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|