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:
sdarbinyan
2026-07-17 01:51:13 +04:00
parent 54725c624e
commit 3bfe820443
9 changed files with 426 additions and 230 deletions

View File

@@ -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);
}
});
});

View File

@@ -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);
}
}

View 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();

View 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[];
}