From eb4290a45e10f90d3427b3a410bd63263d8824df Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 06:59:23 -0700 Subject: [PATCH] feat(site-builder): derive Layers rows from array-prop composites FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery, ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm store their content in array props rather than Craft child nodes, so the Layers tree showed nothing underneath them. This adds the pure deriveVirtualRows() function and VIRTUAL_CHILD_PROPS registry the Layers panel will consume in a later task. Verified the registry against each component's actual item interface: corrected Tabs (label field is `label`, not `title` -- TabItem has no `title`) and Content Slider (label field is `heading`, not `title` -- Slide has no `title`). Both would have silently fallen back to "Tab N" / "Slide N" for every existing site. Co-Authored-By: Claude Opus 5 (1M context) --- .../panels/left/layers-virtual-rows.test.ts | 55 ++++++++++++++ craft/src/panels/left/layers-virtual-rows.ts | 73 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 craft/src/panels/left/layers-virtual-rows.test.ts create mode 100644 craft/src/panels/left/layers-virtual-rows.ts diff --git a/craft/src/panels/left/layers-virtual-rows.test.ts b/craft/src/panels/left/layers-virtual-rows.test.ts new file mode 100644 index 0000000..9c334e6 --- /dev/null +++ b/craft/src/panels/left/layers-virtual-rows.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from 'vitest'; +import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows'; + +describe('deriveVirtualRows', () => { + test('returns one row per item, labelled by the registered field', () => { + const rows = deriveVirtualRows('Features Grid', { + features: [{ title: 'Fast' }, { title: 'Secure' }], + }); + expect(rows).toEqual([ + { index: 0, label: 'Fast' }, + { index: 1, label: 'Secure' }, + ]); + }); + + test('falls back to " N" when the label field is missing or blank', () => { + const rows = deriveVirtualRows('Features Grid', { + features: [{ title: '' }, { description: 'no title key' }], + }); + expect(rows).toEqual([ + { index: 0, label: 'Feature 1' }, + { index: 1, label: 'Feature 2' }, + ]); + }); + + test('trims and truncates a long label to 40 characters with an ellipsis', () => { + const long = 'x'.repeat(60); + const rows = deriveVirtualRows('Features Grid', { features: [{ title: ` ${long} ` }] }); + expect(rows[0].label).toHaveLength(41); + expect(rows[0].label.endsWith('…')).toBe(true); + }); + + test('an unregistered component yields no rows', () => { + expect(deriveVirtualRows('Heading', { text: 'hi' })).toEqual([]); + }); + + test('a missing or non-array prop yields no rows instead of throwing', () => { + expect(deriveVirtualRows('Tabs', {})).toEqual([]); + expect(deriveVirtualRows('Tabs', { tabs: 'not an array' })).toEqual([]); + expect(deriveVirtualRows('Tabs', { tabs: null })).toEqual([]); + }); + + test('a non-object item still gets a fallback label', () => { + expect(deriveVirtualRows('Menu', { links: ['raw string'] })).toEqual([ + { index: 0, label: 'Link 1' }, + ]); + }); + + test('every registry entry has a non-empty prop, label and fallback', () => { + for (const [name, spec] of Object.entries(VIRTUAL_CHILD_PROPS)) { + expect(spec.prop, `${name}.prop`).toBeTruthy(); + expect(spec.label, `${name}.label`).toBeTruthy(); + expect(spec.fallback, `${name}.fallback`).toBeTruthy(); + } + }); +}); diff --git a/craft/src/panels/left/layers-virtual-rows.ts b/craft/src/panels/left/layers-virtual-rows.ts new file mode 100644 index 0000000..c8402a5 --- /dev/null +++ b/craft/src/panels/left/layers-virtual-rows.ts @@ -0,0 +1,73 @@ +/** + * Virtual Layers rows for components whose content lives in ARRAY PROPS + * rather than Craft child nodes. + * + * FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery, + * ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm all + * render their items from a prop array, so Craft sees them as leaf nodes and + * the Layers tree showed nothing underneath them -- the "Layers doesn't show + * everything" report. These rows are display-only: they are not Craft nodes, + * cannot be dragged, and selecting one selects the PARENT node (plus asks the + * array editor to scroll that item into view -- see LayerFocusContext). + * + * ColumnLayout is deliberately absent: it uses real `` + * children, which LayersPanel already nests correctly. + */ + +export interface VirtualRow { + index: number; + label: string; +} + +export interface VirtualChildSpec { + /** Name of the array prop holding the items. */ + prop: string; + /** Per-item field used as the row label. */ + label: string; + /** Used as " " when the label field is missing or blank. */ + fallback: string; +} + +/** Keyed by the component's craft `displayName` -- the same string + * `LayerNode` already resolves and shows as the row text. + * + * Verified against each component's actual item interface and + * `craft.props` defaults (see task-9-report.md, Step 1): + * - Tabs items are `{ label, content }`, not `{ title }` -- registry + * corrected from `title` to `label`. + * - ContentSlider items are `{ heading, text, ... }`, not `{ title }` -- + * registry corrected from `title` to `heading`. + * Every other entry (prop name and label field) matched the component as + * originally drafted. */ +export const VIRTUAL_CHILD_PROPS: Record = { + 'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' }, + Tabs: { prop: 'tabs', label: 'label', fallback: 'Tab' }, + Accordion: { prop: 'items', label: 'title', fallback: 'Item' }, + 'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' }, + Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' }, + Gallery: { prop: 'images', label: 'alt', fallback: 'Image' }, + 'Content Slider': { prop: 'slides', label: 'heading', fallback: 'Slide' }, + 'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' }, + Menu: { prop: 'links', label: 'text', fallback: 'Link' }, + 'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' }, + Navbar: { prop: 'links', label: 'text', fallback: 'Link' }, + 'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' }, +}; + +const MAX_LABEL = 40; + +export function deriveVirtualRows(displayName: string, props: Record): VirtualRow[] { + const spec = VIRTUAL_CHILD_PROPS[displayName]; + if (!spec) return []; + + const items = props?.[spec.prop]; + if (!Array.isArray(items)) return []; + + return items.map((item, index) => { + const raw = item && typeof item === 'object' ? item[spec.label] : undefined; + const text = typeof raw === 'string' ? raw.trim() : ''; + if (!text) return { index, label: `${spec.fallback} ${index + 1}` }; + const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text; + return { index, label }; + }); +}