From a7bab6be529b3b60ecff3f189c8df6329e1e7b05 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 17 Jul 2026 02:07:14 +0400 Subject: [PATCH] feat(project-editor): live inline validation and publish gating Milestone 3 of the Configuration Engine sprint. - Facade fieldError(key) accessor over issuesByField for inline field errors. - Bind [error] on schema-backed fields: theme palette colours, general name/domain, branding logo (translated via each section). - project-editor-nav: per-section blocking-issue count badge (issuesBySection). - save-bar: Publish now disabled on hasBlockingIssues() (errors only, so new warnings no longer block); issue list tags warning vs error severity. Editor verified rendering at /ru/edit/theme with the new nav + save bar; validation logic covered by the 25 unit tests. Gate: tsc --noEmit, npm test (25/25), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 --- .../project-editor-nav.component.html | 2 +- .../project-editor-nav.component.scss | 22 +++++++++++++++++++ .../project-editor-nav.component.ts | 11 +++++++++- .../project-editor-save-bar.component.html | 4 ++-- .../project-editor-save-bar.component.scss | 8 +++++++ .../project-editor-save-bar.component.ts | 1 + .../facade/project-editor.facade.ts | 6 +++++ .../sections/branding-section.component.html | 2 +- .../sections/branding-section.component.ts | 6 +++++ .../sections/general-section.component.html | 4 ++-- .../sections/general-section.component.ts | 6 +++++ .../sections/theme-section.component.html | 16 +++++++------- .../sections/theme-section.component.ts | 4 ++++ 13 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/app/features/project-editor/components/project-editor-nav.component.html b/src/app/features/project-editor/components/project-editor-nav.component.html index 5cc877b..73380bb 100644 --- a/src/app/features/project-editor/components/project-editor-nav.component.html +++ b/src/app/features/project-editor/components/project-editor-nav.component.html @@ -5,6 +5,6 @@ routerLinkActive="active" [ariaCurrentWhenActive]="'page'" class="editor-nav-link" - >{{ section.label | translate }} + >{{ section.label | translate }}@if (issueCount(section.id); as count) {{{ count }}} } diff --git a/src/app/features/project-editor/components/project-editor-nav.component.scss b/src/app/features/project-editor/components/project-editor-nav.component.scss index 0b00703..4a8bf54 100644 --- a/src/app/features/project-editor/components/project-editor-nav.component.scss +++ b/src/app/features/project-editor/components/project-editor-nav.component.scss @@ -36,6 +36,28 @@ } } +.editor-nav-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + margin-left: 6px; + padding: 0 5px; + border-radius: 999px; + background: var(--error-color, #ef4444); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + vertical-align: middle; +} + +.editor-nav-link.active .editor-nav-badge { + background: #fff; + color: var(--error-color, #ef4444); +} + @media (prefers-reduced-motion: reduce) { .editor-nav-link { transition: none; diff --git a/src/app/features/project-editor/components/project-editor-nav.component.ts b/src/app/features/project-editor/components/project-editor-nav.component.ts index dde2de3..1fa6c09 100644 --- a/src/app/features/project-editor/components/project-editor-nav.component.ts +++ b/src/app/features/project-editor/components/project-editor-nav.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslatePipe } from '../../../i18n/translate.pipe'; import { LangRoutePipe } from '../../../pipes/lang-route.pipe'; import { ProjectEditorSectionId } from '../models/project-editor.model'; +import { ProjectEditorFacade } from '../facade/project-editor.facade'; @Component({ selector: 'app-project-editor-nav', @@ -13,6 +14,14 @@ import { ProjectEditorSectionId } from '../models/project-editor.model'; changeDetection: ChangeDetectionStrategy.OnPush }) export class ProjectEditorNavComponent { + private readonly facade = inject(ProjectEditorFacade); + private readonly issuesBySection = this.facade.issuesBySection; + + /** Count of blocking issues in a section, for the nav badge. */ + issueCount(sectionId: ProjectEditorSectionId): number { + return this.issuesBySection().get(sectionId) ?? 0; + } + readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [ { id: 'general', label: 'builder.general' }, { id: 'branding', label: 'builder.branding' }, diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html index e0b45ec..6278f49 100644 --- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html +++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html @@ -15,7 +15,7 @@ @if (issues().length > 0) { } @@ -23,6 +23,6 @@
{{ 'builder.resetDraft' | translate }} {{ 'builder.save' | translate }} - {{ 'builder.publish' | translate }} + {{ 'builder.publish' | translate }}
diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss index c00ed90..1850341 100644 --- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss +++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss @@ -20,6 +20,14 @@ margin: 0.25rem 0 0; padding-left: 1.25rem; color: var(--danger, #b91c1c); + + li.is-warning { + color: var(--warning-color, #b45309); + } + + li.is-error { + color: var(--error-color, #b91c1c); + } } .project-editor-save-bar-actions { diff --git a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts index 0f8e156..5033adf 100644 --- a/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts +++ b/src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts @@ -18,6 +18,7 @@ export class ProjectEditorSaveBarComponent { readonly dirty = this.facade.dirty; readonly status = this.facade.status; readonly issues = this.facade.validationIssues; + readonly hasBlockingIssues = this.facade.hasBlockingIssues; readonly lastSavedAt = this.facade.lastSavedAt; readonly draftRestored = this.facade.draftRestored; diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts index b9bab98..f3fdac3 100644 --- a/src/app/features/project-editor/facade/project-editor.facade.ts +++ b/src/app/features/project-editor/facade/project-editor.facade.ts @@ -119,6 +119,12 @@ export class ProjectEditorFacade { this.draftStorage.save(next); } + /** i18n message key of the first issue mapped to a field, or null. Reactive: reads `issuesByField`. */ + fieldError(fieldKey: string): string | null { + const issues = this.issuesByField().get(fieldKey); + return issues && issues.length > 0 ? issues[0].message : null; + } + dismissDraftRestoredNotice(): void { this.state.update(current => ({ ...current, draftRestored: false })); } diff --git a/src/app/features/project-editor/sections/branding-section.component.html b/src/app/features/project-editor/sections/branding-section.component.html index 8c7862b..be0bdea 100644 --- a/src/app/features/project-editor/sections/branding-section.component.html +++ b/src/app/features/project-editor/sections/branding-section.component.html @@ -1,7 +1,7 @@ @if (bootstrap(); as bootstrap) {
- +
{{ 'adminCategories.chooseImage' | translate }} diff --git a/src/app/features/project-editor/sections/branding-section.component.ts b/src/app/features/project-editor/sections/branding-section.component.ts index e455f09..af523fb 100644 --- a/src/app/features/project-editor/sections/branding-section.component.ts +++ b/src/app/features/project-editor/sections/branding-section.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ProjectEditorFacade } from '../facade/project-editor.facade'; import { TranslatePipe } from '../../../i18n/translate.pipe'; +import { TranslateService } from '../../../i18n/translate.service'; import { InputComponent } from '../../../shared/ui/input/input.component'; import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component'; @@ -21,7 +22,12 @@ type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl'; }) export class ProjectEditorBrandingSectionComponent { private readonly facade = inject(ProjectEditorFacade); + private readonly translate = inject(TranslateService); readonly bootstrap = this.facade.bootstrap; + readonly fieldError = (key: string): string | null => { + const messageKey = this.facade.fieldError(key); + return messageKey ? this.translate.t(messageKey) : null; + }; protected mediaPickerOpen = false; private mediaPickerTarget: BrandingImageField | null = null; diff --git a/src/app/features/project-editor/sections/general-section.component.html b/src/app/features/project-editor/sections/general-section.component.html index b6c630f..c3c26ef 100644 --- a/src/app/features/project-editor/sections/general-section.component.html +++ b/src/app/features/project-editor/sections/general-section.component.html @@ -1,10 +1,10 @@ @if (bootstrap(); as bootstrap) {
- + - +