feat(project-editor): session undo/redo and modified-field tracking
Milestone 4 of the Configuration Engine sprint. - Add schema/history.util: pure undo/redo reducer (commit/undo/redo, depth cap) with full spec coverage. - Facade: debounced snapshot history (~300ms coalesce so a typing burst = one undo step); undo()/redo() route through the draft-save path so autosave never desyncs; canUndo/canRedo; history cleared on load/publish/resetDraft. modifiedFields (schema-diff vs original) + modifiedSections computeds. - save-bar: Undo/Redo buttons. Page: Ctrl/Cmd+Z / Shift+Z / Y shortcuts (skipped while a text field is focused so native text undo is preserved); beforeunload guard already present. - nav: amber modified-field dot per section (when no blocking badge). - i18n: builder.undo / builder.redo 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:
60
src/app/features/project-editor/schema/history.util.spec.ts
Normal file
60
src/app/features/project-editor/schema/history.util.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { commit, emptyHistory, redo, undo } from './history.util';
|
||||
|
||||
describe('history reducer', () => {
|
||||
it('starts empty', () => {
|
||||
const history = emptyHistory<number>();
|
||||
expect(history.past).toEqual([]);
|
||||
expect(history.future).toEqual([]);
|
||||
});
|
||||
|
||||
it('commit records an undo point and clears the redo stack', () => {
|
||||
let history = emptyHistory<number>();
|
||||
history = commit(history, 1);
|
||||
history = commit({ ...history, future: [9] }, 2);
|
||||
expect(history.past).toEqual([1, 2]);
|
||||
expect(history.future).toEqual([]);
|
||||
});
|
||||
|
||||
it('caps history depth at the limit', () => {
|
||||
let history = emptyHistory<number>();
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
history = commit(history, i, 3);
|
||||
}
|
||||
expect(history.past).toEqual([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('undo steps back and moves current onto the redo stack', () => {
|
||||
const history = { past: [1, 2], future: [] as number[] };
|
||||
const step = undo(history, 3);
|
||||
expect(step).not.toBeNull();
|
||||
expect(step!.value).toBe(2);
|
||||
expect(step!.history.past).toEqual([1]);
|
||||
expect(step!.history.future).toEqual([3]);
|
||||
});
|
||||
|
||||
it('undo returns null when there is nothing to undo', () => {
|
||||
expect(undo(emptyHistory<number>(), 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('redo steps forward and moves current back onto the undo stack', () => {
|
||||
const history = { past: [1], future: [3] };
|
||||
const step = redo(history, 2);
|
||||
expect(step).not.toBeNull();
|
||||
expect(step!.value).toBe(3);
|
||||
expect(step!.history.past).toEqual([1, 2]);
|
||||
expect(step!.history.future).toEqual([]);
|
||||
});
|
||||
|
||||
it('redo returns null when there is nothing to redo', () => {
|
||||
expect(redo(emptyHistory<number>(), 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('undo then redo round-trips to the same value', () => {
|
||||
const start = { past: [10, 20], future: [] as number[] };
|
||||
const undone = undo(start, 30)!;
|
||||
const redone = redo(undone.history, undone.value)!;
|
||||
expect(redone.value).toBe(30);
|
||||
expect(redone.history.past).toEqual([10, 20]);
|
||||
expect(redone.history.future).toEqual([]);
|
||||
});
|
||||
});
|
||||
52
src/app/features/project-editor/schema/history.util.ts
Normal file
52
src/app/features/project-editor/schema/history.util.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Pure undo/redo history reducer over immutable snapshots. Framework-agnostic
|
||||
* so it can be unit-tested in isolation; the facade wraps it with debounced
|
||||
* commits and Angular signals. `past` is oldest-to-newest; `future` is the
|
||||
* redo stack, newest-undone first.
|
||||
*/
|
||||
|
||||
export const HISTORY_LIMIT = 50;
|
||||
|
||||
export interface History<T> {
|
||||
readonly past: readonly T[];
|
||||
readonly future: readonly T[];
|
||||
}
|
||||
|
||||
export interface HistoryStep<T> {
|
||||
readonly history: History<T>;
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
export function emptyHistory<T>(): History<T> {
|
||||
return { past: [], future: [] };
|
||||
}
|
||||
|
||||
/** Records a snapshot as a new undo point and drops the redo stack. Caps depth at `limit`. */
|
||||
export function commit<T>(history: History<T>, snapshot: T, limit = HISTORY_LIMIT): History<T> {
|
||||
const past = [...history.past, snapshot].slice(-limit);
|
||||
return { past, future: [] };
|
||||
}
|
||||
|
||||
/** Steps back one snapshot, pushing `current` onto the redo stack. Null when nothing to undo. */
|
||||
export function undo<T>(history: History<T>, current: T): HistoryStep<T> | null {
|
||||
if (history.past.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const value = history.past[history.past.length - 1];
|
||||
return {
|
||||
history: { past: history.past.slice(0, -1), future: [current, ...history.future] },
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
/** Steps forward one snapshot, pushing `current` back onto the undo stack. Null when nothing to redo. */
|
||||
export function redo<T>(history: History<T>, current: T): HistoryStep<T> | null {
|
||||
if (history.future.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const value = history.future[0];
|
||||
return {
|
||||
history: { past: [...history.past, current], future: history.future.slice(1) },
|
||||
value,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user