feat(project-editor): add field-schema registry + test harness
Milestone 1 of the Configuration Engine sprint. - Add schema/ registry: FieldSchema model, SECTION_FIELD_SCHEMAS covering every editable field per section, and EditorSchemaService (getFields, getField, all, getByPath). Single source of truth for labels, defaults, and validator references; sections stay hand-authored (metadata-augmented). - Stand up Karma + Jasmine (ng test) with a headless, sandbox-free Chrome launcher; add tsconfig.spec.json, karma.conf.js, angular.json test target, and npm "test" script. First spec: editor-schema.service.spec (7 passing). No behavior change. Gate: arch:check, tsc --noEmit, npm test (7/7), build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { EditorSchemaService } from './editor-schema.service';
|
||||
import { ALL_FIELD_SCHEMAS } from './editor-schema';
|
||||
|
||||
describe('EditorSchemaService', () => {
|
||||
let service: EditorSchemaService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new EditorSchemaService();
|
||||
});
|
||||
|
||||
it('returns the fields declared for a section', () => {
|
||||
const themeFields = service.getFields('theme');
|
||||
expect(themeFields.length).toBeGreaterThan(0);
|
||||
expect(themeFields.every(field => field.section === 'theme')).toBeTrue();
|
||||
expect(themeFields.some(field => field.key === 'theme.palette.primary')).toBeTrue();
|
||||
});
|
||||
|
||||
it('returns an empty array for a section with no fields', () => {
|
||||
expect(service.getFields('preview')).toEqual([]);
|
||||
});
|
||||
|
||||
it('looks up a field by dot-path key', () => {
|
||||
const field = service.getField('theme.palette.primary');
|
||||
expect(field).toBeDefined();
|
||||
expect(field?.type).toBe('color');
|
||||
expect(field?.validators?.some(validator => validator.name === 'hexColor')).toBeTrue();
|
||||
});
|
||||
|
||||
it('exposes every field via all()', () => {
|
||||
expect(service.all()).toBe(ALL_FIELD_SCHEMAS);
|
||||
expect(service.all().length).toBe(ALL_FIELD_SCHEMAS.length);
|
||||
});
|
||||
|
||||
it('resolves nested dot-path values against a source object', () => {
|
||||
const source = { theme: { palette: { primary: '#497671' } } };
|
||||
expect(service.getByPath(source, 'theme.palette.primary')).toBe('#497671');
|
||||
});
|
||||
|
||||
it('returns undefined when a dot-path segment is missing instead of throwing', () => {
|
||||
const source = { theme: {} };
|
||||
expect(service.getByPath(source, 'theme.palette.primary')).toBeUndefined();
|
||||
expect(service.getByPath(null, 'theme.palette.primary')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declares no duplicate keys within a single section', () => {
|
||||
for (const field of ALL_FIELD_SCHEMAS) {
|
||||
const sameSection = ALL_FIELD_SCHEMAS.filter(
|
||||
candidate => candidate.section === field.section && candidate.key === field.key,
|
||||
);
|
||||
expect(sameSection.length).withContext(`${field.section}:${field.key}`).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { FieldSchema } from './field-schema.model';
|
||||
import { ALL_FIELD_SCHEMAS, SECTION_FIELD_SCHEMAS } from './editor-schema';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
|
||||
/**
|
||||
* Read-only accessor over the field-schema registry. The single source of
|
||||
* truth for which fields exist, how they are labelled, and which validators
|
||||
* apply. Consumed by the validator engine (issue -> field mapping) and by the
|
||||
* facade (modified-field diffing).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EditorSchemaService {
|
||||
private readonly byKey = new Map<string, FieldSchema>(
|
||||
// First definition wins; a key may appear under two sections (e.g.
|
||||
// localization.supportedLocales in general + languages) - both point at the
|
||||
// same underlying config path, so either schema entry resolves it.
|
||||
ALL_FIELD_SCHEMAS.map((field): [string, FieldSchema] => [field.key, field]).reverse(),
|
||||
);
|
||||
|
||||
/** All fields declared for a section (empty array for unknown sections). */
|
||||
getFields(section: ProjectEditorSectionId): readonly FieldSchema[] {
|
||||
return SECTION_FIELD_SCHEMAS[section] ?? [];
|
||||
}
|
||||
|
||||
/** Lookup a single field by its dot-path key. */
|
||||
getField(key: string): FieldSchema | undefined {
|
||||
return this.byKey.get(key);
|
||||
}
|
||||
|
||||
/** Every field across every section. */
|
||||
all(): readonly FieldSchema[] {
|
||||
return ALL_FIELD_SCHEMAS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dot-path key against an object, e.g.
|
||||
* `getByPath(bootstrap, 'theme.palette.primary')`. Returns `undefined` when
|
||||
* any segment is missing rather than throwing.
|
||||
*/
|
||||
getByPath(source: unknown, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((value, segment) => {
|
||||
if (value === null || value === undefined || typeof value !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
return (value as Record<string, unknown>)[segment];
|
||||
}, source);
|
||||
}
|
||||
}
|
||||
99
src/app/features/project-editor/schema/editor-schema.ts
Normal file
99
src/app/features/project-editor/schema/editor-schema.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { FieldSchema } from './field-schema.model';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
|
||||
const hex = [{ name: 'hexColor' as const }];
|
||||
|
||||
/**
|
||||
* The declarative registry of every editable field, grouped by editor section.
|
||||
* Label/hint keys mirror the ones already used in each section template so the
|
||||
* schema never re-labels anything. Dynamic collections (nav links, payment
|
||||
* icons, homepage widgets) are represented by their container field with type
|
||||
* `list`; their per-row validation is handled by the centralized validators.
|
||||
*/
|
||||
export const SECTION_FIELD_SCHEMAS: Record<ProjectEditorSectionId, readonly FieldSchema[]> = {
|
||||
general: [
|
||||
{ key: 'branding.brandName', section: 'general', type: 'text', labelKey: 'builder.marketplaceName', hintKey: 'builder.marketplaceNameDesc', required: true, validators: [{ name: 'required' }] },
|
||||
{ key: 'tenant.host', section: 'general', type: 'text', labelKey: 'builder.domain', hintKey: 'builder.domainDesc', required: true, validators: [{ name: 'required' }] },
|
||||
{ key: 'seo.default.description', section: 'general', type: 'text', labelKey: 'builder.descriptionLabel', hintKey: 'builder.descriptionFieldDesc' },
|
||||
{ key: 'localization.defaultLocale', section: 'general', type: 'text', labelKey: 'builder.defaultLanguage', hintKey: 'builder.defaultLanguageDesc', required: true },
|
||||
{ key: 'localization.supportedLocales', section: 'general', type: 'list', labelKey: 'builder.supportedLanguages', hintKey: 'builder.supportedLanguagesDesc' },
|
||||
{ key: 'tenant.websiteBaseUrl', section: 'general', type: 'url', labelKey: 'builder.domain', hintKey: 'builder.domainDesc', validators: [{ name: 'url' }] },
|
||||
],
|
||||
branding: [
|
||||
{ key: 'branding.logoUrl', section: 'branding', type: 'text', labelKey: 'builder.logo', hintKey: 'builder.logoDesc', required: true, validators: [{ name: 'required' }] },
|
||||
{ key: 'branding.logoCompactUrl', section: 'branding', type: 'text', labelKey: 'builder.smallLogo', hintKey: 'builder.smallLogoDesc' },
|
||||
{ key: 'branding.faviconUrl', section: 'branding', type: 'text', labelKey: 'builder.favicon', hintKey: 'builder.faviconDesc' },
|
||||
{ key: 'seo.default.title', section: 'branding', type: 'text', labelKey: 'builder.marketplaceTitle', hintKey: 'builder.marketplaceTitleDesc' },
|
||||
],
|
||||
theme: [
|
||||
{ key: 'theme.palette.primary', section: 'theme', type: 'color', labelKey: 'builder.primaryColor', hintKey: 'builder.primaryColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.secondary', section: 'theme', type: 'color', labelKey: 'builder.secondaryColor', hintKey: 'builder.secondaryColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.accent', section: 'theme', type: 'color', labelKey: 'builder.primaryColor', validators: hex },
|
||||
{ key: 'theme.palette.backgroundPrimary', section: 'theme', type: 'color', labelKey: 'builder.backgroundColor', hintKey: 'builder.backgroundColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.backgroundSecondary', section: 'theme', type: 'color', labelKey: 'builder.surfaceColor', hintKey: 'builder.surfaceColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.textPrimary', section: 'theme', type: 'color', labelKey: 'builder.textColor', hintKey: 'builder.textColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.textSecondary', section: 'theme', type: 'color', labelKey: 'builder.textColor', validators: hex },
|
||||
{ key: 'theme.palette.border', section: 'theme', type: 'color', labelKey: 'builder.surfaceColor', validators: hex },
|
||||
{ key: 'theme.palette.info', section: 'theme', type: 'color', labelKey: 'builder.primaryColor', validators: hex },
|
||||
{ key: 'theme.palette.success', section: 'theme', type: 'color', labelKey: 'builder.successColor', hintKey: 'builder.successColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.warning', section: 'theme', type: 'color', labelKey: 'builder.warningColor', hintKey: 'builder.warningColorDesc', validators: hex },
|
||||
{ key: 'theme.palette.danger', section: 'theme', type: 'color', labelKey: 'builder.dangerColor', hintKey: 'builder.dangerColorDesc', validators: hex },
|
||||
{ key: 'theme.mode', section: 'theme', type: 'select', labelKey: 'builder.themeModeLabel', hintKey: 'builder.themeModeDesc' },
|
||||
{ key: 'layout.type', section: 'theme', type: 'select', labelKey: 'builder.siteLayoutLabel', hintKey: 'builder.siteLayoutDesc' },
|
||||
],
|
||||
header: [
|
||||
{ key: 'header.showLogo', section: 'header', type: 'toggle', labelKey: 'builder.showLogo', hintKey: 'builder.showLogoDesc' },
|
||||
{ key: 'header.showSearch', section: 'header', type: 'toggle', labelKey: 'builder.showSearch', hintKey: 'builder.showSearchDesc' },
|
||||
{ key: 'header.showCategories', section: 'header', type: 'toggle', labelKey: 'builder.showCategories', hintKey: 'builder.showCategoriesDesc' },
|
||||
{ key: 'header.showLanguages', section: 'header', type: 'toggle', labelKey: 'builder.showLanguages', hintKey: 'builder.showLanguagesDesc' },
|
||||
{ key: 'header.showCart', section: 'header', type: 'toggle', labelKey: 'builder.showCart', hintKey: 'builder.showCartDesc' },
|
||||
{ key: 'header.showProfile', section: 'header', type: 'toggle', labelKey: 'builder.showProfile', hintKey: 'builder.showProfileDesc' },
|
||||
{ key: 'header.showWishlist', section: 'header', type: 'toggle', labelKey: 'builder.showWishlist', hintKey: 'builder.showWishlistDesc' },
|
||||
{ key: 'header.showCompare', section: 'header', type: 'toggle', labelKey: 'builder.showCompare', hintKey: 'builder.showCompareDesc' },
|
||||
{ key: 'header.showRegion', section: 'header', type: 'toggle', labelKey: 'builder.showRegion', hintKey: 'builder.showRegionDesc' },
|
||||
],
|
||||
footer: [
|
||||
{ key: 'company.companyName', section: 'footer', type: 'text', labelKey: 'builder.companyName', hintKey: 'builder.companyNameDesc' },
|
||||
{ key: 'company.address.street', section: 'footer', type: 'text', labelKey: 'builder.address', hintKey: 'builder.addressDesc' },
|
||||
{ key: 'company.contacts.phone', section: 'footer', type: 'text', labelKey: 'builder.phone', hintKey: 'builder.phoneDesc' },
|
||||
{ key: 'company.contacts.email', section: 'footer', type: 'email', labelKey: 'builder.email', hintKey: 'builder.emailDesc', validators: [{ name: 'email' }] },
|
||||
{ key: 'footer.copyright', section: 'footer', type: 'text', labelKey: 'builder.copyright', hintKey: 'builder.copyrightDesc' },
|
||||
{ key: 'footer.logoUrl', section: 'footer', type: 'text', labelKey: 'builder.footerLogo', hintKey: 'builder.footerLogoDesc' },
|
||||
{ key: 'footer.paymentIcons', section: 'footer', type: 'list', labelKey: 'builder.paymentIcons', hintKey: 'builder.paymentIconsDesc' },
|
||||
{ key: 'footer.socialLinks', section: 'footer', type: 'list', labelKey: 'builder.socialLinks', hintKey: 'builder.socialLinksDesc', validators: [{ name: 'url' }] },
|
||||
{ key: 'footer.staticPages', section: 'footer', type: 'list', labelKey: 'builder.staticPages', hintKey: 'builder.staticPagesFieldDesc' },
|
||||
],
|
||||
homepage: [
|
||||
{ key: 'pages', section: 'homepage', type: 'list', labelKey: 'builder.homepage', validators: [{ name: 'duplicateRoutes' }] },
|
||||
],
|
||||
widgets: [
|
||||
{ key: 'pages', section: 'widgets', type: 'list', labelKey: 'builder.widgets', validators: [{ name: 'widgetConfig' }] },
|
||||
],
|
||||
'static-pages': [
|
||||
{ key: 'staticPages', section: 'static-pages', type: 'list', labelKey: 'builder.staticPages', validators: [{ name: 'duplicateRoutes' }] },
|
||||
],
|
||||
features: [
|
||||
{ key: 'featureFlags.wishlist', section: 'features', type: 'toggle', labelKey: 'builder.showWishlist', hintKey: 'builder.wishlistFeatureDesc' },
|
||||
{ key: 'featureFlags.compare', section: 'features', type: 'toggle', labelKey: 'builder.showCompare', hintKey: 'builder.compareFeatureDesc' },
|
||||
{ key: 'featureFlags.reviews', section: 'features', type: 'toggle', labelKey: 'builder.reviews', hintKey: 'builder.reviewsFeatureDesc' },
|
||||
{ key: 'featureFlags.comments', section: 'features', type: 'toggle', labelKey: 'builder.comments', hintKey: 'builder.commentsFeatureDesc' },
|
||||
{ key: 'featureFlags.recommendations', section: 'features', type: 'toggle', labelKey: 'builder.recommendations', hintKey: 'builder.recommendationsFeatureDesc' },
|
||||
{ key: 'productPage.questions.enabled', section: 'features', type: 'toggle', labelKey: 'builder.questions', hintKey: 'builder.questionsFeatureDesc' },
|
||||
{ key: 'userExperience.recentlyViewed.enabled', section: 'features', type: 'toggle', labelKey: 'builder.recentlyViewed', hintKey: 'builder.recentlyViewedFeatureDesc' },
|
||||
{ key: 'catalog.suggestionsEnabled', section: 'features', type: 'toggle', labelKey: 'builder.searchSuggestions', hintKey: 'builder.searchSuggestionsDesc' },
|
||||
{ key: 'catalog.searchHistoryEnabled', section: 'features', type: 'toggle', labelKey: 'builder.searchHistory', hintKey: 'builder.searchHistoryDesc' },
|
||||
{ key: 'catalog.navigationMode', section: 'features', type: 'select', labelKey: 'builder.catalogNavigationModeLabel', hintKey: 'builder.catalogNavigationModeDesc' },
|
||||
],
|
||||
languages: [
|
||||
{ key: 'localization.supportedLocales', section: 'languages', type: 'list', labelKey: 'builder.languagesTab', hintKey: 'builder.languagesTabDesc', validators: [{ name: 'localeCompleteness' }] },
|
||||
{ key: 'localization.defaultLocale', section: 'languages', type: 'text', labelKey: 'builder.defaultLanguageLabel', required: true },
|
||||
],
|
||||
navigation: [
|
||||
{ key: 'navigation.header', section: 'navigation', type: 'list', labelKey: 'builder.header', validators: [{ name: 'duplicateRoutes' }] },
|
||||
{ key: 'navigation.footer', section: 'navigation', type: 'list', labelKey: 'builder.footer' },
|
||||
],
|
||||
preview: [],
|
||||
};
|
||||
|
||||
/** Flat list of every field across all sections. */
|
||||
export const ALL_FIELD_SCHEMAS: readonly FieldSchema[] = Object.values(SECTION_FIELD_SCHEMAS).flat();
|
||||
57
src/app/features/project-editor/schema/field-schema.model.ts
Normal file
57
src/app/features/project-editor/schema/field-schema.model.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
|
||||
/**
|
||||
* Names of the reusable, centralized validators (implemented under
|
||||
* `schema/validators/`). A field references validators by name so the schema
|
||||
* stays declarative and no validation logic is duplicated per field.
|
||||
*/
|
||||
export type ValidatorName =
|
||||
| 'required'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'hexColor'
|
||||
| 'json'
|
||||
| 'css'
|
||||
| 'localeCompleteness'
|
||||
| 'duplicateRoutes'
|
||||
| 'widgetConfig';
|
||||
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'color'
|
||||
| 'select'
|
||||
| 'number'
|
||||
| 'toggle'
|
||||
| 'json'
|
||||
| 'css'
|
||||
| 'html'
|
||||
| 'locale-map'
|
||||
| 'list';
|
||||
|
||||
export interface FieldValidatorRef {
|
||||
readonly name: ValidatorName;
|
||||
readonly params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative description of one editable field in the Project Editor.
|
||||
* The schema is the single source of truth that drives validation, inline
|
||||
* error mapping, labels and defaults. Section templates stay hand-authored
|
||||
* (metadata-augmented approach) and bind against these keys.
|
||||
*/
|
||||
export interface FieldSchema {
|
||||
/** Dot path into `BootstrapConfig`, e.g. `theme.palette.primary`. Unique across all sections. */
|
||||
readonly key: string;
|
||||
/** Editor section the field belongs to (drives per-section grouping/badges). */
|
||||
readonly section: ProjectEditorSectionId;
|
||||
readonly type: FieldType;
|
||||
/** i18n key for the field label. */
|
||||
readonly labelKey: string;
|
||||
/** i18n key for the field hint/description. */
|
||||
readonly hintKey?: string;
|
||||
readonly default?: unknown;
|
||||
readonly required?: boolean;
|
||||
readonly validators?: readonly FieldValidatorRef[];
|
||||
}
|
||||
Reference in New Issue
Block a user