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:
2026-08-09 06:59:23 -07:00
co-authored by Claude Opus 5
parent 38c8d22e5d
commit eb4290a45e
2 changed files with 128 additions and 0 deletions
@@ -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();
}
});
});