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:
223
tests/feature-grid-regression.test.ts
Normal file
223
tests/feature-grid-regression.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Feature Grid Regression Tests
|
||||
* Tests to prevent regression of items/features naming and default values
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SECTION_SCHEMAS,
|
||||
cloneDefaultProps,
|
||||
normalizeFeatureGridProps,
|
||||
} from '../../admin-spa/src/routes/Appearance/Pages/schema/sectionSchema';
|
||||
|
||||
describe('Feature Grid Regression Tests', () => {
|
||||
describe('Items vs Features Naming', () => {
|
||||
it('schema uses items not features', () => {
|
||||
const schema = SECTION_SCHEMAS['feature-grid'];
|
||||
expect(schema.defaultProps).toHaveProperty('items');
|
||||
expect(schema.defaultProps).not.toHaveProperty('features');
|
||||
});
|
||||
|
||||
it('items default value is empty array', () => {
|
||||
const props = cloneDefaultProps('feature-grid');
|
||||
expect(Array.isArray(props.items.value)).toBe(true);
|
||||
expect(props.items.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('normalizeFeatureGridProps uses items not features', () => {
|
||||
const legacyProps = {
|
||||
features: [{ title: 'Legacy Feature' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(legacyProps);
|
||||
|
||||
expect(normalized).toHaveProperty('items');
|
||||
expect(normalized.items).toHaveLength(1);
|
||||
expect(normalized.items[0].title).toBe('Legacy Feature');
|
||||
});
|
||||
|
||||
it('normalizeFeatureGridProps keeps items when present', () => {
|
||||
const currentProps = {
|
||||
items: [{ title: 'Current Feature' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(currentProps);
|
||||
|
||||
expect(normalized.items).toHaveLength(1);
|
||||
expect(normalized.items[0].title).toBe('Current Feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Value Type', () => {
|
||||
it('items is always an array, not empty string', () => {
|
||||
const props = cloneDefaultProps('feature-grid');
|
||||
|
||||
// Should be array, not empty string
|
||||
expect(props.items.value).toEqual([]);
|
||||
expect(typeof props.items.value).toBe('object');
|
||||
});
|
||||
|
||||
it('normalizeFeatureGridProps handles empty features string', () => {
|
||||
const legacyProps = {
|
||||
features: '', // Legacy empty string
|
||||
};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(legacyProps);
|
||||
|
||||
expect(Array.isArray(normalized.items)).toBe(true);
|
||||
expect(normalized.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('normalizeFeatureGridProps handles missing features', () => {
|
||||
const noFeaturesProps = {};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(noFeaturesProps);
|
||||
|
||||
expect(normalized.items).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Grid Component Props Contract', () => {
|
||||
// These tests verify the contract between schema and component
|
||||
it('schema items prop structure matches component expectation', () => {
|
||||
const props = cloneDefaultProps('feature-grid');
|
||||
|
||||
// Component expects: items = array of FeatureItem
|
||||
expect(props.items.type).toBe('static');
|
||||
expect(Array.isArray(props.items.value)).toBe(true);
|
||||
});
|
||||
|
||||
it('feature item structure supports title, description, icon', () => {
|
||||
const schema = SECTION_SCHEMAS['feature-grid'];
|
||||
|
||||
// The schema doesn't restrict the item structure directly
|
||||
// but the component expects these fields
|
||||
expect(schema.defaultProps.items.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('heading is static by default', () => {
|
||||
const props = cloneDefaultProps('feature-grid');
|
||||
|
||||
expect(props.heading.type).toBe('static');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Source Handling', () => {
|
||||
it('feature-grid items can be dynamic', () => {
|
||||
// Verify the schema structure supports dynamic items
|
||||
// The actual component handles both static arrays and dynamic sources
|
||||
const dynamicItems = {
|
||||
items: { type: 'dynamic', source: 'related_posts' },
|
||||
};
|
||||
|
||||
expect(dynamicItems.items.type).toBe('dynamic');
|
||||
expect(dynamicItems.items.source).toBe('related_posts');
|
||||
});
|
||||
|
||||
it('normalizeFeatureGridProps preserves dynamic items', () => {
|
||||
const dynamicProps = {
|
||||
items: { type: 'dynamic', source: 'related_posts' },
|
||||
};
|
||||
|
||||
const normalized = normalizeFeatureGridProps(dynamicProps);
|
||||
|
||||
expect(normalized.items.type).toBe('dynamic');
|
||||
expect(normalized.items.source).toBe('related_posts');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Style Keys Regression Tests', () => {
|
||||
describe('Section Styles Normalization', () => {
|
||||
it('contentWidth is the canonical key', () => {
|
||||
const styles = {
|
||||
contentWidth: 'full',
|
||||
};
|
||||
|
||||
// Should NOT have container_width
|
||||
expect(styles).not.toHaveProperty('container_width');
|
||||
expect(styles.contentWidth).toBe('full');
|
||||
});
|
||||
|
||||
it('backgroundType is required', () => {
|
||||
const styles = {
|
||||
backgroundType: 'gradient',
|
||||
gradientFrom: '#9333ea',
|
||||
gradientTo: '#3b82f6',
|
||||
};
|
||||
|
||||
expect(styles.backgroundType).toBe('gradient');
|
||||
});
|
||||
|
||||
it('heightPreset replaces old height key', () => {
|
||||
const styles = {
|
||||
heightPreset: 'fullscreen',
|
||||
};
|
||||
|
||||
// Should NOT have legacy 'height' key
|
||||
expect(styles).not.toHaveProperty('height');
|
||||
expect(styles.heightPreset).toBe('fullscreen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Element Styles Keys', () => {
|
||||
it('cta_text is the canonical button key', () => {
|
||||
const elementStyles = {
|
||||
cta_text: { color: '#fff' },
|
||||
};
|
||||
|
||||
// Should NOT use 'cta' or 'button' aliases
|
||||
expect(elementStyles).not.toHaveProperty('cta');
|
||||
expect(elementStyles).toHaveProperty('cta_text');
|
||||
});
|
||||
|
||||
it('heading element key is consistent', () => {
|
||||
const elementStyles = {
|
||||
heading: { fontSize: '2rem' },
|
||||
};
|
||||
|
||||
expect(elementStyles.heading).toBeDefined();
|
||||
});
|
||||
|
||||
it('feature_item is the canonical card key', () => {
|
||||
const elementStyles = {
|
||||
feature_item: { backgroundColor: '#fff' },
|
||||
};
|
||||
|
||||
expect(elementStyles).toHaveProperty('feature_item');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Repeater Arrays Regression', () => {
|
||||
describe('Related Posts Array Structure', () => {
|
||||
it('related_posts resolves to array of post objects', () => {
|
||||
const resolvedRelatedPosts = [
|
||||
{ id: 1, title: 'Post 1', url: '/post-1', featured_image: '/img1.jpg', excerpt: 'Excerpt 1' },
|
||||
{ id: 2, title: 'Post 2', url: '/post-2', featured_image: '/img2.jpg', excerpt: 'Excerpt 2' },
|
||||
];
|
||||
|
||||
// Component should render these as post cards
|
||||
expect(Array.isArray(resolvedRelatedPosts)).toBe(true);
|
||||
expect(resolvedRelatedPosts[0]).toHaveProperty('url');
|
||||
expect(resolvedRelatedPosts[0]).toHaveProperty('title');
|
||||
});
|
||||
|
||||
it('empty related_posts shows placeholder', () => {
|
||||
const emptyRelatedPosts: any[] = [];
|
||||
|
||||
// Component should show empty state message
|
||||
expect(emptyRelatedPosts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Features Array', () => {
|
||||
it('features can be dynamic source', () => {
|
||||
const dynamicFeatures = {
|
||||
features: { type: 'dynamic', source: 'product_categories' },
|
||||
};
|
||||
|
||||
expect(dynamicFeatures.features.type).toBe('dynamic');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user