Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ testem.log
# System Files
.DS_Store
Thumbs.db
.tool-versions

.nx
.angular
/versions.txt

.claude/worktrees
.claude/settings.local.json
.claude/settings.local.json
2 changes: 1 addition & 1 deletion .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@

pnpm --no -- commitlint --edit ${1}
pnpm -- commitlint --edit ${1}
2 changes: 1 addition & 1 deletion apps/demo/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
Expand Down
1 change: 1 addition & 0 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
>withEntityResources</a
>
<a mat-list-item routerLink="/with-resource">withResource</a>
<a mat-list-item routerLink="/with-undo-redo">withUndoRedo</a>
</mat-nav-list>
</mat-drawer>
<mat-drawer-content>
Expand Down
7 changes: 7 additions & 0 deletions apps/demo/src/app/lazy-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
},
];
52 changes: 52 additions & 0 deletions apps/demo/src/app/with-undo-redo/undo-redo.store.ts
Original file line number Diff line number Diff line change
@@ -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<UndoRedoState>({
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<typeof UndoRedoStore>;
36 changes: 36 additions & 0 deletions apps/demo/src/app/with-undo-redo/with-undo-redo.component.css
Original file line number Diff line number Diff line change
@@ -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;
}
99 changes: 99 additions & 0 deletions apps/demo/src/app/with-undo-redo/with-undo-redo.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<h2>withUndoRedo Playground</h2>

<section class="panels">
<!-- State display -->
<mat-card class="card">
<mat-card-header>
<mat-card-title>Current State</mat-card-title>
</mat-card-header>
<mat-card-content>
<h3>Counter: {{ store.counter() }}</h3>
<div class="counter-actions">
<button mat-flat-button (click)="store.increment()">+1</button>
<button mat-flat-button (click)="store.decrement()">-1</button>
</div>

<h3>Items</h3>
@if (store.items().length === 0) {
<p class="empty">No items yet. Add one below.</p>
}
<mat-list>
@for (item of store.items(); track item.id) {
<mat-list-item>
@if (editItemId === item.id) {
<input
matInput
[(ngModel)]="editItemText"
(keyup.enter)="saveEdit()"
/>
<button mat-icon-button (click)="saveEdit()">
<mat-icon>check</mat-icon>
</button>
<button mat-icon-button (click)="cancelEdit()">
<mat-icon>close</mat-icon>
</button>
} @else {
<span>{{ item.id }}. {{ item.text }}</span>
<button mat-icon-button (click)="startEdit(item.id, item.text)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button (click)="removeItem(item.id)">
<mat-icon>delete</mat-icon>
</button>
}
</mat-list-item>
}
</mat-list>
</mat-card-content>
</mat-card>

<!-- Controls -->
<mat-card class="card">
<mat-card-header>
<mat-card-title>Controls</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Add item -->
<div class="add-item">
<mat-form-field>
<mat-label>New item text</mat-label>
<input matInput [(ngModel)]="newItemText" (keyup.enter)="addItem()" />
</mat-form-field>
<button mat-flat-button (click)="addItem()">Add Item</button>
</div>

<!-- Undo / Redo -->
<h3>Undo / Redo</h3>
<div class="actions">
<button mat-flat-button [disabled]="!store.canUndo()" (click)="undo()">
<mat-icon>undo</mat-icon> Undo
</button>
<button mat-flat-button [disabled]="!store.canRedo()" (click)="redo()">
<mat-icon>redo</mat-icon> Redo
</button>
<button mat-stroked-button (click)="clearStack()">Clear Stack</button>
</div>

<!-- Savepoints -->
<h3>Savepoints</h3>
<div class="actions">
<button mat-flat-button (click)="createSavepoint()">
<mat-icon>flag</mat-icon> Create Savepoint
</button>
</div>
@if (savepoints().length > 0) {
<p>Click a savepoint to rollback to that point in time:</p>
<mat-action-list>
@for (sp of savepoints(); track sp) {
<button mat-list-item (click)="rollbackTo(sp)">
<mat-icon matListItemIcon>restore</mat-icon>
{{ sp | date: 'HH:mm:ss.SSS' }}
</button>
}
</mat-action-list>
} @else {
<p class="empty">No savepoints created yet.</p>
}
</mat-card-content>
</mat-card>
</section>
84 changes: 84 additions & 0 deletions apps/demo/src/app/with-undo-redo/with-undo-redo.component.ts
Original file line number Diff line number Diff line change
@@ -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<number[]>([]);

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([]);
}
}
25 changes: 24 additions & 1 deletion docs/docs/with-undo-redo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Loading
Loading