feat: Page Editor UX Phase 2 - unsaved changes panel, property search, reset property, empty states
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

- ProjectEditorFacade.resetField(key): field-level revert-to-original, reusing getByPath + a new immutable setByPath (write-side counterpart, scalar/object dot-paths only).
- app-form-field gains showReset/resetLabel/(resetClicked) - a reusable per-field reset affordance, wired on branding logo/title and theme primary/background color.
- Save bar: unsaved-changes count is now clickable, expanding a field-level diff list (reuses facade.changeSummary(), already built for the Preview tab) with jump-to-section links.
- BuilderPropertySearchComponent: filters EditorSchemaService.all() by translated label/hint, jumps to the owning section - no new registry, reuses the existing schema.
- navigation-section: app-empty-state (existing component) added for empty header/footer link lists.
- Reset Section and Draft/Published badge were already implemented; not touched.
- New copy added as translation keys (en/ru/hy).
This commit is contained in:
sdarbinyan
2026-07-27 11:12:35 +04:00
parent 42f11dd8c0
commit 65c6d6f5d1
23 changed files with 432 additions and 9 deletions

View File

@@ -0,0 +1,32 @@
<div class="builder-property-search">
<label class="builder-property-search__label" for="builder-property-search-input">
<app-icon name="search" [size]="14" aria-hidden="true" />
<span class="sr-only">{{ 'builder.propertySearchLabel' | translate }}</span>
</label>
<input
id="builder-property-search-input"
type="search"
class="builder-property-search__input"
[placeholder]="'builder.propertySearchPlaceholder' | translate"
[attr.aria-label]="'builder.propertySearchLabel' | translate"
[ngModel]="query()"
(ngModelChange)="query.set($event)"
(focus)="onFocus()"
(blur)="onBlur()"
/>
@if (open() && query().trim()) {
<ul class="builder-property-search__results" [attr.aria-label]="'builder.propertySearchResultsLabel' | translate">
@for (result of results(); track result.key) {
<li>
<a [routerLink]="['/edit', result.sectionId] | langRoute" (click)="selectResult()">
<span class="builder-property-search__result-label">{{ result.label }}</span>
<span class="builder-property-search__result-section">{{ result.sectionLabel }}</span>
</a>
</li>
} @empty {
<li class="builder-property-search__empty">{{ 'builder.propertySearchNoResults' | translate }}</li>
}
</ul>
}
</div>

View File

@@ -0,0 +1,76 @@
.builder-property-search {
position: relative;
}
.builder-property-search__label {
position: absolute;
left: var(--space-sm, 0.5rem);
top: 50%;
transform: translateY(-50%);
display: flex;
color: var(--text-secondary, #6b7280);
pointer-events: none;
}
.builder-property-search__input {
width: 100%;
height: 36px;
padding: 0 var(--space-sm, 0.5rem) 0 32px;
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #1f322d);
font-family: inherit;
font-size: var(--font-size-sm, 0.8125rem);
&:focus-visible {
outline: none;
border-color: var(--primary-color, #2f6e5d);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color, #2f6e5d) 25%, transparent);
}
}
.builder-property-search__results {
position: absolute;
z-index: 50;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: var(--space-xs, 0.25rem);
list-style: none;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e4e4e7);
border-radius: var(--radius-md, 6px);
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.08));
max-height: 300px;
overflow-y: auto;
a {
display: flex;
justify-content: space-between;
gap: var(--space-sm, 0.5rem);
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
border-radius: var(--radius-sm, 4px);
color: var(--text-primary, #1f322d);
text-decoration: none;
font-size: var(--font-size-sm, 0.8125rem);
&:hover,
&:focus-visible {
background: var(--bg-secondary, #f4f4f5);
outline: none;
}
}
}
.builder-property-search__result-section {
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-xs, 0.75rem);
}
.builder-property-search__empty {
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-sm, 0.8125rem);
}

View File

@@ -0,0 +1,92 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { EditorSchemaService } from '../../schema/editor-schema.service';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
import { RouterLink } from '@angular/router';
interface PropertySearchResult {
key: string;
label: string;
hint: string;
sectionId: string;
sectionLabel: string;
}
const SECTION_LABEL_KEYS: Record<string, string> = {
general: 'builder.general',
branding: 'builder.branding',
theme: 'builder.theme',
header: 'builder.header',
footer: 'builder.footer',
homepage: 'builder.homepage',
widgets: 'builder.widgets',
'static-pages': 'builder.staticPages',
features: 'builder.marketplaceFeatures',
languages: 'builder.languagesTab',
navigation: 'builder.navigationTab',
preview: 'builder.preview',
};
/**
* Filters the field-schema registry (EditorSchemaService.all(), the single source of
* truth already used for validation/readiness) by translated label/hint text, so a
* non-technical admin can find "where do I change X" without knowing which section
* it lives in. Selecting a result jumps to that field's section.
*/
@Component({
selector: 'app-builder-property-search',
standalone: true,
imports: [FormsModule, TranslatePipe, RouterLink, LangRoutePipe, IconComponent],
templateUrl: './builder-property-search.component.html',
styleUrl: './builder-property-search.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BuilderPropertySearchComponent {
private readonly schema = inject(EditorSchemaService);
private readonly translate = inject(TranslateService);
readonly query = signal('');
readonly open = signal(false);
private readonly allResults = computed<PropertySearchResult[]>(() =>
this.schema.all().map(field => ({
key: field.key,
label: this.translate.t(field.labelKey),
hint: field.hintKey ? this.translate.t(field.hintKey) : '',
sectionId: field.section,
sectionLabel: this.translate.t(SECTION_LABEL_KEYS[field.section] ?? field.section),
}))
);
readonly results = computed<PropertySearchResult[]>(() => {
const term = this.query().trim().toLowerCase();
if (!term) {
return [];
}
return this.allResults()
.filter(result =>
result.label.toLowerCase().includes(term) ||
result.hint.toLowerCase().includes(term) ||
result.sectionLabel.toLowerCase().includes(term)
)
.slice(0, 8);
});
onFocus(): void {
this.open.set(true);
}
onBlur(): void {
// Deferred so a click on a result (which fires before blur settles) still registers.
setTimeout(() => this.open.set(false), 150);
}
/** RouterLink on the result anchor handles navigation; this just resets the search UI after the click. */
selectResult(): void {
this.query.set('');
this.open.set(false);
}
}

View File

@@ -8,7 +8,9 @@
<div class="project-editor-save-bar-status" role="status" aria-live="polite">
<span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span>
@if (dirty()) {
<span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span>
<button type="button" class="project-editor-save-bar-dirty project-editor-save-bar-dirty--toggle" [attr.aria-expanded]="changesOpen()" (click)="toggleChanges()">
{{ 'builder.unsavedChanges' | translate }} ({{ changeSummary().length }})
</button>
} @else if (lastSavedAt()) {
<span class="project-editor-save-bar-saved-at">{{ 'builder.lastSaved' | translate }}: {{ formatSavedAt(lastSavedAt()!) }}</span>
}
@@ -20,6 +22,18 @@
</ul>
}
</div>
@if (changesOpen() && changeSummary().length > 0) {
<ul class="project-editor-save-bar-changes" [attr.aria-label]="'builder.unsavedChangesPanelTitle' | translate">
@for (row of changeSummary(); track row.fieldKey) {
<li>
<a [routerLink]="row.section ? (['/edit', row.section] | langRoute) : null" (click)="toggleChanges()">
<span class="project-editor-save-bar-changes__field">{{ row.labelKey | translate }}</span>
<span class="project-editor-save-bar-changes__diff">{{ row.before || '—' }} &rarr; {{ row.after || '—' }}</span>
</a>
</li>
}
</ul>
}
<div class="project-editor-save-bar-actions">
<app-button variant="ghost" size="sm" [disabled]="!canUndo()" (click)="undo()">{{ 'builder.undo' | translate }}</app-button>
<app-button variant="ghost" size="sm" [disabled]="!canRedo()" (click)="redo()">{{ 'builder.redo' | translate }}</app-button>

View File

@@ -16,6 +16,62 @@
margin-left: 0.5rem;
}
.project-editor-save-bar-dirty--toggle {
border: none;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
&:focus-visible {
outline: 2px solid var(--primary-color, #2f6e5d);
outline-offset: 2px;
}
}
.project-editor-save-bar-changes {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin: 0;
padding: var(--space-xs, 0.25rem);
list-style: none;
max-height: 220px;
overflow-y: auto;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #ddd);
border-bottom: none;
box-shadow: var(--shadow-md, 0 -4px 12px rgba(0, 0, 0, 0.08));
a {
display: flex;
justify-content: space-between;
gap: var(--space-sm, 0.5rem);
padding: var(--space-xs, 0.25rem) var(--space-sm, 0.5rem);
border-radius: var(--radius-sm, 4px);
color: var(--text-primary, #1f322d);
text-decoration: none;
font-size: var(--font-size-sm, 0.8125rem);
&:hover,
&:focus-visible {
background: var(--bg-secondary, #f4f4f5);
outline: none;
}
}
}
.project-editor-save-bar-changes__diff {
color: var(--text-secondary, #6b7280);
font-size: var(--font-size-xs, 0.75rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-editor-save-bar-issues {
margin: 0.25rem 0 0;
padding-left: 1.25rem;

View File

@@ -1,6 +1,8 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { ProjectEditorFacade } from '../../facade/project-editor.facade';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@@ -8,7 +10,7 @@ import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/con
@Component({
selector: 'app-project-editor-save-bar',
standalone: true,
imports: [TranslatePipe, ButtonComponent, ConfirmDialogComponent],
imports: [TranslatePipe, ButtonComponent, ConfirmDialogComponent, RouterLink, LangRoutePipe],
templateUrl: './project-editor-save-bar.component.html',
styleUrls: ['./project-editor-save-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -22,6 +24,12 @@ export class ProjectEditorSaveBarComponent {
readonly hasBlockingIssues = this.facade.hasBlockingIssues;
readonly canUndo = this.facade.canUndo;
readonly canRedo = this.facade.canRedo;
readonly changeSummary = this.facade.changeSummary;
readonly changesOpen = signal(false);
toggleChanges(): void {
this.changesOpen.update(open => !open);
}
undo(): void {
this.facade.undo();

View File

@@ -453,6 +453,34 @@ export class ProjectEditorFacade {
});
}
/**
* Reverts a single schema-registered field to its originally loaded/published value.
* Field-level counterpart to resetSection - same "revert to original" semantics, scoped to
* one dot-path instead of a whole bootstrap key. No confirmation needed: it's a single-value
* change that flows through updateBootstrap like any edit, so undo covers it.
*/
resetField(key: string): void {
const original = this.state().originalBootstrap;
if (!original) {
return;
}
const value = JSON.parse(JSON.stringify(this.schema.getByPath(original, key)));
this.updateBootstrap(current => this.setByPath(current, key, value));
}
/** Immutable dot-path setter, the write-side counterpart to EditorSchemaService.getByPath. Only used for resetField's scalar/object schema paths - none of the registered field keys index into array elements. */
private setByPath<T extends object>(source: T, key: string, value: unknown): T {
const [head, ...rest] = key.split('.');
if (rest.length === 0) {
return { ...source, [head]: value };
}
const child = (source as Record<string, unknown>)[head];
return {
...source,
[head]: this.setByPath(typeof child === 'object' && child !== null ? child : {}, rest.join('.'), value),
};
}
/** Discards the entire draft, reverting to the originally loaded/published bootstrap. Caller is responsible for confirmation UX. */
resetDraft(): void {
const original = this.state().originalBootstrap;

View File

@@ -13,6 +13,7 @@
<app-icon name="home" [size]="16" />
{{ 'builder.backToDashboard' | translate }}
</a>
<app-builder-property-search class="project-editor-layout__search" />
<app-project-editor-nav (linkClicked)="closeMobileDrawer()" />
</aside>

View File

@@ -309,6 +309,11 @@
color: var(--text-primary);
}
.project-editor-layout__search {
display: block;
margin-bottom: var(--space-md, 1rem);
}
.project-editor-live-preview__hint {
margin: 0 0 var(--space-sm, 0.5rem);
font-size: var(--font-size-xs, 0.75rem);

View File

@@ -26,6 +26,7 @@ import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-ed
import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model';
import { BUILDER_GROUPS, SECTION_TO_GROUP } from '../builder/builder-groups.model';
import { BuilderLivePreviewComponent } from '../components/live-preview/builder-live-preview.component';
import { BuilderPropertySearchComponent } from '../components/property-search/builder-property-search.component';
/** Sections that currently expose highlight-mapped fields (see HighlightSourceDirective usages) - showing the panel elsewhere would just be an inert, unhighlightable diagram. */
const SECTIONS_WITH_LIVE_PREVIEW: ProjectEditorSectionId[] = ['branding', 'theme', 'homepage'];
@@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record<ProjectEditorSectionId, string> = {
ButtonComponent,
ConfirmDialogComponent,
BuilderLivePreviewComponent,
BuilderPropertySearchComponent,
],
templateUrl: './project-editor-page.component.html',
styleUrls: ['./project-editor-page.component.scss'],

View File

@@ -9,6 +9,9 @@
[error]="fieldError('branding.logoUrl')"
[usedBy]="[('builder.usedByHeader' | translate), ('builder.usedByFooter' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('branding.logoUrl')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('branding.logoUrl')"
>
<app-image-field [value]="bootstrap.branding.logoUrl" (valueChange)="updateField('logoUrl', $event)" />
</app-form-field>
@@ -28,6 +31,9 @@
[hint]="'builder.marketplaceTitleDesc' | translate"
[usedBy]="[('builder.usedByHeader' | translate), ('builder.usedBySeo' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('seo.default.title')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('seo.default.title')"
>
<app-input [ngModel]="bootstrap.seo.default.title" (ngModelChange)="updateMarketplaceTitle($event)" />
</app-form-field>

View File

@@ -29,6 +29,10 @@ export class ProjectEditorBrandingSectionComponent {
const messageKey = this.facade.fieldError(key);
return messageKey ? this.translate.t(messageKey) : null;
};
readonly isFieldModified = (key: string): boolean => this.facade.modifiedFields().has(key);
resetField(key: string): void {
this.facade.resetField(key);
}
updateField<K extends BrandingImageField | 'brandName'>(key: K, value: string): void {
this.facade.updateBootstrap(current => ({

View File

@@ -11,6 +11,9 @@
</div>
<div class="stack-list">
@if (headerLinks().length === 0) {
<app-empty-state [title]="'builder.navigationHeaderEmptyTitle' | translate" [description]="'builder.navigationHeaderEmptyDesc' | translate" />
}
@for (item of headerLinks(); track item.id) {
<article class="sub-card">
<div class="editor-actions">
@@ -45,6 +48,9 @@
@if (footerLinks(); as footer) {
<div class="stack-list">
@if (footer.length === 0) {
<app-empty-state [title]="'builder.navigationFooterEmptyTitle' | translate" [description]="'builder.navigationFooterEmptyDesc' | translate" />
}
@for (item of footer; track item.id) {
<article class="sub-card">
<div class="editor-actions">

View File

@@ -10,12 +10,13 @@ import { SectionCardComponent } from '../../../shared/ui/section-card/section-ca
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
import { LocaleTabsComponent } from '../../../shared/ui/locale-tabs/locale-tabs.component';
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
import { ContentManagementFacade } from '../../content-management/facade/content-management.facade';
@Component({
selector: 'app-project-editor-navigation-section',
standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, SectionCardComponent, ToggleComponent, LocaleTabsComponent, SelectComponent],
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, SectionCardComponent, ToggleComponent, LocaleTabsComponent, SelectComponent, EmptyStateComponent],
templateUrl: './navigation-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -8,6 +8,9 @@
[error]="fieldError('theme.palette.primary')"
[usedBy]="[('builder.usedByButtons' | translate), ('builder.usedByLinks' | translate), ('builder.usedByBadges' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('theme.palette.primary')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('theme.palette.primary')"
><app-color-picker [ariaLabel]="'builder.primaryColor' | translate" [ngModel]="bootstrap.theme.palette.primary" (ngModelChange)="updatePalette('primary', $event)" /></app-form-field>
<app-form-field [label]="'builder.secondaryColor' | translate" [hint]="'builder.secondaryColorDesc' | translate" [error]="fieldError('theme.palette.secondary')"><app-color-picker [ariaLabel]="'builder.secondaryColor' | translate" [ngModel]="bootstrap.theme.palette.secondary" (ngModelChange)="updatePalette('secondary', $event)" /></app-form-field>
<app-form-field
@@ -17,6 +20,9 @@
[error]="fieldError('theme.palette.backgroundPrimary')"
[usedBy]="[('builder.usedByPageBackground' | translate)]"
[usedByLabel]="'builder.usedByLabel' | translate"
[showReset]="isFieldModified('theme.palette.backgroundPrimary')"
[resetLabel]="'builder.resetField' | translate"
(resetClicked)="resetField('theme.palette.backgroundPrimary')"
><app-color-picker [ariaLabel]="'builder.backgroundColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundPrimary" (ngModelChange)="updatePalette('backgroundPrimary', $event)" /></app-form-field>
<app-form-field [label]="'builder.surfaceColor' | translate" [hint]="'builder.surfaceColorDesc' | translate" [error]="fieldError('theme.palette.backgroundSecondary')"><app-color-picker [ariaLabel]="'builder.surfaceColor' | translate" [ngModel]="bootstrap.theme.palette.backgroundSecondary" (ngModelChange)="updatePalette('backgroundSecondary', $event)" /></app-form-field>
<app-form-field [label]="'builder.textColor' | translate" [hint]="'builder.textColorDesc' | translate" [error]="fieldError('theme.palette.textPrimary')"><app-color-picker [ariaLabel]="'builder.textColor' | translate" [ngModel]="bootstrap.theme.palette.textPrimary" (ngModelChange)="updatePalette('textPrimary', $event)" /></app-form-field>

View File

@@ -38,6 +38,10 @@ export class ProjectEditorThemeSectionComponent {
const messageKey = this.facade.fieldError(key);
return messageKey ? this.translate.t(messageKey) : null;
};
readonly isFieldModified = (key: string): boolean => this.facade.modifiedFields().has(key);
resetField(key: string): void {
this.facade.resetField(key);
}
readonly themeModeOptions: ThemeModeOption[] = [
{ value: 'light', labelKey: 'builder.themeModeLight', descKey: 'builder.themeModeLightDesc' },