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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,6 @@
|
|||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[ariaCurrentWhenActive]="'page'"
|
[ariaCurrentWhenActive]="'page'"
|
||||||
class="editor-nav-link"
|
class="editor-nav-link"
|
||||||
>{{ section.label | translate }}</a>
|
>{{ section.label | translate }}@if (issueCount(section.id); as count) {<span class="editor-nav-badge" [attr.aria-label]="count + ' ' + ('builder.title' | translate)">{{ count }}</span>}</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.editor-nav-link {
|
.editor-nav-link {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
import { LangRoutePipe } from '../../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../../pipes/lang-route.pipe';
|
||||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||||
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-editor-nav',
|
selector: 'app-project-editor-nav',
|
||||||
@@ -13,6 +14,14 @@ import { ProjectEditorSectionId } from '../models/project-editor.model';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ProjectEditorNavComponent {
|
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 }> = [
|
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
|
||||||
{ id: 'general', label: 'builder.general' },
|
{ id: 'general', label: 'builder.general' },
|
||||||
{ id: 'branding', label: 'builder.branding' },
|
{ id: 'branding', label: 'builder.branding' },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
@if (issues().length > 0) {
|
@if (issues().length > 0) {
|
||||||
<ul class="project-editor-save-bar-issues">
|
<ul class="project-editor-save-bar-issues">
|
||||||
@for (issue of issues(); track issue.code) {
|
@for (issue of issues(); track issue.code) {
|
||||||
<li>{{ issue.message | translate }}</li>
|
<li [class.is-warning]="issue.severity === 'warning'" [class.is-error]="issue.severity === 'error'">{{ issue.message | translate }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,6 @@
|
|||||||
<div class="project-editor-save-bar-actions">
|
<div class="project-editor-save-bar-actions">
|
||||||
<app-button variant="danger" size="sm" (click)="resetDraft()">{{ 'builder.resetDraft' | translate }}</app-button>
|
<app-button variant="danger" size="sm" (click)="resetDraft()">{{ 'builder.resetDraft' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="save()">{{ 'builder.save' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="save()">{{ 'builder.save' | translate }}</app-button>
|
||||||
<app-button variant="primary" size="sm" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</app-button>
|
<app-button variant="primary" size="sm" [disabled]="hasBlockingIssues()" (click)="publish()">{{ 'builder.publish' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
color: var(--danger, #b91c1c);
|
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 {
|
.project-editor-save-bar-actions {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class ProjectEditorSaveBarComponent {
|
|||||||
readonly dirty = this.facade.dirty;
|
readonly dirty = this.facade.dirty;
|
||||||
readonly status = this.facade.status;
|
readonly status = this.facade.status;
|
||||||
readonly issues = this.facade.validationIssues;
|
readonly issues = this.facade.validationIssues;
|
||||||
|
readonly hasBlockingIssues = this.facade.hasBlockingIssues;
|
||||||
readonly lastSavedAt = this.facade.lastSavedAt;
|
readonly lastSavedAt = this.facade.lastSavedAt;
|
||||||
readonly draftRestored = this.facade.draftRestored;
|
readonly draftRestored = this.facade.draftRestored;
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ export class ProjectEditorFacade {
|
|||||||
this.draftStorage.save(next);
|
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 {
|
dismissDraftRestoredNotice(): void {
|
||||||
this.state.update(current => ({ ...current, draftRestored: false }));
|
this.state.update(current => ({ ...current, draftRestored: false }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@if (bootstrap(); as bootstrap) {
|
@if (bootstrap(); as bootstrap) {
|
||||||
<app-section-card [title]="'builder.branding' | translate">
|
<app-section-card [title]="'builder.branding' | translate">
|
||||||
<div class="editor-grid two">
|
<div class="editor-grid two">
|
||||||
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate">
|
<app-form-field [label]="'builder.logo' | translate" [hint]="'builder.logoDesc' | translate" [error]="fieldError('branding.logoUrl')">
|
||||||
<div class="media-field-row">
|
<div class="media-field-row">
|
||||||
<app-input [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" />
|
<app-input [ngModel]="bootstrap.branding.logoUrl" (ngModelChange)="updateField('logoUrl', $event)" />
|
||||||
<app-button variant="secondary" size="sm" (click)="openMediaPicker('logoUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="openMediaPicker('logoUrl')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { InputComponent } from '../../../shared/ui/input/input.component';
|
import { InputComponent } from '../../../shared/ui/input/input.component';
|
||||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||||
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||||
@@ -21,7 +22,12 @@ type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl';
|
|||||||
})
|
})
|
||||||
export class ProjectEditorBrandingSectionComponent {
|
export class ProjectEditorBrandingSectionComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
readonly bootstrap = this.facade.bootstrap;
|
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;
|
protected mediaPickerOpen = false;
|
||||||
private mediaPickerTarget: BrandingImageField | null = null;
|
private mediaPickerTarget: BrandingImageField | null = null;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@if (bootstrap(); as bootstrap) {
|
@if (bootstrap(); as bootstrap) {
|
||||||
<app-section-card [title]="'builder.general' | translate">
|
<app-section-card [title]="'builder.general' | translate">
|
||||||
<div class="editor-grid two">
|
<div class="editor-grid two">
|
||||||
<app-form-field [label]="'builder.marketplaceName' | translate" [hint]="'builder.marketplaceNameDesc' | translate">
|
<app-form-field [label]="'builder.marketplaceName' | translate" [hint]="'builder.marketplaceNameDesc' | translate" [error]="fieldError('branding.brandName')">
|
||||||
<app-input [ngModel]="bootstrap.branding.brandName" (ngModelChange)="updateMarketplaceName($event)" />
|
<app-input [ngModel]="bootstrap.branding.brandName" (ngModelChange)="updateMarketplaceName($event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<app-form-field [label]="'builder.domain' | translate" [hint]="'builder.domainDesc' | translate">
|
<app-form-field [label]="'builder.domain' | translate" [hint]="'builder.domainDesc' | translate" [error]="fieldError('tenant.host') || fieldError('tenant.websiteBaseUrl')">
|
||||||
<app-input [ngModel]="bootstrap.tenant.host" (ngModelChange)="updateDomain($event)" />
|
<app-input [ngModel]="bootstrap.tenant.host" (ngModelChange)="updateDomain($event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { InputComponent } from '../../../shared/ui/input/input.component';
|
import { InputComponent } from '../../../shared/ui/input/input.component';
|
||||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||||
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
||||||
@@ -16,7 +17,12 @@ import { SectionCardComponent } from '../../../shared/ui/section-card/section-ca
|
|||||||
})
|
})
|
||||||
export class ProjectEditorGeneralSectionComponent {
|
export class ProjectEditorGeneralSectionComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
readonly bootstrap = this.facade.bootstrap;
|
readonly bootstrap = this.facade.bootstrap;
|
||||||
|
readonly fieldError = (key: string): string | null => {
|
||||||
|
const messageKey = this.facade.fieldError(key);
|
||||||
|
return messageKey ? this.translate.t(messageKey) : null;
|
||||||
|
};
|
||||||
readonly languagesValue = computed(() => (this.bootstrap()?.localization.supportedLocales ?? []).join(', '));
|
readonly languagesValue = computed(() => (this.bootstrap()?.localization.supportedLocales ?? []).join(', '));
|
||||||
|
|
||||||
updateMarketplaceName(value: string): void {
|
updateMarketplaceName(value: string): void {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
@if (bootstrap(); as bootstrap) {
|
@if (bootstrap(); as bootstrap) {
|
||||||
<app-section-card [title]="'builder.theme' | translate">
|
<app-section-card [title]="'builder.theme' | translate">
|
||||||
<div class="editor-grid two">
|
<div class="editor-grid two">
|
||||||
<app-form-field [label]="'builder.primaryColor' | translate" [hint]="'builder.primaryColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.primaryColor' | translate" [hint]="'builder.primaryColorDesc' | translate" [error]="fieldError('theme.palette.primary')"><app-color-picker [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.secondaryColor' | translate" [hint]="'builder.secondaryColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.secondaryColor' | translate" [hint]="'builder.secondaryColorDesc' | translate" [error]="fieldError('theme.palette.secondary')"><app-color-picker [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.backgroundColor' | translate" [hint]="'builder.backgroundColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.backgroundColor' | translate" [hint]="'builder.backgroundColorDesc' | translate" [error]="fieldError('theme.palette.backgroundPrimary')"><app-color-picker [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.surfaceColor' | translate" [hint]="'builder.surfaceColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.surfaceColor' | translate" [hint]="'builder.surfaceColorDesc' | translate" [error]="fieldError('theme.palette.backgroundSecondary')"><app-color-picker [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.textColor' | translate" [hint]="'builder.textColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.textColor' | translate" [hint]="'builder.textColorDesc' | translate" [error]="fieldError('theme.palette.textPrimary')"><app-color-picker [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.successColor' | translate" [hint]="'builder.successColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.success" (ngModelChange)="updatePalette('success', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.successColor' | translate" [hint]="'builder.successColorDesc' | translate" [error]="fieldError('theme.palette.success')"><app-color-picker [ngModel]="bootstrap.theme.palette.success" (ngModelChange)="updatePalette('success', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.warningColor' | translate" [hint]="'builder.warningColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.warning" (ngModelChange)="updatePalette('warning', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.warningColor' | translate" [hint]="'builder.warningColorDesc' | translate" [error]="fieldError('theme.palette.warning')"><app-color-picker [ngModel]="bootstrap.theme.palette.warning" (ngModelChange)="updatePalette('warning', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.dangerColor' | translate" [hint]="'builder.dangerColorDesc' | translate"><app-color-picker [ngModel]="bootstrap.theme.palette.danger" (ngModelChange)="updatePalette('danger', $event)" /></app-form-field>
|
<app-form-field [label]="'builder.dangerColor' | translate" [hint]="'builder.dangerColorDesc' | translate" [error]="fieldError('theme.palette.danger')"><app-color-picker [ngModel]="bootstrap.theme.palette.danger" (ngModelChange)="updatePalette('danger', $event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.themeModeLabel' | translate" [hint]="'builder.themeModeDesc' | translate">
|
<app-form-field [label]="'builder.themeModeLabel' | translate" [hint]="'builder.themeModeDesc' | translate">
|
||||||
<app-select [options]="themeModeSelectOptions()" [ngModel]="bootstrap.theme.mode" (ngModelChange)="updateThemeMode($event)" />
|
<app-select [options]="themeModeSelectOptions()" [ngModel]="bootstrap.theme.mode" (ngModelChange)="updateThemeMode($event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class ProjectEditorThemeSectionComponent {
|
|||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
readonly bootstrap = this.facade.bootstrap;
|
readonly bootstrap = this.facade.bootstrap;
|
||||||
|
readonly fieldError = (key: string): string | null => {
|
||||||
|
const messageKey = this.facade.fieldError(key);
|
||||||
|
return messageKey ? this.translate.t(messageKey) : null;
|
||||||
|
};
|
||||||
|
|
||||||
readonly themeModeOptions: ThemeModeOption[] = [
|
readonly themeModeOptions: ThemeModeOption[] = [
|
||||||
{ value: 'light', labelKey: 'builder.themeModeLight', descKey: 'builder.themeModeLightDesc' },
|
{ value: 'light', labelKey: 'builder.themeModeLight', descKey: 'builder.themeModeLightDesc' },
|
||||||
|
|||||||
Reference in New Issue
Block a user