feat(content-management): surface validation errors per-page
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

Replace global duplicate-slug/empty-title banners with per-page indicators:
a danger badge next to the offending page's heading, plus inline error text
on the specific pageId/slug app-form-field. Made ContentPageService.normalizeSlug
public (was private) so the component can match validation results to a given
page's normalized slug without duplicating the normalization logic.

Verified in browser: setting a duplicate slug live shows both the header
badge and the inline field error immediately.

Completes Sprint 3 (Static Page Generator): DRY cleanup, Design System
adoption, SEO field coverage, per-page validation UX.
This commit is contained in:
sdarbinyan
2026-07-15 04:46:27 +04:00
parent 97bd4b2b95
commit 9133113ab2
5 changed files with 28 additions and 11 deletions

View File

@@ -4,13 +4,6 @@
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button> <app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
</div> </div>
@if (validation().duplicateSlugs.length > 0) {
<app-badge variant="danger">{{ 'staticPages.duplicateSlug' | translate }}</app-badge>
}
@if (validation().emptyTitles.length > 0) {
<app-badge variant="danger">{{ 'staticPages.emptyTitle' | translate }}</app-badge>
}
@if (pages().length === 0) { @if (pages().length === 0) {
<app-empty-state [title]="'builder.staticPages' | translate" [description]="'builder.createPage' | translate"> <app-empty-state [title]="'builder.staticPages' | translate" [description]="'builder.createPage' | translate">
<span slot="actions"> <span slot="actions">
@@ -24,7 +17,15 @@
<app-card padding="md"> <app-card padding="md">
<div class="page-card"> <div class="page-card">
<div class="page-card__header"> <div class="page-card__header">
<h3 class="page-card__title">{{ page.id }}</h3> <h3 class="page-card__title">
{{ page.id }}
@if (hasDuplicateSlug(page)) {
<app-badge variant="danger">{{ 'staticPages.duplicateSlug' | translate }}</app-badge>
}
@if (hasEmptyTitle(page)) {
<app-badge variant="danger">{{ 'staticPages.emptyTitle' | translate }}</app-badge>
}
</h3>
<div class="editor-actions"> <div class="editor-actions">
<app-button variant="ghost" size="sm" (click)="move(page.id, -1)">&uarr;</app-button> <app-button variant="ghost" size="sm" (click)="move(page.id, -1)">&uarr;</app-button>
<app-button variant="ghost" size="sm" (click)="move(page.id, 1)">&darr;</app-button> <app-button variant="ghost" size="sm" (click)="move(page.id, 1)">&darr;</app-button>
@@ -33,10 +34,10 @@
</div> </div>
<div class="editor-grid three"> <div class="editor-grid three">
<app-form-field [label]="'builder.pageId' | translate"> <app-form-field [label]="'builder.pageId' | translate" [error]="hasEmptyTitle(page) ? ('staticPages.emptyTitle' | translate) : null">
<app-input [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" /> <app-input [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" />
</app-form-field> </app-form-field>
<app-form-field [label]="'builder.slug' | translate"> <app-form-field [label]="'builder.slug' | translate" [error]="hasDuplicateSlug(page) ? ('staticPages.duplicateSlug' | translate) : null">
<app-input [ngModel]="page.slug" (ngModelChange)="updatePage(page.id, { slug: $event })" /> <app-input [ngModel]="page.slug" (ngModelChange)="updatePage(page.id, { slug: $event })" />
</app-form-field> </app-form-field>
<app-form-field [label]="'builder.iconLabel' | translate"> <app-form-field [label]="'builder.iconLabel' | translate">

View File

@@ -13,6 +13,10 @@
.page-card__title { .page-card__title {
margin: 0; margin: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary, #1e3c38); color: var(--text-primary, #1e3c38);

View File

@@ -81,6 +81,14 @@ export class StaticPagesEditorComponent {
}))); })));
} }
hasDuplicateSlug(page: ContentPage): boolean {
return this.validation().duplicateSlugs.includes(this.contentFacade.normalizeSlug(page.slug));
}
hasEmptyTitle(page: ContentPage): boolean {
return this.validation().emptyTitles.includes(page.id);
}
updateSeo(id: string, patch: Partial<ContentPageSeoConfig>): void { updateSeo(id: string, patch: Partial<ContentPageSeoConfig>): void {
this.persist(this.pages().map(page => page.id !== id ? page : ({ this.persist(this.pages().map(page => page.id !== id ? page : ({
...page, ...page,

View File

@@ -26,4 +26,8 @@ export class ContentManagementFacade {
serializePages(pages: ContentPage[]) { serializePages(pages: ContentPage[]) {
return this.service.toBootstrapRecord(pages); return this.service.toBootstrapRecord(pages);
} }
normalizeSlug(value: string): string {
return this.service.normalizeSlug(value);
}
} }

View File

@@ -170,7 +170,7 @@ export class ContentPageService {
return translations; return translations;
} }
private normalizeSlug(value: string): string { normalizeSlug(value: string): string {
return value.replace(/^\/+/, '').trim(); return value.replace(/^\/+/, '').trim();
} }