From cb3dff819e8692faeb755458ac0424abb947aab0 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 17 Jul 2026 09:55:21 +0400 Subject: [PATCH] feat(static-pages): CRUD completion + search/filter/bulk actions Milestone 2 of the Static Pages Module sprint. - StaticPagesEditorComponent: duplicate page, confirm-before-delete/bulk- delete (matches the resetDraft confirm pattern), route/enabled/customTemplate/ media(hero/thumbnail/gallery) fields wired into the card, per-page publish/ unpublish action, status + duplicate-route/invalid-html/invalid-seo badges. - Search (id/slug/route/title across all locales), filter by status (draft/published) and by locale (hides pages missing a translation for the selected locale) - all local computed() filters, no new service. - Bulk selection (per-row + select-all-visible checkboxes) with bulk delete/ enable/disable/publish/unpublish, one updateBootstrap() call each. - Correctness note: introduced `allPages` (unfiltered) vs `pages` (filtered view) computeds. Every mutation (create/duplicate/delete/move/bulk) reads from allPages(), never the filtered pages() - reading from the filtered view would have silently deleted whatever an active search/filter hid on the next persist(). Documented inline on persist() as a guardrail for future edits. - Fixed a template compile error found by the build gate: Angular templates don't support inline arrow functions in binding expressions ((ngModelChange)="...map(v => v.trim())..." failed to parse) - moved the gallery CSV-parsing into a component method (updateGallery). - SEO robots field added to the page card (validated against a known-token set from M1). - i18n: staticPages.* extended (search/filter/bulk/route/enabled/status/ media/robots/disabled labels) across the interface + en/ru/hy. Gate: tsc --noEmit, npm test (57/57), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 --- .../static-pages-editor.component.html | 76 ++++++++ .../static-pages-editor.component.ts | 184 ++++++++++++++++-- src/app/i18n/en.ts | 27 +++ src/app/i18n/hy.ts | 27 +++ src/app/i18n/ru.ts | 27 +++ src/app/i18n/translations.ts | 27 +++ 6 files changed, 356 insertions(+), 12 deletions(-) diff --git a/src/app/features/content-management/components/static-pages-editor.component.html b/src/app/features/content-management/components/static-pages-editor.component.html index a5da9b0..e6b2e58 100644 --- a/src/app/features/content-management/components/static-pages-editor.component.html +++ b/src/app/features/content-management/components/static-pages-editor.component.html @@ -4,6 +4,35 @@ {{ 'builder.createPage' | translate }} +
+ + + + + + + + + +
+ + @if (pages().length > 0) { +
+ + @if (selectedCount() > 0) { + {{ selectedCount() }} {{ 'staticPages.selectedCount' | translate }} + {{ 'staticPages.bulkEnable' | translate }} + {{ 'staticPages.bulkDisable' | translate }} + {{ 'staticPages.bulkPublishAction' | translate }} + {{ 'staticPages.bulkUnpublish' | translate }} + {{ 'staticPages.bulkDelete' | translate }} + } +
+ } + @if (pages().length === 0) { @@ -17,18 +46,40 @@
+

{{ page.id }} + {{ (page.status === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }} + @if (!page.enabled) { + {{ 'staticPages.disabledBadge' | translate }} + } @if (hasDuplicateSlug(page)) { {{ 'staticPages.duplicateSlug' | translate }} } + @if (hasDuplicateRoute(page)) { + {{ 'staticPages.duplicateRoute' | translate }} + } @if (hasEmptyTitle(page)) { {{ 'staticPages.emptyTitle' | translate }} } + @if (hasInvalidHtml(page)) { + {{ 'staticPages.invalidHtml' | translate }} + } + @if (hasInvalidSeo(page)) { + {{ 'staticPages.invalidSeo' | translate }} + }

+ {{ 'staticPages.duplicatePage' | translate }} + @if (page.status === 'published') { + {{ 'staticPages.unpublishPageAction' | translate }} + } @else { + {{ 'staticPages.publishPageAction' | translate }} + } {{ 'builder.deletePage' | translate }}
@@ -40,6 +91,9 @@ + + + @@ -49,6 +103,25 @@ + + + + +
+ +
+ + + + + + + + +
@@ -96,6 +169,9 @@ + + +
diff --git a/src/app/features/content-management/components/static-pages-editor.component.ts b/src/app/features/content-management/components/static-pages-editor.component.ts index 59dec1b..0dea331 100644 --- a/src/app/features/content-management/components/static-pages-editor.component.ts +++ b/src/app/features/content-management/components/static-pages-editor.component.ts @@ -1,9 +1,10 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade'; import { ContentManagementFacade } from '../facade/content-management.facade'; -import { ContentPage, ContentPageSeoConfig } from '../models/content-page.model'; +import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model'; import { TranslatePipe } from '../../../i18n/translate.pipe'; +import { TranslateService } from '../../../i18n/translate.service'; import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { InputComponent } from '../../../shared/ui/input/input.component'; @@ -11,6 +12,11 @@ import { CardComponent } from '../../../shared/ui/card/card.component'; import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; import { BadgeComponent } from '../../../shared/ui/badge/badge.component'; import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component'; +import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component'; +import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component'; + +type StatusFilter = 'all' | ContentPageStatus; +const ALL_LOCALES_FILTER = 'all'; @Component({ selector: 'app-static-pages-editor', @@ -24,7 +30,9 @@ import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state. CardComponent, FormFieldComponent, BadgeComponent, - EmptyStateComponent + EmptyStateComponent, + SelectComponent, + ToggleComponent, ], templateUrl: './static-pages-editor.component.html', styleUrls: ['../../project-editor/sections/section.shared.scss', './static-pages-editor.component.scss'], @@ -33,20 +41,65 @@ import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state. export class StaticPagesEditorComponent { private readonly projectEditor = inject(ProjectEditorFacade); private readonly contentFacade = inject(ContentManagementFacade); + private readonly translate = inject(TranslateService); readonly bootstrap = this.projectEditor.bootstrap; - readonly pages = computed(() => this.contentFacade.pages(this.bootstrap())); + readonly allPages = computed(() => this.contentFacade.pages(this.bootstrap())); readonly validation = computed(() => this.contentFacade.validatePages(this.bootstrap())); readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']); + readonly searchQuery = signal(''); + readonly statusFilter = signal('all'); + readonly localeFilter = signal(ALL_LOCALES_FILTER); + readonly selectedIds = signal>(new Set()); + + readonly statusFilterOptions = computed(() => [ + { value: 'all', label: this.translate.t('staticPages.filterAll') }, + { value: 'draft', label: this.translate.t('builder.statusDraft') }, + { value: 'published', label: this.translate.t('builder.statusPublished') }, + ]); + + readonly localeFilterOptions = computed(() => [ + { value: ALL_LOCALES_FILTER, label: this.translate.t('staticPages.filterAllLocales') }, + ...this.locales().map(locale => ({ value: locale, label: locale })), + ]); + + readonly pages = computed(() => { + const query = this.searchQuery().trim().toLowerCase(); + const status = this.statusFilter(); + const locale = this.localeFilter(); + + return this.allPages().filter(page => { + if (status !== 'all' && page.status !== status) { + return false; + } + if (locale !== ALL_LOCALES_FILTER && !(page.translations[locale]?.title ?? '').trim()) { + return false; + } + if (!query) { + return true; + } + const haystack = [page.id, page.slug, page.route, page.title, ...Object.values(page.translations).map(t => t.title ?? '')] + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); + }); + + readonly selectedCount = computed(() => this.selectedIds().size); + readonly allVisibleSelected = computed(() => { + const visible = this.pages(); + return visible.length > 0 && visible.every(page => this.selectedIds().has(page.id)); + }); + createPage(): void { - const slug = `custom-page-${this.pages().length + 1}`; + const slug = `custom-page-${this.allPages().length + 1}`; const page: ContentPage = { id: `page-${Date.now()}`, slug, route: slug, title: '', - order: this.pages().length + 1, + order: this.allPages().length + 1, showInFooter: false, showInHeader: false, showInSitemap: true, @@ -64,19 +117,40 @@ export class StaticPagesEditorComponent { status: 'draft', }; - this.persist([...this.pages(), page]); + this.persist([...this.allPages(), page]); + } + + duplicatePage(id: string): void { + const source = this.allPages().find(page => page.id === id); + if (!source) { + return; + } + const clone: ContentPage = { + ...source, + id: `page-${Date.now()}`, + slug: `${source.slug}-copy`, + route: `${source.route}-copy`, + order: this.allPages().length + 1, + status: 'draft', + translations: Object.fromEntries(Object.entries(source.translations).map(([locale, t]) => [locale, { ...t }])), + }; + this.persist([...this.allPages(), clone]); } deletePage(id: string): void { - this.persist(this.pages().filter(page => page.id !== id)); + if (!confirm(this.translate.t('staticPages.confirmDeletePage'))) { + return; + } + this.persist(this.allPages().filter(page => page.id !== id)); + this.deselect(id); } updatePage(id: string, patch: Partial): void { - this.persist(this.pages().map(page => page.id !== id ? page : ({ ...page, ...patch }))); + this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, ...patch }))); } updateTranslation(id: string, locale: string, field: 'title' | 'html', value: string): void { - this.persist(this.pages().map(page => page.id !== id ? page : ({ + this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, translations: { ...page.translations, @@ -96,8 +170,25 @@ export class StaticPagesEditorComponent { return this.validation().emptyTitles.includes(page.id); } + hasDuplicateRoute(page: ContentPage): boolean { + return this.validation().duplicateRoutes.includes(page.route || page.slug); + } + + hasInvalidHtml(page: ContentPage): boolean { + return this.validation().invalidHtml.includes(page.id); + } + + hasInvalidSeo(page: ContentPage): boolean { + return this.validation().invalidSeo.includes(page.id); + } + + updateGallery(id: string, value: string): void { + const gallery = value.split(',').map(url => url.trim()).filter(url => url.length > 0); + this.updatePage(id, { gallery }); + } + updateSeo(id: string, patch: Partial): void { - this.persist(this.pages().map(page => page.id !== id ? page : ({ + this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, seo: { ...(page.seo ?? {}), @@ -107,7 +198,10 @@ export class StaticPagesEditorComponent { } move(id: string, direction: -1 | 1): void { - const pages = [...this.pages()].sort((a, b) => a.order - b.order); + // Reorders within the full page set (not the filtered/visible subset) so + // a search or status/locale filter can never drop pages out of the + // bootstrap - see the persist() note below. + const pages = [...this.allPages()].sort((a, b) => a.order - b.order); const index = pages.findIndex(page => page.id === id); const nextIndex = index + direction; if (index < 0 || nextIndex < 0 || nextIndex >= pages.length) { @@ -120,7 +214,73 @@ export class StaticPagesEditorComponent { this.persist(pages.map((page, order) => ({ ...page, order: order + 1 }))); } + // --- Search / filter --- + + updateSearchQuery(value: string): void { + this.searchQuery.set(value); + } + + updateStatusFilter(value: string): void { + this.statusFilter.set(value as StatusFilter); + } + + updateLocaleFilter(value: string): void { + this.localeFilter.set(value); + } + + // --- Bulk selection / actions --- + + isSelected(id: string): boolean { + return this.selectedIds().has(id); + } + + toggleSelect(id: string, checked: boolean): void { + const next = new Set(this.selectedIds()); + checked ? next.add(id) : next.delete(id); + this.selectedIds.set(next); + } + + toggleSelectAllVisible(checked: boolean): void { + const next = new Set(this.selectedIds()); + for (const page of this.pages()) { + checked ? next.add(page.id) : next.delete(page.id); + } + this.selectedIds.set(next); + } + + private deselect(id: string): void { + const next = new Set(this.selectedIds()); + next.delete(id); + this.selectedIds.set(next); + } + + clearSelection(): void { + this.selectedIds.set(new Set()); + } + + bulkDelete(): void { + if (!confirm(this.translate.t('staticPages.confirmBulkDelete'))) { + return; + } + const ids = this.selectedIds(); + this.persist(this.allPages().filter(page => !ids.has(page.id))); + this.clearSelection(); + } + + bulkSetEnabled(enabled: boolean): void { + const ids = this.selectedIds(); + this.persist(this.allPages().map(page => ids.has(page.id) ? { ...page, enabled } : page)); + } + + bulkSetStatus(status: ContentPageStatus): void { + const ids = this.selectedIds(); + this.persist(this.allPages().map(page => ids.has(page.id) ? { ...page, status } : page)); + } + private persist(pages: ContentPage[]): void { + // Always writes the full page set. Callers must build `pages` from + // allPages(), never from the filtered pages() view, or an active search/ + // status/locale filter would silently delete the pages it hid. this.projectEditor.updateBootstrap(current => ({ ...current, staticPages: this.contentFacade.serializePages(pages) diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index afbe40d..53629d0 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -719,6 +719,33 @@ export const en: Translations = { backHome: 'Back to home', duplicateSlug: 'Duplicate slugs are not allowed.', emptyTitle: 'Each page must have at least one title.', + duplicateRoute: 'Two or more pages share the same route.', + invalidHtml: 'This page has malformed HTML content.', + invalidSeo: 'This page has an invalid SEO field (canonical/OG image URL or robots value).', + searchPlaceholder: 'Search pages…', + filterAll: 'All statuses', + filterAllLocales: 'All languages', + confirmDeletePage: 'Delete this page? This cannot be undone.', + confirmBulkDelete: 'Delete the selected pages? This cannot be undone.', + duplicatePage: 'Duplicate', + selectAll: 'Select all', + selectedCount: 'selected', + bulkDelete: 'Delete selected', + bulkEnable: 'Enable selected', + bulkDisable: 'Disable selected', + bulkPublishAction: 'Publish selected', + bulkUnpublish: 'Unpublish selected', + routeLabel: 'Route', + enabledLabel: 'Enabled', + statusLabel: 'Status', + publishPageAction: 'Publish page', + unpublishPageAction: 'Unpublish page', + heroImageLabel: 'Hero image', + thumbnailLabel: 'Thumbnail', + galleryLabel: 'Gallery', + customTemplateLabel: 'Custom template', + seoRobotsLabel: 'Robots', + disabledBadge: 'Disabled', }, widgets: { unavailable: 'Widget unavailable', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 7373c80..1e3f463 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -719,6 +719,33 @@ export const hy: Translations = { backHome: 'Վերադառնալ գլխավոր', duplicateSlug: 'Կրկնվող slug-երը թույլատրելի չեն։', emptyTitle: 'Յուրաքանչյուր էջ պետք է ունենա առնվազն մեկ վերնագիր։', + duplicateRoute: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։', + invalidHtml: 'Այս էջը պարունակում է սխալ HTML բովանդակություն։', + invalidSeo: 'Այս էջն ունի անվավեր SEO դաշտ (canonical/OG պատկեր կամ robots արժեք)։', + searchPlaceholder: 'Փնտրել էջեր…', + filterAll: 'Բոլոր կարգավիճակները', + filterAllLocales: 'Բոլոր լեզուները', + confirmDeletePage: 'Ջնջե՞լ այս էջը։ Հնարավոր չէ հետարկել։', + confirmBulkDelete: 'Ջնջե՞լ ընտրված էջերը։ Հնարավոր չէ հետարկել։', + duplicatePage: 'Կրկնօրինակել', + selectAll: 'Ընտրել բոլորը', + selectedCount: 'ընտրված է', + bulkDelete: 'Ջնջել ընտրվածները', + bulkEnable: 'Միացնել ընտրվածները', + bulkDisable: 'Անջատել ընտրվածները', + bulkPublishAction: 'Հրապարակել ընտրվածները', + bulkUnpublish: 'Հանել հրապարակումից ընտրվածները', + routeLabel: 'Երթուղի', + enabledLabel: 'Միացված է', + statusLabel: 'Կարգավիճակ', + publishPageAction: 'Հրապարակել էջը', + unpublishPageAction: 'Հանել էջը հրապարակումից', + heroImageLabel: 'Գլխավոր պատկեր', + thumbnailLabel: 'Մանրապատկեր', + galleryLabel: 'Պատկերասրահ', + customTemplateLabel: 'Հատուկ ձևանմուշ', + seoRobotsLabel: 'Robots', + disabledBadge: 'Անջատված', }, widgets: { unavailable: 'Վիջեթը հասանելի չէ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 0e03086..49f4744 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -719,6 +719,33 @@ export const ru: Translations = { backHome: 'На главную', duplicateSlug: 'Дублирующиеся slug запрещены.', emptyTitle: 'У каждой страницы должен быть хотя бы один заголовок.', + duplicateRoute: 'Два или более страниц используют один и тот же маршрут.', + invalidHtml: 'На этой странице некорректный HTML-контент.', + invalidSeo: 'У этой страницы некорректное SEO-поле (canonical/OG-изображение или значение robots).', + searchPlaceholder: 'Поиск страниц…', + filterAll: 'Все статусы', + filterAllLocales: 'Все языки', + confirmDeletePage: 'Удалить эту страницу? Это действие нельзя отменить.', + confirmBulkDelete: 'Удалить выбранные страницы? Это действие нельзя отменить.', + duplicatePage: 'Дублировать', + selectAll: 'Выбрать все', + selectedCount: 'выбрано', + bulkDelete: 'Удалить выбранные', + bulkEnable: 'Включить выбранные', + bulkDisable: 'Отключить выбранные', + bulkPublishAction: 'Опубликовать выбранные', + bulkUnpublish: 'Снять с публикации выбранные', + routeLabel: 'Маршрут', + enabledLabel: 'Включена', + statusLabel: 'Статус', + publishPageAction: 'Опубликовать страницу', + unpublishPageAction: 'Снять страницу с публикации', + heroImageLabel: 'Главное изображение', + thumbnailLabel: 'Миниатюра', + galleryLabel: 'Галерея', + customTemplateLabel: 'Пользовательский шаблон', + seoRobotsLabel: 'Robots', + disabledBadge: 'Отключена', }, widgets: { unavailable: 'Виджет недоступен', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index a0367be..32fbb9b 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -717,6 +717,33 @@ export interface Translations { backHome: string; duplicateSlug: string; emptyTitle: string; + duplicateRoute: string; + invalidHtml: string; + invalidSeo: string; + searchPlaceholder: string; + filterAll: string; + filterAllLocales: string; + confirmDeletePage: string; + confirmBulkDelete: string; + duplicatePage: string; + selectAll: string; + selectedCount: string; + bulkDelete: string; + bulkEnable: string; + bulkDisable: string; + bulkPublishAction: string; + bulkUnpublish: string; + routeLabel: string; + enabledLabel: string; + statusLabel: string; + publishPageAction: string; + unpublishPageAction: string; + heroImageLabel: string; + thumbnailLabel: string; + galleryLabel: string; + customTemplateLabel: string; + seoRobotsLabel: string; + disabledBadge: string; }; widgets: { unavailable: string;