Skip to content
4 changes: 3 additions & 1 deletion docs/docs/with-undo-redo.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const SyncStore = signalStore(
```

```typescript
import { clearUndoRedo } from '@angular-architects/ngrx-toolkit';

@Component(...)
public class UndoRedoComponent {
private syncStore = inject(SyncStore);
Expand All @@ -43,7 +45,7 @@ public class UndoRedoComponent {
}

clearStack(): void {
this.store.clearStack();
clearUndoRedo(this.store);
}
}
```
4 changes: 3 additions & 1 deletion libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export {
withRedux,
} from './lib/with-redux';

export { clearUndoRedo } from './lib/undo-redo/clear-undo-redo';
export * from './lib/undo-redo/with-undo-redo';

export * from './lib/with-call-state';
export * from './lib/with-data-service';
export * from './lib/with-pagination';
export { setResetState, withReset } from './lib/with-reset';
export * from './lib/with-undo-redo';

export { withImmutableState } from './lib/immutable-state/with-immutable-state';
export { withIndexedDB } from './lib/storage-sync/features/with-indexed-db';
Expand Down
28 changes: 28 additions & 0 deletions libs/ngrx-toolkit/src/lib/undo-redo/clear-undo-redo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { clearUndoRedo } from './clear-undo-redo';
import { withUndoRedo } from './with-undo-redo';

describe('withUndoRedo', () => {
describe('clearUndoRedo', () => {
it('should throw an error if the store is not configured with withUndoRedo()', () => {
const Store = signalStore({ providedIn: 'root' }, withState({}));
const store = TestBed.inject(Store);

expect(() => clearUndoRedo(store)).toThrow(
'Cannot clear undoRedo, since store is not configured with withUndoRedo()',
);
});

it('should not throw no error if the store is configured with withUndoRedo()', () => {
const Store = signalStore(
{ providedIn: 'root' },
withState({}),
withUndoRedo(),
);
const store = TestBed.inject(Store);

expect(() => clearUndoRedo(store)).not.toThrow();
});
});
});
37 changes: 37 additions & 0 deletions libs/ngrx-toolkit/src/lib/undo-redo/clear-undo-redo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { StateSource } from '@ngrx/signals';

export type ClearUndoRedoOptions<TState extends object> = {
lastRecord: Partial<TState> | null;
};

export type ClearUndoRedoFn<TState extends object> = (
opts?: ClearUndoRedoOptions<TState>,
) => void;

export function clearUndoRedo<TState extends object>(
store: StateSource<TState>,
opts?: ClearUndoRedoOptions<TState>,
): void {
if (canClearUndoRedo(store)) {
store.__clearUndoRedo__(opts);
} else {
throw new Error(
'Cannot clear undoRedo, since store is not configured with withUndoRedo()',
);
}
}

function canClearUndoRedo<TState extends object>(
store: StateSource<TState>,
): store is StateSource<TState> & {
__clearUndoRedo__: ClearUndoRedoFn<TState>;
} {
if (
'__clearUndoRedo__' in store &&
typeof store.__clearUndoRedo__ === 'function'
) {
return true;
} else {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
withState,
} from '@ngrx/signals';
import { addEntity, withEntities } from '@ngrx/signals/entities';
import { withCallState } from './with-call-state';
import { withCallState } from '../with-call-state';
import { clearUndoRedo } from './clear-undo-redo';
import { withUndoRedo } from './with-undo-redo';

const testState = { test: '' };
Expand All @@ -32,6 +33,7 @@ describe('withUndoRedo', () => {
'canRedo',
'undo',
'redo',
'__clearUndoRedo__',
'clearStack',
]);
});
Expand Down Expand Up @@ -260,7 +262,7 @@ describe('withUndoRedo', () => {

store.update('Gordon');

store.clearStack();
clearUndoRedo(store, { lastRecord: null });

// After clearing the undo/redo stack, there is no previous item anymore.
// The following update becomes the first value.
Expand All @@ -270,5 +272,29 @@ describe('withUndoRedo', () => {
expect(store.canUndo()).toBe(false);
expect(store.canRedo()).toBe(false);
});

it('can undo after setting lastRecord', () => {
const Store = signalStore(
{ providedIn: 'root' },
withState(testState),
withMethods((store) => ({
update: (value: string) => patchState(store, { test: value }),
})),
withUndoRedo({ keys: testKeys }),
);

const store = TestBed.inject(Store);

store.update('Alan');

store.update('Gordon');

clearUndoRedo(store, { lastRecord: { test: 'Joan' } });

store.update('Hugh');

expect(store.canUndo()).toBe(true);
expect(store.canRedo()).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
withHooks,
withMethods,
} from '@ngrx/signals';
import { capitalize } from './with-data-service';
import { capitalize } from '../with-data-service';
import { ClearUndoRedoOptions } from './clear-undo-redo';

export type StackItem = Record<string, unknown>;

Expand Down Expand Up @@ -69,11 +70,12 @@ export function withUndoRedo<Input extends EmptyFeatureResult>(
methods: {
undo: () => void;
redo: () => void;
/** @deprecated Use {@link clearUndoRedo} instead. */
clearStack: () => void;
};
}
> {
let previous: StackItem | null = null;
let lastRecord: StackItem | null = null;
let skipOnce = false;

const normalized = {
Expand Down Expand Up @@ -108,40 +110,50 @@ export function withUndoRedo<Input extends EmptyFeatureResult>(
undo(): void {
const item = undoStack.pop();

if (item && previous) {
redoStack.push(previous);
if (item && lastRecord) {
redoStack.push(lastRecord);
}

if (item) {
skipOnce = true;
patchState(store, item);
previous = item;
lastRecord = item;
}

updateInternal();
},
redo(): void {
const item = redoStack.pop();

if (item && previous) {
undoStack.push(previous);
if (item && lastRecord) {
undoStack.push(lastRecord);
}

if (item) {
skipOnce = true;
patchState(store, item);
previous = item;
lastRecord = item;
}

updateInternal();
},
clearStack(): void {
__clearUndoRedo__(opts?: ClearUndoRedoOptions<Input['state']>): void {
undoStack.splice(0);
redoStack.splice(0);
previous = null;

if (opts) {
lastRecord = opts.lastRecord;
}

updateInternal();
},
})),
withMethods((store) => ({
/** @deprecated Use {@link clearUndoRedo} instead. */
clearStack(): void {
store.__clearUndoRedo__();
},
})),
withHooks({
onInit(store) {
watchState(store, () => {
Expand Down Expand Up @@ -174,22 +186,22 @@ export function withUndoRedo<Input extends EmptyFeatureResult>(
// if the component sends back the undone filter
// to the store.
//
if (JSON.stringify(cand) === JSON.stringify(previous)) {
if (JSON.stringify(cand) === JSON.stringify(lastRecord)) {
return;
}

// Clear redoStack after recorded action
redoStack.splice(0);

if (previous) {
undoStack.push(previous);
if (lastRecord) {
undoStack.push(lastRecord);
}

if (redoStack.length > normalized.maxStackSize) {
undoStack.unshift();
}

previous = cand;
lastRecord = cand;

// Don't propogate current reactive context
untracked(() => updateInternal());
Expand Down