feat(project-editor): pre-publish change + validation preview
Milestone 5 of the Configuration Engine sprint. - Facade: changeSummary computed - per modified field, before/after values (schema label + stringified diff vs originalBootstrap), reusing modifiedFields from M4. - preview-section: new "changes since last publish" card ahead of the existing export/import/live-preview card - validation issue list (warning/error styled) plus a before/after change table. Reuses the existing, non-destructive ProjectEditorPreviewService.preview() call. - i18n: previewChangesTitle/NoIssues/NoChanges/ChangeField/Before/After in interface + en/ru/hy. Gate: tsc --noEmit, npm test (33/33), arch:check, build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,15 @@ import { EditorSchemaService } from '../schema/editor-schema.service';
|
|||||||
import { History, commit as commitHistory, emptyHistory, redo as redoHistory, undo as undoHistory } from '../schema/history.util';
|
import { History, commit as commitHistory, emptyHistory, redo as redoHistory, undo as undoHistory } from '../schema/history.util';
|
||||||
|
|
||||||
const HISTORY_DEBOUNCE_MS = 300;
|
const HISTORY_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export interface ChangeSummaryRow {
|
||||||
|
readonly fieldKey: string;
|
||||||
|
/** i18n key for the field's label. */
|
||||||
|
readonly labelKey: string;
|
||||||
|
readonly section?: ProjectEditorSectionId;
|
||||||
|
readonly before: string;
|
||||||
|
readonly after: string;
|
||||||
|
}
|
||||||
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
|
||||||
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
|
||||||
|
|
||||||
@@ -123,6 +132,37 @@ export class ProjectEditorFacade {
|
|||||||
}
|
}
|
||||||
return sections;
|
return sections;
|
||||||
});
|
});
|
||||||
|
/** Per-field before/after diff vs the originally loaded/published config, for the pre-publish preview. */
|
||||||
|
readonly changeSummary = computed<ChangeSummaryRow[]>(() => {
|
||||||
|
const current = this.bootstrap();
|
||||||
|
const original = this.state().originalBootstrap;
|
||||||
|
if (!current || !original) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows: ChangeSummaryRow[] = [];
|
||||||
|
for (const key of this.modifiedFields()) {
|
||||||
|
const field = this.schema.getField(key);
|
||||||
|
rows.push({
|
||||||
|
fieldKey: key,
|
||||||
|
labelKey: field?.labelKey ?? key,
|
||||||
|
section: field?.section,
|
||||||
|
before: this.stringifyValue(this.schema.getByPath(original, key)),
|
||||||
|
after: this.stringifyValue(this.schema.getByPath(current, key)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
private stringifyValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
return json.length > 80 ? `${json.slice(0, 77)}…` : json;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
loadBootstrap(): void {
|
loadBootstrap(): void {
|
||||||
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
|
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
|
||||||
|
|||||||
@@ -1,3 +1,38 @@
|
|||||||
|
<app-section-card [title]="'builder.previewChangesTitle' | translate">
|
||||||
|
@if (issues().length > 0) {
|
||||||
|
<ul class="preview-validation" [class.has-errors]="hasBlockingIssues()">
|
||||||
|
@for (issue of issues(); track issue.code) {
|
||||||
|
<li [class.is-warning]="issue.severity === 'warning'" [class.is-error]="issue.severity === 'error'">{{ issue.message | translate }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="preview-validation-ok">{{ 'builder.previewNoIssues' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (changeSummary().length > 0) {
|
||||||
|
<table class="preview-changes">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ 'builder.previewChangeField' | translate }}</th>
|
||||||
|
<th>{{ 'builder.previewChangeBefore' | translate }}</th>
|
||||||
|
<th>{{ 'builder.previewChangeAfter' | translate }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of changeSummary(); track row.fieldKey) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.labelKey | translate }}</td>
|
||||||
|
<td class="preview-before">{{ row.before || '—' }}</td>
|
||||||
|
<td class="preview-after">{{ row.after || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<p class="preview-validation-ok">{{ 'builder.previewNoChanges' | translate }}</p>
|
||||||
|
}
|
||||||
|
</app-section-card>
|
||||||
|
|
||||||
<app-section-card [title]="'builder.preview' | translate">
|
<app-section-card [title]="'builder.preview' | translate">
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<app-button variant="primary" (click)="preview()">{{ 'builder.livePreview' | translate }}</app-button>
|
<app-button variant="primary" (click)="preview()">{{ 'builder.livePreview' | translate }}</app-button>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export class ProjectEditorPreviewSectionComponent {
|
|||||||
readonly exportValue = signal('');
|
readonly exportValue = signal('');
|
||||||
readonly importValue = signal('');
|
readonly importValue = signal('');
|
||||||
readonly importError = this.facade.importError;
|
readonly importError = this.facade.importError;
|
||||||
|
readonly changeSummary = this.facade.changeSummary;
|
||||||
|
readonly issues = this.facade.validationIssues;
|
||||||
|
readonly hasBlockingIssues = this.facade.hasBlockingIssues;
|
||||||
|
|
||||||
refreshExport(): void {
|
refreshExport(): void {
|
||||||
this.exportValue.set(this.facade.exportBootstrap());
|
this.exportValue.set(this.facade.exportBootstrap());
|
||||||
|
|||||||
@@ -170,6 +170,57 @@ button.secondary {
|
|||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-validation {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
color: var(--warning-color, #b45309);
|
||||||
|
|
||||||
|
&.has-errors {
|
||||||
|
color: var(--error-color, #991b1b);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.is-warning {
|
||||||
|
color: var(--warning-color, #b45309);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.is-error {
|
||||||
|
color: var(--error-color, #991b1b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-validation-ok {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-changes {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #d3dad9);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-before {
|
||||||
|
color: var(--text-secondary, #5f6e6a);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-after {
|
||||||
|
color: var(--text-primary, #1e3c38);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.editor-grid.two,
|
.editor-grid.two,
|
||||||
.editor-grid.three,
|
.editor-grid.three,
|
||||||
|
|||||||
@@ -502,6 +502,12 @@ export const en: Translations = {
|
|||||||
searchSuggestions: 'Search Suggestions',
|
searchSuggestions: 'Search Suggestions',
|
||||||
searchHistory: 'Search History',
|
searchHistory: 'Search History',
|
||||||
livePreview: 'Live Preview',
|
livePreview: 'Live Preview',
|
||||||
|
previewChangesTitle: 'Changes since last publish',
|
||||||
|
previewNoIssues: 'No validation issues.',
|
||||||
|
previewNoChanges: 'No changes since the last publish.',
|
||||||
|
previewChangeField: 'Field',
|
||||||
|
previewChangeBefore: 'Before',
|
||||||
|
previewChangeAfter: 'After',
|
||||||
languagesTab: 'Languages',
|
languagesTab: 'Languages',
|
||||||
addLanguage: 'Add Language',
|
addLanguage: 'Add Language',
|
||||||
removeLanguage: 'Remove',
|
removeLanguage: 'Remove',
|
||||||
|
|||||||
@@ -502,6 +502,12 @@ export const hy: Translations = {
|
|||||||
searchSuggestions: 'Որոնման առաջարկներ',
|
searchSuggestions: 'Որոնման առաջարկներ',
|
||||||
searchHistory: 'Որոնման պատմություն',
|
searchHistory: 'Որոնման պատմություն',
|
||||||
livePreview: 'Կենդանի preview',
|
livePreview: 'Կենդանի preview',
|
||||||
|
previewChangesTitle: 'Փոփոխություններ վերջին հրապարակումից ի վեր',
|
||||||
|
previewNoIssues: 'Վավերացման խնդիրներ չկան։',
|
||||||
|
previewNoChanges: 'Փոփոխություններ վերջին հրապարակումից ի վեր չկան։',
|
||||||
|
previewChangeField: 'Դաշտ',
|
||||||
|
previewChangeBefore: 'Մինչ',
|
||||||
|
previewChangeAfter: 'Հետո',
|
||||||
languagesTab: 'Լեզուներ',
|
languagesTab: 'Լեզուներ',
|
||||||
addLanguage: 'Ավելացնել լեզու',
|
addLanguage: 'Ավելացնել լեզու',
|
||||||
removeLanguage: 'Հեռացնել',
|
removeLanguage: 'Հեռացնել',
|
||||||
|
|||||||
@@ -502,6 +502,12 @@ export const ru: Translations = {
|
|||||||
searchSuggestions: 'Поисковые подсказки',
|
searchSuggestions: 'Поисковые подсказки',
|
||||||
searchHistory: 'История поиска',
|
searchHistory: 'История поиска',
|
||||||
livePreview: 'Живое превью',
|
livePreview: 'Живое превью',
|
||||||
|
previewChangesTitle: 'Изменения с последней публикации',
|
||||||
|
previewNoIssues: 'Нет проблем валидации.',
|
||||||
|
previewNoChanges: 'Нет изменений с последней публикации.',
|
||||||
|
previewChangeField: 'Поле',
|
||||||
|
previewChangeBefore: 'До',
|
||||||
|
previewChangeAfter: 'После',
|
||||||
languagesTab: 'Языки',
|
languagesTab: 'Языки',
|
||||||
addLanguage: 'Добавить язык',
|
addLanguage: 'Добавить язык',
|
||||||
removeLanguage: 'Удалить',
|
removeLanguage: 'Удалить',
|
||||||
|
|||||||
@@ -500,6 +500,12 @@ export interface Translations {
|
|||||||
searchSuggestions: string;
|
searchSuggestions: string;
|
||||||
searchHistory: string;
|
searchHistory: string;
|
||||||
livePreview: string;
|
livePreview: string;
|
||||||
|
previewChangesTitle: string;
|
||||||
|
previewNoIssues: string;
|
||||||
|
previewNoChanges: string;
|
||||||
|
previewChangeField: string;
|
||||||
|
previewChangeBefore: string;
|
||||||
|
previewChangeAfter: string;
|
||||||
languagesTab: string;
|
languagesTab: string;
|
||||||
addLanguage: string;
|
addLanguage: string;
|
||||||
removeLanguage: string;
|
removeLanguage: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user