feat: Page Editor v1.0 - canonical schema, SSR parity, and migration
Major improvements to WooNooW Page Editor system: Schema & Architecture: - Canonical section schema with unified sectionSchema.ts - Normalized feature-grid to use items (not features) - Standardized default values across all section types - Schema versioning with automatic migration on read Backend (PHP): - Enhanced PlaceholderRenderer with typed output contracts - Added fallback behavior for empty/invalid dynamic sources - Added caching support for post data resolution - New SchemaMigration class for backward compatibility - New Features class for feature flags - Enhanced PageSSR with full style support - Removed controller-level special-casing for related_posts Frontend (Admin SPA): - Updated CanvasRenderer with schema-aware transformation - Enhanced InspectorPanel with canonical schema metadata - Added new section renderers Frontend (Customer SPA): - New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage - Updated FeatureGridSection for items prop contract Testing: - Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest - Add TypeScript tests: schema-integration, feature-grid-regression - Add parity tests for React vs SSR content matching - Add CI script: check-schema-drift.mjs - Add VERIFICATION_CHECKLIST.md Documentation: - RELEASE_NOTES-v1.0.md with full release notes - docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md - docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
279
tests/schema-integration.test.ts
Normal file
279
tests/schema-integration.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Page Editor Schema Tests
|
||||
* Tests for canonical section schema and normalization
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
SECTION_SCHEMAS,
|
||||
getSectionSchema,
|
||||
cloneDefaultProps,
|
||||
cloneDefaultStyles,
|
||||
normalizeFeatureGridProps,
|
||||
} from '../../admin-spa/src/routes/Appearance/Pages/schema/sectionSchema';
|
||||
|
||||
describe('Section Schema', () => {
|
||||
describe('SECTION_SCHEMAS', () => {
|
||||
it('includes all required section types', () => {
|
||||
const expectedTypes = [
|
||||
'hero',
|
||||
'content',
|
||||
'image-text',
|
||||
'feature-grid',
|
||||
'cta-banner',
|
||||
'contact-form',
|
||||
'bento-category-grid',
|
||||
'product-carousel',
|
||||
'shoppable-image',
|
||||
'marquee-banner',
|
||||
];
|
||||
|
||||
expectedTypes.forEach((type) => {
|
||||
expect(SECTION_SCHEMAS).toHaveProperty(type);
|
||||
});
|
||||
});
|
||||
|
||||
it('feature-grid uses items not features', () => {
|
||||
const featureGrid = SECTION_SCHEMAS['feature-grid'];
|
||||
expect(featureGrid.defaultProps).toHaveProperty('items');
|
||||
expect(featureGrid.defaultProps.items.type).toBe('static');
|
||||
expect(Array.isArray(featureGrid.defaultProps.items.value)).toBe(true);
|
||||
expect(featureGrid.defaultProps.items.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hero section has correct default props', () => {
|
||||
const hero = SECTION_SCHEMAS['hero'];
|
||||
expect(hero.defaultProps).toHaveProperty('title');
|
||||
expect(hero.defaultProps).toHaveProperty('subtitle');
|
||||
expect(hero.defaultProps).toHaveProperty('image');
|
||||
expect(hero.defaultProps).toHaveProperty('cta_text');
|
||||
expect(hero.defaultProps).toHaveProperty('cta_url');
|
||||
});
|
||||
|
||||
it('all sections have defaultStyles with contentWidth', () => {
|
||||
Object.values(SECTION_SCHEMAS).forEach((schema) => {
|
||||
expect(schema.defaultStyles).toBeDefined();
|
||||
expect(schema.defaultStyles).toHaveProperty('contentWidth');
|
||||
});
|
||||
});
|
||||
|
||||
it('all sections have fields defined', () => {
|
||||
Object.values(SECTION_SCHEMAS).forEach((schema) => {
|
||||
expect(Array.isArray(schema.fields)).toBe(true);
|
||||
expect(schema.fields.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSectionSchema', () => {
|
||||
it('returns schema for valid section type', () => {
|
||||
const schema = getSectionSchema('hero');
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema?.type).toBe('hero');
|
||||
});
|
||||
|
||||
it('returns undefined for invalid section type', () => {
|
||||
const schema = getSectionSchema('nonexistent');
|
||||
expect(schema).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneDefaultProps', () => {
|
||||
it('returns props object for valid type', () => {
|
||||
const props = cloneDefaultProps('hero');
|
||||
expect(props).toHaveProperty('title');
|
||||
expect(props).toHaveProperty('subtitle');
|
||||
});
|
||||
|
||||
it('returns empty object for invalid type', () => {
|
||||
const props = cloneDefaultProps('nonexistent');
|
||||
expect(Object.keys(props)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clones arrays correctly (no reference sharing)', () => {
|
||||
const props1 = cloneDefaultProps('feature-grid');
|
||||
const props2 = cloneDefaultProps('feature-grid');
|
||||
|
||||
props1.items.value = [{ title: 'Test' }];
|
||||
expect(props2.items.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloneDefaultStyles', () => {
|
||||
it('returns default styles for valid type', () => {
|
||||
const styles = cloneDefaultStyles('hero');
|
||||
expect(styles).toHaveProperty('contentWidth');
|
||||
});
|
||||
|
||||
it('returns undefined for invalid type', () => {
|
||||
const styles = cloneDefaultStyles('nonexistent');
|
||||
expect(styles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFeatureGridProps', () => {
|
||||
it('leaves props unchanged when items exists', () => {
|
||||
const props = {
|
||||
items: [{ title: 'Feature 1' }],
|
||||
heading: 'Our Features',
|
||||
};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(props);
|
||||
|
||||
expect(normalized.items).toHaveLength(1);
|
||||
expect(normalized.items[0].title).toBe('Feature 1');
|
||||
});
|
||||
|
||||
it('copies features to items when items is undefined', () => {
|
||||
const props = {
|
||||
features: [{ title: 'Legacy Feature' }],
|
||||
heading: 'Our Features',
|
||||
};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(props);
|
||||
|
||||
expect(normalized.items).toHaveLength(1);
|
||||
expect(normalized.items[0].title).toBe('Legacy Feature');
|
||||
});
|
||||
|
||||
it('handles empty props', () => {
|
||||
const props = {};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(props);
|
||||
|
||||
expect(normalized).toEqual({});
|
||||
});
|
||||
|
||||
it('handles null/undefined input gracefully', () => {
|
||||
expect(normalizeFeatureGridProps(null)).toBeNull();
|
||||
expect(normalizeFeatureGridProps(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Editor Store Schema Integration', () => {
|
||||
// Test that the store uses the schema correctly
|
||||
it('section schema is compatible with store SectionProp interface', () => {
|
||||
const props = cloneDefaultProps('hero');
|
||||
|
||||
// Check SectionProp structure
|
||||
expect(props.title).toHaveProperty('type');
|
||||
expect(props.title).toHaveProperty('value');
|
||||
|
||||
// Ensure type is valid
|
||||
expect(props.title.type).toMatch(/^(static|dynamic)$/);
|
||||
});
|
||||
|
||||
it('feature-grid default items is an empty array', () => {
|
||||
const props = cloneDefaultProps('feature-grid');
|
||||
|
||||
expect(Array.isArray(props.items.value)).toBe(true);
|
||||
expect(props.items.value).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canvas Renderer Schema Integration', () => {
|
||||
// Test flattenSectionProps behavior with schema
|
||||
const flattenSectionProps = (section: any) => {
|
||||
const props = section.type === 'feature-grid'
|
||||
? normalizeFeatureGridProps(section.props || {})
|
||||
: section.props || {};
|
||||
|
||||
const flattened: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
|
||||
flattened[key] = value.value;
|
||||
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
|
||||
flattened[key] = `[${value.source}]`;
|
||||
} else {
|
||||
flattened[key] = value;
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
};
|
||||
|
||||
it('flattens static props correctly', () => {
|
||||
const section = {
|
||||
type: 'hero',
|
||||
props: {
|
||||
title: { type: 'static', value: 'Welcome' },
|
||||
subtitle: { type: 'static', value: 'Subtitle text' },
|
||||
},
|
||||
};
|
||||
|
||||
const flattened = flattenSectionProps(section);
|
||||
|
||||
expect(flattened.title).toBe('Welcome');
|
||||
expect(flattened.subtitle).toBe('Subtitle text');
|
||||
});
|
||||
|
||||
it('flattens dynamic props with placeholder marker', () => {
|
||||
const section = {
|
||||
type: 'hero',
|
||||
props: {
|
||||
title: { type: 'dynamic', source: 'post_title' },
|
||||
},
|
||||
};
|
||||
|
||||
const flattened = flattenSectionProps(section);
|
||||
|
||||
expect(flattened.title).toBe('[post_title]');
|
||||
});
|
||||
|
||||
it('flattens feature-grid items correctly', () => {
|
||||
const section = {
|
||||
type: 'feature-grid',
|
||||
props: {
|
||||
heading: { type: 'static', value: 'Features' },
|
||||
items: {
|
||||
type: 'static',
|
||||
value: [
|
||||
{ title: 'Feature 1', icon: 'Star' },
|
||||
{ title: 'Feature 2', icon: 'Heart' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const flattened = flattenSectionProps(section);
|
||||
|
||||
expect(flattened.heading).toBe('Features');
|
||||
expect(Array.isArray(flattened.items)).toBe(true);
|
||||
expect(flattened.items).toHaveLength(2);
|
||||
expect(flattened.items[0].title).toBe('Feature 1');
|
||||
});
|
||||
|
||||
it('handles mixed static and dynamic props', () => {
|
||||
const section = {
|
||||
type: 'image-text',
|
||||
props: {
|
||||
title: { type: 'dynamic', source: 'post_title' },
|
||||
text: { type: 'static', value: 'Static description' },
|
||||
image: { type: 'dynamic', source: 'post_featured_image' },
|
||||
},
|
||||
};
|
||||
|
||||
const flattened = flattenSectionProps(section);
|
||||
|
||||
expect(flattened.title).toBe('[post_title]');
|
||||
expect(flattened.text).toBe('Static description');
|
||||
expect(flattened.image).toBe('[post_featured_image]');
|
||||
});
|
||||
|
||||
it('handles nested props without SectionProp wrapper', () => {
|
||||
const section = {
|
||||
type: 'feature-grid',
|
||||
props: {
|
||||
items: [
|
||||
{ title: 'Direct Item 1' },
|
||||
{ title: 'Direct Item 2' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const flattened = flattenSectionProps(section);
|
||||
|
||||
expect(Array.isArray(flattened.items)).toBe(true);
|
||||
expect(flattened.items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user