feat(static-pages): navigation integration (insert page link)
Milestone 6 of the Static Pages Module sprint.
- ProjectEditorFacade.addStaticPageNavLink(target, pageId): creates a
NavigationItemConfig { type: 'staticPage', key: pageId }. This shape was
already understood end-to-end by the resolvers (StaticPageResolverService/
FooterResolverService derive label+route from the linked page - see
footer-resolver.service.ts resolveGroupItem/resolveLegacyItem) - the only
gap was that the editor UI never exposed a way to create it.
- navigation-section: "Insert page link" control (page picker + button) next
to both header and footer "Add link". Rows for a static-page link show a
"Linked to page" indicator instead of the raw label/URL inputs (those
fields don't apply - the resolver derives them dynamically). labelOf()
falls back to the page id for the row heading since a static-page link has
no label of its own.
- i18n: builder.insertPageLink, builder.linkedToPage in interface + en/ru/hy.
Verified (no rebuild needed) that pages already participate in preview(),
exportBootstrap()/importBootstrap(), and draft/publish gating - all flow
through bootstrap.staticPages and the M1-M2 additive fields untouched here.
Gate: tsc --noEmit, npm test (57/57), arch:check, build all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -386,6 +386,31 @@ export class ProjectEditorFacade {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a nav link that points at a static page by id rather than a raw
|
||||||
|
* route. `type: 'staticPage'` is already understood by the resolvers
|
||||||
|
* (StaticPageResolverService / FooterResolverService derive the label and
|
||||||
|
* route from the page itself) - this only closes the editor-side gap of
|
||||||
|
* never being able to create that item shape.
|
||||||
|
*/
|
||||||
|
addStaticPageNavLink(target: 'header' | 'footer', pageId: string): void {
|
||||||
|
this.updateBootstrap(current => {
|
||||||
|
const list = current.navigation[target];
|
||||||
|
if (!this.isFlatNavList(list) || !pageId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const items = list as NavigationItemConfig[];
|
||||||
|
const newItem: NavigationItemConfig = {
|
||||||
|
id: `nav-page-${Date.now()}`,
|
||||||
|
type: 'staticPage',
|
||||||
|
key: pageId,
|
||||||
|
order: items.length + 1,
|
||||||
|
visible: true,
|
||||||
|
};
|
||||||
|
return { ...current, navigation: { ...current.navigation, [target]: [...items, newItem] } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
addNavLink(target: 'header' | 'footer'): void {
|
addNavLink(target: 'header' | 'footer'): void {
|
||||||
this.updateBootstrap(current => {
|
this.updateBootstrap(current => {
|
||||||
const list = current.navigation[target];
|
const list = current.navigation[target];
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<app-button variant="primary" (click)="addLink('header')">{{ 'builder.addLink' | translate }}</app-button>
|
<app-button variant="primary" (click)="addLink('header')">{{ 'builder.addLink' | translate }}</app-button>
|
||||||
|
<app-select [options]="pageOptions()" [ngModel]="selectedPageId().header" (ngModelChange)="updateSelectedPage('header', $event)" />
|
||||||
|
<app-button variant="secondary" (click)="insertPageLink('header')">{{ 'builder.insertPageLink' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack-list">
|
<div class="stack-list">
|
||||||
@@ -17,8 +19,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-grid three">
|
<div class="editor-grid three">
|
||||||
|
@if (isStaticPageLink(item)) {
|
||||||
|
<label class="full"><span>{{ 'builder.linkedToPage' | translate }}</span><small class="field-desc">{{ item.key }}</small></label>
|
||||||
|
} @else {
|
||||||
<label><span>{{ 'builder.linkLabel' | translate }}</span><small class="field-desc">{{ 'builder.linkLabelDesc' | translate }}</small><app-input [ngModel]="editableLabel(item)" (ngModelChange)="updateLabel('header', item.id, $event)" /></label>
|
<label><span>{{ 'builder.linkLabel' | translate }}</span><small class="field-desc">{{ 'builder.linkLabelDesc' | translate }}</small><app-input [ngModel]="editableLabel(item)" (ngModelChange)="updateLabel('header', item.id, $event)" /></label>
|
||||||
<label><span>{{ 'builder.linkUrl' | translate }}</span><small class="field-desc">{{ 'builder.linkUrlDesc' | translate }}</small><app-input [ngModel]="item.route" (ngModelChange)="updateRoute('header', item.id, $event)" /></label>
|
<label><span>{{ 'builder.linkUrl' | translate }}</span><small class="field-desc">{{ 'builder.linkUrlDesc' | translate }}</small><app-input [ngModel]="item.route" (ngModelChange)="updateRoute('header', item.id, $event)" /></label>
|
||||||
|
}
|
||||||
<label class="toggle-row"><app-toggle [ngModel]="item.visible !== false" (ngModelChange)="updateVisible('header', item.id, $event)" [ariaLabel]="'builder.visible' | translate" /><span>{{ 'builder.visible' | translate }}</span><small class="field-desc">{{ 'builder.linkVisibleDesc' | translate }}</small></label>
|
<label class="toggle-row"><app-toggle [ngModel]="item.visible !== false" (ngModelChange)="updateVisible('header', item.id, $event)" [ariaLabel]="'builder.visible' | translate" /><span>{{ 'builder.visible' | translate }}</span><small class="field-desc">{{ 'builder.linkVisibleDesc' | translate }}</small></label>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -29,6 +35,8 @@
|
|||||||
<h2>{{ 'builder.navigationFooter' | translate }}</h2>
|
<h2>{{ 'builder.navigationFooter' | translate }}</h2>
|
||||||
@if (footerLinks(); as footer) {
|
@if (footerLinks(); as footer) {
|
||||||
<app-button variant="primary" (click)="addLink('footer')">{{ 'builder.addLink' | translate }}</app-button>
|
<app-button variant="primary" (click)="addLink('footer')">{{ 'builder.addLink' | translate }}</app-button>
|
||||||
|
<app-select [options]="pageOptions()" [ngModel]="selectedPageId().footer" (ngModelChange)="updateSelectedPage('footer', $event)" />
|
||||||
|
<app-button variant="secondary" (click)="insertPageLink('footer')">{{ 'builder.insertPageLink' | translate }}</app-button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,8 +53,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-grid three">
|
<div class="editor-grid three">
|
||||||
|
@if (isStaticPageLink(item)) {
|
||||||
|
<label class="full"><span>{{ 'builder.linkedToPage' | translate }}</span><small class="field-desc">{{ item.key }}</small></label>
|
||||||
|
} @else {
|
||||||
<label><span>{{ 'builder.linkLabel' | translate }}</span><small class="field-desc">{{ 'builder.linkLabelDesc' | translate }}</small><app-input [ngModel]="editableLabel(item)" (ngModelChange)="updateLabel('footer', item.id, $event)" /></label>
|
<label><span>{{ 'builder.linkLabel' | translate }}</span><small class="field-desc">{{ 'builder.linkLabelDesc' | translate }}</small><app-input [ngModel]="editableLabel(item)" (ngModelChange)="updateLabel('footer', item.id, $event)" /></label>
|
||||||
<label><span>{{ 'builder.linkUrl' | translate }}</span><small class="field-desc">{{ 'builder.linkUrlDesc' | translate }}</small><app-input [ngModel]="item.route" (ngModelChange)="updateRoute('footer', item.id, $event)" /></label>
|
<label><span>{{ 'builder.linkUrl' | translate }}</span><small class="field-desc">{{ 'builder.linkUrlDesc' | translate }}</small><app-input [ngModel]="item.route" (ngModelChange)="updateRoute('footer', item.id, $event)" /></label>
|
||||||
|
}
|
||||||
<label class="toggle-row"><app-toggle [ngModel]="item.visible !== false" (ngModelChange)="updateVisible('footer', item.id, $event)" [ariaLabel]="'builder.visible' | translate" /><span>{{ 'builder.visible' | translate }}</span><small class="field-desc">{{ 'builder.linkVisibleDesc' | translate }}</small></label>
|
<label class="toggle-row"><app-toggle [ngModel]="item.visible !== false" (ngModelChange)="updateVisible('footer', item.id, $event)" [ariaLabel]="'builder.visible' | translate" /><span>{{ 'builder.visible' | translate }}</span><small class="field-desc">{{ 'builder.linkVisibleDesc' | translate }}</small></label>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -8,19 +8,44 @@ import { InputComponent } from '../../../shared/ui/input/input.component';
|
|||||||
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
||||||
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
|
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
|
||||||
import { LocaleTabsComponent } from '../../../shared/ui/locale-tabs/locale-tabs.component';
|
import { LocaleTabsComponent } from '../../../shared/ui/locale-tabs/locale-tabs.component';
|
||||||
|
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
|
||||||
|
import { ContentManagementFacade } from '../../content-management/facade/content-management.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-editor-navigation-section',
|
selector: 'app-project-editor-navigation-section',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, SectionCardComponent, ToggleComponent, LocaleTabsComponent],
|
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, SectionCardComponent, ToggleComponent, LocaleTabsComponent, SelectComponent],
|
||||||
templateUrl: './navigation-section.component.html',
|
templateUrl: './navigation-section.component.html',
|
||||||
styleUrls: ['./section.shared.scss'],
|
styleUrls: ['./section.shared.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class ProjectEditorNavigationSectionComponent {
|
export class ProjectEditorNavigationSectionComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
|
private readonly contentFacade = inject(ContentManagementFacade);
|
||||||
readonly bootstrap = this.facade.bootstrap;
|
readonly bootstrap = this.facade.bootstrap;
|
||||||
|
|
||||||
|
readonly pageOptions = computed<SelectOption[]>(() =>
|
||||||
|
this.contentFacade.pages(this.bootstrap()).map(page => ({ value: page.id, label: page.title || page.id })),
|
||||||
|
);
|
||||||
|
readonly selectedPageId = signal<Record<'header' | 'footer', string>>({ header: '', footer: '' });
|
||||||
|
|
||||||
|
updateSelectedPage(target: 'header' | 'footer', pageId: string): void {
|
||||||
|
this.selectedPageId.set({ ...this.selectedPageId(), [target]: pageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
insertPageLink(target: 'header' | 'footer'): void {
|
||||||
|
const pageId = this.selectedPageId()[target];
|
||||||
|
if (!pageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.facade.addStaticPageNavLink(target, pageId);
|
||||||
|
this.updateSelectedPage(target, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
isStaticPageLink(item: NavigationItemConfig): boolean {
|
||||||
|
return item.type === 'staticPage';
|
||||||
|
}
|
||||||
|
|
||||||
readonly supportedLocales = computed(() => this.bootstrap()?.tenant.supportedLocales ?? []);
|
readonly supportedLocales = computed(() => this.bootstrap()?.tenant.supportedLocales ?? []);
|
||||||
readonly defaultLocale = computed(() => this.bootstrap()?.tenant.defaultLocale ?? '');
|
readonly defaultLocale = computed(() => this.bootstrap()?.tenant.defaultLocale ?? '');
|
||||||
readonly activeLocale = signal<string | null>(null);
|
readonly activeLocale = signal<string | null>(null);
|
||||||
@@ -40,6 +65,12 @@ export class ProjectEditorNavigationSectionComponent {
|
|||||||
|
|
||||||
/** Display label used for the row heading — a stable identifier, not tied to the active locale. */
|
/** Display label used for the row heading — a stable identifier, not tied to the active locale. */
|
||||||
labelOf(item: NavigationItemConfig): string {
|
labelOf(item: NavigationItemConfig): string {
|
||||||
|
if (this.isStaticPageLink(item) && !item.label) {
|
||||||
|
// A static-page link has no editable label of its own - the resolver
|
||||||
|
// derives it from the linked page's title at render time - so fall
|
||||||
|
// back to the page id rather than showing a blank row heading.
|
||||||
|
return item.key ?? '';
|
||||||
|
}
|
||||||
if (typeof item.label === 'string' || !item.label) {
|
if (typeof item.label === 'string' || !item.label) {
|
||||||
return item.label ?? '';
|
return item.label ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -518,6 +518,8 @@ export const en: Translations = {
|
|||||||
navigationFooter: 'Footer Navigation',
|
navigationFooter: 'Footer Navigation',
|
||||||
navigationFooterGrouped: 'Footer navigation uses grouped columns and is not editable here yet — edit via the Footer tab.',
|
navigationFooterGrouped: 'Footer navigation uses grouped columns and is not editable here yet — edit via the Footer tab.',
|
||||||
addLink: 'Add Link',
|
addLink: 'Add Link',
|
||||||
|
insertPageLink: 'Insert page link',
|
||||||
|
linkedToPage: 'Linked to page',
|
||||||
removeLink: 'Remove',
|
removeLink: 'Remove',
|
||||||
linkLabel: 'Label',
|
linkLabel: 'Label',
|
||||||
linkUrl: 'URL',
|
linkUrl: 'URL',
|
||||||
|
|||||||
@@ -518,6 +518,8 @@ export const hy: Translations = {
|
|||||||
navigationFooter: 'Ստորին նավիգացիա',
|
navigationFooter: 'Ստորին նավիգացիա',
|
||||||
navigationFooterGrouped: 'Ստորին նավիգացիան խմբավորված է սյուներով և դեռ խմբագրելի չէ այստեղ. օգտագործեք «Footer» ներդիրը։',
|
navigationFooterGrouped: 'Ստորին նավիգացիան խմբավորված է սյուներով և դեռ խմբագրելի չէ այստեղ. օգտագործեք «Footer» ներդիրը։',
|
||||||
addLink: 'Ավելացնել հղում',
|
addLink: 'Ավելացնել հղում',
|
||||||
|
insertPageLink: 'Ներդնել էջի հղում',
|
||||||
|
linkedToPage: 'Կապված է էջի հետ',
|
||||||
removeLink: 'Հեռացնել',
|
removeLink: 'Հեռացնել',
|
||||||
linkLabel: 'Պիտակ',
|
linkLabel: 'Պիտակ',
|
||||||
linkUrl: 'URL',
|
linkUrl: 'URL',
|
||||||
|
|||||||
@@ -518,6 +518,8 @@ export const ru: Translations = {
|
|||||||
navigationFooter: 'Навигация в подвале',
|
navigationFooter: 'Навигация в подвале',
|
||||||
navigationFooterGrouped: 'Навигация подвала сгруппирована по колонкам и пока недоступна для редактирования здесь — используйте вкладку "Подвал".',
|
navigationFooterGrouped: 'Навигация подвала сгруппирована по колонкам и пока недоступна для редактирования здесь — используйте вкладку "Подвал".',
|
||||||
addLink: 'Добавить ссылку',
|
addLink: 'Добавить ссылку',
|
||||||
|
insertPageLink: 'Вставить ссылку на страницу',
|
||||||
|
linkedToPage: 'Связано со страницей',
|
||||||
removeLink: 'Удалить',
|
removeLink: 'Удалить',
|
||||||
linkLabel: 'Название',
|
linkLabel: 'Название',
|
||||||
linkUrl: 'URL',
|
linkUrl: 'URL',
|
||||||
|
|||||||
@@ -516,6 +516,8 @@ export interface Translations {
|
|||||||
navigationFooter: string;
|
navigationFooter: string;
|
||||||
navigationFooterGrouped: string;
|
navigationFooterGrouped: string;
|
||||||
addLink: string;
|
addLink: string;
|
||||||
|
insertPageLink: string;
|
||||||
|
linkedToPage: string;
|
||||||
removeLink: string;
|
removeLink: string;
|
||||||
linkLabel: string;
|
linkLabel: string;
|
||||||
linkUrl: string;
|
linkUrl: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user