feat(project-editor): centralize schema-driven validation

Milestone 2 of the Configuration Engine sprint.

- Add schema/validators/primitives: pure isValidHexColor/HttpUrl/Email,
  validateJson, validateCss, extractStyleBlocks, normalizeRoute. One function
  per concern, no duplicated validator logic.
- Refactor ProjectValidator to compose the primitives and tag every issue with
  section + fieldKey + severity ('error' blocks publish, 'warning' advisory).
  Preserves all existing codes/messages; adds duplicate-routes, invalid-css,
  invalid-widget-config checks.
- Facade: issuesByField, issuesBySection, blockingIssues, hasBlockingIssues;
  publish() now gates on severity==='error' instead of any issue.
- i18n: add validationInvalidJson/Css/DuplicateRoutes/InvalidWidgetConfig to
  the Translations interface + en/ru/hy.
- Specs: primitives + ProjectValidator (25 passing total).

Gate: tsc --noEmit, npm test (25/25), arch:check, build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-17 01:58:36 +04:00
parent 3bfe820443
commit 7b8382131d
9 changed files with 439 additions and 15 deletions

View File

@@ -8,7 +8,8 @@ import { ProjectEditorPreviewService } from '../services/project-editor-preview.
import { LocaleSyncService } from '../services/locale-sync.service';
import { ProjectEditorState } from '../models/project-editor.model';
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
import { ProjectValidator } from '../services/project-validator.service';
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
@@ -47,6 +48,36 @@ export class ProjectEditorFacade {
const current = this.bootstrap();
return current ? this.validator.validate(current) : [];
});
/** Issues that block publishing (`severity === 'error'`), in declaration order. */
readonly blockingIssues = computed(() => this.validationIssues().filter(issue => issue.severity === 'error'));
readonly hasBlockingIssues = computed(() => this.blockingIssues().length > 0);
/** Issues grouped by the schema field key they map to, for inline field errors. */
readonly issuesByField = computed<Map<string, ProjectValidationIssue[]>>(() => {
const map = new Map<string, ProjectValidationIssue[]>();
for (const issue of this.validationIssues()) {
if (!issue.fieldKey) {
continue;
}
const bucket = map.get(issue.fieldKey);
if (bucket) {
bucket.push(issue);
} else {
map.set(issue.fieldKey, [issue]);
}
}
return map;
});
/** Blocking-issue count per editor section, for nav badges. */
readonly issuesBySection = computed<Map<ProjectEditorSectionId, number>>(() => {
const map = new Map<ProjectEditorSectionId, number>();
for (const issue of this.blockingIssues()) {
if (!issue.section) {
continue;
}
map.set(issue.section, (map.get(issue.section) ?? 0) + 1);
}
return map;
});
readonly dirty = computed(() => {
const current = this.bootstrap();
if (!current) {
@@ -173,7 +204,7 @@ export class ProjectEditorFacade {
publish(): boolean {
const current = this.state().bootstrap;
if (!current || this.validationIssues().length > 0) {
if (!current || this.hasBlockingIssues()) {
return false;
}
this.runtime.reloadFromBootstrap(current);