diff --git a/.gitignore b/.gitignore index e53e4f53..83bacf8d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,11 @@ testem.log # System Files .DS_Store Thumbs.db +.tool-versions .nx .angular /versions.txt .claude/worktrees -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json diff --git a/.husky/commit-msg b/.husky/commit-msg index e0f5e42b..1abec42e 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,2 +1,2 @@ -pnpm --no -- commitlint --edit ${1} +pnpm -- commitlint --edit ${1} diff --git a/apps/demo/project.json b/apps/demo/project.json index e26a915e..4495724e 100644 --- a/apps/demo/project.json +++ b/apps/demo/project.json @@ -27,7 +27,7 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kb", + "maximumWarning": "1mb", "maximumError": "2mb" }, { diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index 86ad36f6..834445eb 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -36,6 +36,7 @@ >withEntityResources withResource + withUndoRedo diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index d74bd3e5..94e3d32f 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -91,4 +91,11 @@ export const lazyRoutes: Route[] = [ (m) => m.WithResourceComponent, ), }, + { + path: 'with-undo-redo', + loadComponent: () => + import('./with-undo-redo/with-undo-redo.component').then( + (m) => m.WithUndoRedoComponent, + ), + }, ]; diff --git a/apps/demo/src/app/with-undo-redo/undo-redo.store.ts b/apps/demo/src/app/with-undo-redo/undo-redo.store.ts new file mode 100644 index 00000000..541530e9 --- /dev/null +++ b/apps/demo/src/app/with-undo-redo/undo-redo.store.ts @@ -0,0 +1,52 @@ +import { withDevtools, withUndoRedo } from '@angular-architects/ngrx-toolkit'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; + +export type Item = { + id: number; + text: string; +}; + +export type UndoRedoState = { + items: Item[]; + counter: number; +}; + +let nextId = 1; + +export const UndoRedoStore = signalStore( + withDevtools('with-undo-redo'), + withState({ + items: [], + counter: 0, + }), + withMethods((store) => ({ + addItem(text: string) { + patchState(store, (state) => ({ + items: [...state.items, { id: nextId++, text }], + })); + }, + updateItem(id: number, text: string) { + patchState(store, (state) => ({ + items: state.items.map((item) => + item.id === id ? { ...item, text } : item, + ), + })); + }, + removeItem(id: number) { + patchState(store, (state) => ({ + items: state.items.filter((item) => item.id !== id), + })); + }, + increment() { + patchState(store, (state) => ({ counter: state.counter + 1 })); + }, + decrement() { + patchState(store, (state) => ({ counter: state.counter - 1 })); + }, + })), + withUndoRedo({ + keys: ['items', 'counter'], + }), +); + +export type UndoRedoStore = InstanceType; diff --git a/apps/demo/src/app/with-undo-redo/with-undo-redo.component.css b/apps/demo/src/app/with-undo-redo/with-undo-redo.component.css new file mode 100644 index 00000000..080bb625 --- /dev/null +++ b/apps/demo/src/app/with-undo-redo/with-undo-redo.component.css @@ -0,0 +1,36 @@ +.panels { + display: flex; + gap: 24px; + flex-wrap: wrap; + align-items: flex-start; +} + +.card { + min-width: 360px; + flex: 1; +} + +.counter-actions { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.add-item { + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 16px; +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.empty { + color: rgba(0, 0, 0, 0.54); + font-style: italic; +} diff --git a/apps/demo/src/app/with-undo-redo/with-undo-redo.component.html b/apps/demo/src/app/with-undo-redo/with-undo-redo.component.html new file mode 100644 index 00000000..c00691af --- /dev/null +++ b/apps/demo/src/app/with-undo-redo/with-undo-redo.component.html @@ -0,0 +1,99 @@ +

withUndoRedo Playground

+ +
+ + + + Current State + + +

Counter: {{ store.counter() }}

+
+ + +
+ +

Items

+ @if (store.items().length === 0) { +

No items yet. Add one below.

+ } + + @for (item of store.items(); track item.id) { + + @if (editItemId === item.id) { + + + + } @else { + {{ item.id }}. {{ item.text }} + + + } + + } + +
+
+ + + + + Controls + + + +
+ + New item text + + + +
+ + +

Undo / Redo

+
+ + + +
+ + +

Savepoints

+
+ +
+ @if (savepoints().length > 0) { +

Click a savepoint to rollback to that point in time:

+ + @for (sp of savepoints(); track sp) { + + } + + } @else { +

No savepoints created yet.

+ } +
+
+
diff --git a/apps/demo/src/app/with-undo-redo/with-undo-redo.component.ts b/apps/demo/src/app/with-undo-redo/with-undo-redo.component.ts new file mode 100644 index 00000000..5e39e692 --- /dev/null +++ b/apps/demo/src/app/with-undo-redo/with-undo-redo.component.ts @@ -0,0 +1,84 @@ +import { clearUndoRedo } from '@angular-architects/ngrx-toolkit'; +import { DatePipe } from '@angular/common'; +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule } from '@angular/material/list'; +import { UndoRedoStore } from './undo-redo.store'; + +@Component({ + selector: 'demo-with-undo-redo', + imports: [ + FormsModule, + DatePipe, + MatButtonModule, + MatCardModule, + MatIconModule, + MatInputModule, + MatListModule, + ], + providers: [UndoRedoStore], + templateUrl: './with-undo-redo.component.html', + styleUrl: './with-undo-redo.component.css', +}) +export class WithUndoRedoComponent { + readonly store = inject(UndoRedoStore); + + newItemText = ''; + editItemId: number | null = null; + editItemText = ''; + + savepoints = signal([]); + + addItem() { + const text = this.newItemText.trim(); + if (!text) return; + this.store.addItem(text); + this.newItemText = ''; + } + + startEdit(id: number, text: string) { + this.editItemId = id; + this.editItemText = text; + } + + saveEdit() { + if (this.editItemId === null) return; + this.store.updateItem(this.editItemId, this.editItemText); + this.editItemId = null; + this.editItemText = ''; + } + + cancelEdit() { + this.editItemId = null; + this.editItemText = ''; + } + + removeItem(id: number) { + this.store.removeItem(id); + } + + undo() { + this.store.undo(); + } + + redo() { + this.store.redo(); + } + + createSavepoint() { + this.savepoints.update((sp) => [...sp, Date.now()]); + } + + rollbackTo(savepoint: number) { + this.store.rollback(savepoint); + } + + clearStack() { + clearUndoRedo(this.store); + this.savepoints.set([]); + } +} diff --git a/docs/docs/with-undo-redo.md b/docs/docs/with-undo-redo.md index ad9413ed..1a2538d9 100644 --- a/docs/docs/with-undo-redo.md +++ b/docs/docs/with-undo-redo.md @@ -28,7 +28,7 @@ const SyncStore = signalStore( import { clearUndoRedo } from '@angular-architects/ngrx-toolkit'; @Component(...) -public class UndoRedoComponent { +export class UndoRedoComponent { private syncStore = inject(SyncStore); canUndo = this.store.canUndo; // use in template or in ts @@ -53,3 +53,26 @@ public class UndoRedoComponent { } } ``` + +### Rollback + +`rollback` lets you jump back to a specific point in time, restoring the state as it was at that moment. It works by accepting a **savepoint** — a `Date.now()` timestamp you capture before making changes. + +All state changes that occurred after the savepoint are moved to the redo stack, so they can still be re-applied with `redo()`. + +```typescript +// 1. Capture a savepoint before making changes +const savepoint = Date.now(); + +// 2. Make state changes … +store.addItem('first'); +store.addItem('second'); + +// 3. Roll back to the savepoint — both additions are undone +store.rollback(savepoint); + +// 4. The rolled-back changes are available on the redo stack +store.canRedo(); // true +``` + +If the savepoint is at or after the current state (i.e. nothing to roll back), the call is a no-op. diff --git a/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.spec.ts b/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.spec.ts index e99d78b6..f6455113 100644 --- a/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.spec.ts +++ b/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.spec.ts @@ -19,7 +19,10 @@ const newValue = 'new value'; const newerValue = 'newer value'; describe('withUndoRedo', () => { - it('adds methods for undo, redo, canUndo, canRedo', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('adds methods for undo, redo, canUndo, canRedo, rollback', () => { TestBed.runInInjectionContext(() => { const Store = signalStore( withState(testState), @@ -33,6 +36,7 @@ describe('withUndoRedo', () => { 'canRedo', 'undo', 'redo', + 'rollback', '__clearUndoRedo__', 'clearStack', ]); @@ -72,6 +76,24 @@ describe('withUndoRedo', () => { // @ts-expect-error - should not allow invalid collections withUndoRedo({ collections: ['test'] }), ); + + // sub-key (dot-path) support + const nestedState = { filter: { searchTerm: '', page: 1 } }; + signalStore( + withState(nestedState), + // valid top-level key + withUndoRedo({ keys: ['filter'] }), + ); + signalStore( + withState(nestedState), + // valid dot-path sub-key + withUndoRedo({ keys: ['filter.searchTerm'] }), + ); + signalStore( + withState(nestedState), + // @ts-expect-error - should not allow invalid dot-path sub-keys + withUndoRedo({ keys: ['filter.invalid'] }), + ); }); describe('undo and redo', () => { @@ -297,4 +319,236 @@ describe('withUndoRedo', () => { expect(store.canRedo()).toBe(false); }); }); + + describe('rollback', () => { + it('rolls back to a specific savepoint', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(testState), + withMethods((store) => ({ + updateTest: (newTest: string) => + patchState(store, { test: newTest }), + })), + withUndoRedo({ keys: testKeys }), + ); + + const store = new Store(); + jest.advanceTimersByTime(5); + store.updateTest('value1'); + + jest.advanceTimersByTime(5); + const savepoint = Date.now(); + + jest.advanceTimersByTime(5); + store.updateTest('value2'); + + jest.advanceTimersByTime(5); + store.updateTest('value3'); + + expect(store.test()).toEqual('value3'); + + jest.advanceTimersByTime(5); + store.rollback(savepoint); + + expect(store.test()).toEqual('value1'); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(true); + }); + }); + + it('rolls back to initial state when savepoint is before all changes', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(testState), + withMethods((store) => ({ + updateTest: (newTest: string) => + patchState(store, { test: newTest }), + })), + withUndoRedo({ keys: testKeys }), + ); + + const store = new Store(); + + jest.advanceTimersByTime(5); + const initialSavepoint = Date.now(); + + jest.advanceTimersByTime(5); + store.updateTest('value1'); + jest.advanceTimersByTime(5); + store.updateTest('value2'); + jest.advanceTimersByTime(5); + store.updateTest('value3'); + + expect(store.test()).toEqual('value3'); + + jest.advanceTimersByTime(5); + store.rollback(initialSavepoint); + + expect(store.test()).toEqual(''); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(true); + }); + }); + + it('does nothing when savepoint is after all changes', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(testState), + withMethods((store) => ({ + updateTest: (newTest: string) => + patchState(store, { test: newTest }), + })), + withUndoRedo({ keys: testKeys }), + ); + + const store = new Store(); + + jest.advanceTimersByTime(5); + store.updateTest('value1'); + jest.advanceTimersByTime(5); + store.updateTest('value2'); + + jest.advanceTimersByTime(5); + const futureSavepoint = Date.now() + 10000; + + expect(store.test()).toEqual('value2'); + + jest.advanceTimersByTime(5); + store.rollback(futureSavepoint); + + expect(store.test()).toEqual('value2'); + expect(store.canUndo()).toBe(true); + expect(store.canRedo()).toBe(false); + }); + }); + + it('handles rollback with nested dot-path keys', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState({ + filter: { searchTerm: '', page: 1, sort: 'asc' }, + other: 'value', + }), + withMethods((store) => ({ + updateSearchTerm: (term: string) => + patchState(store, { + filter: { ...store.filter(), searchTerm: term }, + }), + updatePage: (page: number) => + patchState(store, { filter: { ...store.filter(), page } }), + updateSort: (sort: string) => + patchState(store, { filter: { ...store.filter(), sort } }), + updateOther: (value: string) => patchState(store, { other: value }), + })), + withUndoRedo({ + keys: ['filter.searchTerm', 'filter.page', 'filter.sort'], + }), + ); + + const store = new Store(); + jest.advanceTimersByTime(5); + store.updateSearchTerm('angular'); + store.updateOther('value2'); + jest.advanceTimersByTime(5); + store.updatePage(2); + jest.advanceTimersByTime(5); + const savepoint = Date.now(); + + jest.advanceTimersByTime(5); + store.updateSort('desc'); + jest.advanceTimersByTime(5); + store.updatePage(3); + store.updateOther('value3'); + + expect(store.filter().searchTerm).toEqual('angular'); + expect(store.filter().page).toEqual(3); + expect(store.filter().sort).toEqual('desc'); + expect(store.other()).toEqual('value3'); + + jest.advanceTimersByTime(5); + store.rollback(savepoint); + + expect(store.filter().searchTerm).toEqual('angular'); + expect(store.filter().page).toEqual(2); + expect(store.filter().sort).toEqual('asc'); + // since there is no rollback for this one + expect(store.other()).toEqual('value3'); + }); + }); + + it('handles rollback with complex nested state', () => { + TestBed.runInInjectionContext(() => { + const complexState = { + user: { + profile: { + name: '', + email: '', + settings: { + theme: 'light', + notifications: true, + }, + }, + }, + }; + + const Store = signalStore( + withState(complexState), + withMethods((store) => ({ + updateName: (name: string) => + patchState(store, { + user: { + ...store.user(), + profile: { + ...store.user().profile, + name, + }, + }, + }), + updateTheme: (theme: string) => + patchState(store, { + user: { + ...store.user(), + profile: { + ...store.user().profile, + settings: { + ...store.user().profile.settings, + theme, + }, + }, + }, + }), + })), + withUndoRedo({ + keys: ['user.profile.name', 'user.profile.settings.theme'], + }), + ); + + const store = new Store(); + + jest.advanceTimersByTime(5); + store.updateName('John Doe'); + jest.advanceTimersByTime(5); + store.updateTheme('dark'); + jest.advanceTimersByTime(5); + const savepoint = Date.now(); + + expect(store.user().profile.name).toEqual('John Doe'); + expect(store.user().profile.settings.theme).toEqual('dark'); + + jest.advanceTimersByTime(5); + store.updateName('Jane Doe'); + jest.advanceTimersByTime(5); + store.updateTheme('light'); + + expect(store.user().profile.name).toEqual('Jane Doe'); + expect(store.user().profile.settings.theme).toEqual('light'); + + jest.advanceTimersByTime(5); + store.rollback(savepoint); + + expect(store.user().profile.name).toEqual('John Doe'); + expect(store.user().profile.settings.theme).toEqual('dark'); + }); + }); + }); }); diff --git a/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.ts b/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.ts index ab7e8400..1917636a 100644 --- a/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.ts +++ b/libs/ngrx-toolkit/src/lib/undo-redo/with-undo-redo.ts @@ -1,10 +1,10 @@ -import { Signal, isSignal, signal, untracked } from '@angular/core'; +import { isSignal, Signal, signal, untracked } from '@angular/core'; import { EmptyFeatureResult, - SignalStoreFeature, - SignalStoreFeatureResult, patchState, + SignalStoreFeature, signalStoreFeature, + SignalStoreFeatureResult, watchState, withComputed, withHooks, @@ -13,7 +13,12 @@ import { import { capitalize } from '../with-data-service'; import { ClearUndoRedoOptions } from './clear-undo-redo'; -export type StackItem = Record; +export type StackState = Record; + +export type StackItem = { + timestamp: number; + stack: StackState; +}; export type NormalizedUndoRedoOptions = { maxStackSize: number; @@ -51,13 +56,87 @@ type ExtractEntityCollections = }[keyof Store['props']] >; +type Primitive = string | number | boolean | bigint | symbol | null | undefined; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFunction = (...args: any[]) => any; + +/** + * Recursively builds a union of dot-path strings for all + * reachable leaf (and intermediate) keys in T. + * Stops at primitives, arrays, Date and Function values. + */ +type DotPath< + T, + Depth extends readonly number[] = [], +> = Depth['length'] extends 10 + ? never + : T extends Primitive | Date | AnyFunction | unknown[] + ? never + : { + [K in Extract]: T[K] extends + | Primitive + | Date + | AnyFunction + | unknown[] + ? K + : K | `${K}.${DotPath}`; + }[Extract]; + type OptionsForState = Partial< Omit > & { collections?: ExtractEntityCollections[]; - keys?: (keyof Store['state'])[]; + keys?: (keyof Store['state'] | DotPath)[]; }; +/** + * Expands a flat StackState that may contain dot-path keys (e.g. "filter.searchTerm") + * into a deeply-nested object suitable for patchState. + */ +function createUpdater(stackState: StackState) { + if (Object.keys(stackState).every((key) => !key.includes('.'))) { + return stackState; + } + + // needs partial state updater because of nested items that need to be spread + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (state: any) => { + const updater: Record = {}; + + for (const [key, value] of Object.entries(stackState)) { + const segments = key.split('.'); + if (segments.length === 1) { + // simple key with value + updater[key] = value; + } else { + // nested key + const firstSegment = segments[0]; + // Start with a copy of the current nested object (only if not already set and actually defined) + if ( + !updater[firstSegment] && + (state as Record)?.[firstSegment] + ) { + updater[firstSegment] = { ...structuredClone(state[firstSegment]) }; + } + + let nestedUpdater = updater; + // loop through all but the last item and create the object if it doesn't exist + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + if (!nestedUpdater[segment]) { + nestedUpdater[segment] = {}; + } + nestedUpdater = nestedUpdater[segment] as Record; + } + // set the value of the nested key + nestedUpdater[segments.at(-1) as string] = value; + } + } + + return updater; + }; +} + export function withUndoRedo( options?: OptionsForState, ): SignalStoreFeature< @@ -70,6 +149,7 @@ export function withUndoRedo( methods: { undo: () => void; redo: () => void; + rollback: (savepoint: number) => void; /** @deprecated Use {@link clearUndoRedo} instead. */ clearStack: () => void; }; @@ -99,7 +179,10 @@ export function withUndoRedo( canRedo.set(redoStack.length !== 0); }; - const keys = [...getUndoRedoKeys(normalized.collections), ...normalized.keys]; + const keys = [ + ...getUndoRedoKeys(normalized.collections), + ...(normalized.keys ?? []), + ]; return signalStoreFeature( withComputed(() => ({ @@ -116,7 +199,7 @@ export function withUndoRedo( if (item) { skipOnce = true; - patchState(store, item); + patchState(store, createUpdater(item.stack)); lastRecord = item; } @@ -131,17 +214,55 @@ export function withUndoRedo( if (item) { skipOnce = true; - patchState(store, item); + patchState(store, createUpdater(item.stack)); lastRecord = item; } updateInternal(); }, + rollback(savepoint: number): void { + // Savepoint is at or after the current state: nothing to roll back. + if (lastRecord && lastRecord.timestamp <= savepoint) { + return; + } + + let item: StackItem | undefined; + while (undoStack.length > 0) { + item = undoStack.pop(); + + if (!item) { + break; + } + + if (lastRecord) { + redoStack.push(lastRecord); + } + lastRecord = item; + + // The most recent state with timestamp <= savepoint is the one + // that should be restored. + if (item.timestamp <= savepoint) { + break; + } + } + + if (item) { + skipOnce = true; + patchState(store, createUpdater(item.stack)); + } + + updateInternal(); + }, __clearUndoRedo__(opts?: ClearUndoRedoOptions): void { undoStack.splice(0); redoStack.splice(0); - if (opts) { + if (!opts) { + lastRecord = { + timestamp: Date.now(), + stack: store, + }; + } else if (opts.lastRecord === null) { lastRecord = opts.lastRecord; } @@ -157,17 +278,37 @@ export function withUndoRedo( withHooks({ onInit(store) { watchState(store, () => { - const cand = keys.reduce((acc, key) => { - const s = (store as Record)[ - key - ]; - if (s && isSignal(s)) { - return { - ...acc, - [key]: s(), - }; + const stateSnapshot = keys.reduce((acc, key) => { + const segments = (key as string).split('.'); + + // Walk the nested DeepSignal chain for each segment + let node: unknown = store as Record; + for (const segment of segments) { + if (isSignal(node)) { + node = (node as Signal)(); + } + + // check whether the node has the segment + if ((node as Record)?.[segment] === undefined) { + node = undefined; + break; + } + + // Navigate to the segment + node = (node as Record)[segment]; } - return acc; + + // no state found for the key + if (node === undefined) { + return acc; + } + + // If the final node is a signal, unwrap it + const value = isSignal(node) ? (node as Signal)() : node; + return { + ...acc, + [key]: value, + }; }, {}); if (normalized.skip > 0) { @@ -186,7 +327,9 @@ export function withUndoRedo( // if the component sends back the undone filter // to the store. // - if (JSON.stringify(cand) === JSON.stringify(lastRecord)) { + if ( + JSON.stringify(stateSnapshot) === JSON.stringify(lastRecord?.stack) + ) { return; } @@ -201,9 +344,8 @@ export function withUndoRedo( undoStack.unshift(); } - lastRecord = cand; - - // Don't propogate current reactive context + lastRecord = { timestamp: Date.now(), stack: stateSnapshot }; + // Don't propagate current reactive context untracked(() => updateInternal()); }); },