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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 "<Fallback> 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<Element canvas>`
|
||||||
|
* 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 "<fallback> <n>" 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<string, VirtualChildSpec> = {
|
||||||
|
'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<string, any>): 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user