Skip to content

Commit 28b7651

Browse files
refactor(devtools): rewrite of withDevtools
- `withDevtools` API is backward-compatible but extended with new functionality. - It now uses an internal service to manage devtool instance registration. - Uses `getState` for accessing the state of the SignalStore, replacing a previous "hackish" approach. - Added tests, integrated into GitHub CI. - New method `renameDevtoolsName()` allows renaming SignalStore instances. - Extended demo app to show global and multiple local (which can get destroyed) SignalStores. - `withDevtools` now accepts tree-shakable feature. We start with just one: `withDisabledNameIndices`.
1 parent f257624 commit 28b7651

32 files changed

+1076
-550
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jobs:
1919
node-version: '18'
2020
cache: "pnpm"
2121
- run: pnpm install --frozen-lockfile
22+
- run: pnpm run test:all
2223
- run: pnpm run build:all
2324
- run: ./integration-tests.sh
2425

apps/demo/src/app/app.component.ts

Lines changed: 17 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,29 @@ import { SelectionModel } from '@angular/cdk/collections';
22
import { Component, effect, inject } from '@angular/core';
33
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
44
import { MatCheckboxModule } from '@angular/material/checkbox';
5-
import { Todo, TodoStore } from './todo-store';
5+
import { Todo, TodoStore } from './devtools/todo-store';
66
import { MatIconModule } from '@angular/material/icon';
77
import { CategoryStore } from './category.store';
88
import { RouterLink, RouterOutlet } from '@angular/router';
9-
import { SidebarComponent } from "./core/sidebar/sidebar.component";
9+
import { SidebarComponent } from './core/sidebar/sidebar.component';
1010
import { CommonModule } from '@angular/common';
1111
import { MatToolbarModule } from '@angular/material/toolbar';
1212
import { MatListItem, MatListModule } from '@angular/material/list';
1313

1414
@Component({
15-
selector: 'demo-root',
16-
templateUrl: './app.component.html',
17-
styleUrl: './app.component.css',
18-
imports: [
19-
MatTableModule,
20-
MatCheckboxModule,
21-
MatIconModule,
22-
MatListModule,
23-
RouterLink,
24-
RouterOutlet,
25-
SidebarComponent,
26-
CommonModule,
27-
MatToolbarModule,
28-
]
15+
selector: 'demo-root',
16+
templateUrl: './app.component.html',
17+
styleUrl: './app.component.css',
18+
imports: [
19+
MatTableModule,
20+
MatCheckboxModule,
21+
MatIconModule,
22+
MatListModule,
23+
RouterLink,
24+
RouterOutlet,
25+
SidebarComponent,
26+
CommonModule,
27+
MatToolbarModule,
28+
],
2929
})
30-
export class AppComponent {
31-
todoStore = inject(TodoStore);
32-
categoryStore = inject(CategoryStore);
33-
34-
displayedColumns: string[] = ['finished', 'name', 'description', 'deadline'];
35-
dataSource = new MatTableDataSource<Todo>([]);
36-
selection = new SelectionModel<Todo>(true, []);
37-
38-
constructor() {
39-
effect(() => {
40-
this.dataSource.data = this.todoStore.entities();
41-
});
42-
}
43-
44-
checkboxLabel(todo: Todo) {
45-
this.todoStore.toggleFinished(todo.id);
46-
}
47-
48-
removeTodo(todo: Todo) {
49-
this.todoStore.remove(todo.id);
50-
}
51-
}
30+
export class AppComponent {}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Component, effect, inject, input } from '@angular/core';
2+
import { MatCard, MatCardModule } from '@angular/material/card';
3+
import { Todo } from './todo-store';
4+
import { signalStore, withState } from '@ngrx/signals';
5+
import {
6+
renameDevtoolsName,
7+
withDevtools,
8+
} from '@angular-architects/ngrx-toolkit';
9+
10+
/**
11+
* This Store can be instantiated multiple times, if the user
12+
* selects different todos.
13+
*
14+
* The devtools extension will start to index the store names.
15+
*
16+
* Since we want to apply our own store name, (depending on the id), we
17+
* run renameDevtoolsStore() in the effect.
18+
*/
19+
const TodoDetailStore = signalStore(
20+
withDevtools('todo-detail'),
21+
withState({ id: 1 })
22+
);
23+
24+
@Component({
25+
selector: 'demo-todo-detail',
26+
template: ` <mat-card>
27+
<mat-card-title>{{ todo().name }}</mat-card-title>
28+
<mat-card-content>
29+
<textarea>{{ todo().description }}</textarea>
30+
</mat-card-content>
31+
</mat-card>`,
32+
imports: [MatCardModule],
33+
providers: [TodoDetailStore],
34+
styles: `
35+
mat-card {
36+
margin: 10px;
37+
}
38+
`,
39+
})
40+
export class TodoDetailComponent {
41+
readonly #todoDetailStore = inject(TodoDetailStore);
42+
todo = input.required<Todo>();
43+
44+
constructor() {
45+
effect(
46+
() => {
47+
renameDevtoolsName(this.#todoDetailStore, `todo-${this.todo().id}`);
48+
},
49+
{ allowSignalWrites: true }
50+
);
51+
}
52+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
signalStore,
3+
withComputed,
4+
withHooks,
5+
withMethods,
6+
withState,
7+
} from '@ngrx/signals';
8+
import {
9+
removeEntity,
10+
setEntity,
11+
updateEntity,
12+
withEntities,
13+
} from '@ngrx/signals/entities';
14+
import { updateState, withDevtools } from '@angular-architects/ngrx-toolkit';
15+
import { computed } from '@angular/core';
16+
17+
export interface Todo {
18+
id: number;
19+
name: string;
20+
finished: boolean;
21+
description?: string;
22+
deadline?: Date;
23+
}
24+
25+
export type AddTodo = Omit<Todo, 'id'>;
26+
27+
export const TodoStore = signalStore(
28+
{ providedIn: 'root' },
29+
withDevtools('todo'),
30+
withEntities<Todo>(),
31+
withState({
32+
selectedIds: [] as number[],
33+
}),
34+
withMethods((store) => {
35+
let currentId = 0;
36+
return {
37+
add(todo: AddTodo) {
38+
updateState(store, 'add todo', setEntity({ id: ++currentId, ...todo }));
39+
},
40+
41+
remove(id: number) {
42+
updateState(store, 'remove todo', removeEntity(id));
43+
},
44+
45+
toggleFinished(id: number): void {
46+
const todo = store.entityMap()[id];
47+
updateState(
48+
store,
49+
'toggle todo',
50+
updateEntity({ id, changes: { finished: !todo.finished } })
51+
);
52+
},
53+
toggleSelectTodo(id: number) {
54+
updateState(store, `select todo ${id}`, ({ selectedIds }) => {
55+
if (selectedIds.includes(id)) {
56+
return {
57+
selectedIds: selectedIds.filter(
58+
(selectedId) => selectedId !== id
59+
),
60+
};
61+
}
62+
return {
63+
selectedIds: [...store.selectedIds(), id],
64+
};
65+
});
66+
},
67+
};
68+
}),
69+
withComputed((state) => ({
70+
selectedTodos: computed(() =>
71+
state.selectedIds().map((id) => state.entityMap()[id])
72+
),
73+
})),
74+
withHooks({
75+
onInit: (store) => {
76+
store.add({
77+
name: 'Go for a Walk',
78+
finished: false,
79+
description:
80+
'Go for a walk in the park to relax and enjoy nature. Walking is a great way to clear your mind and get some exercise. It can help reduce stress and improve your mood. Make sure to wear comfortable shoes and bring a bottle of water. Enjoy the fresh air and take in the scenery around you.',
81+
});
82+
83+
store.add({
84+
name: 'Read a Book',
85+
finished: false,
86+
description:
87+
'Spend some time reading a book. It can be a novel, a non-fiction book, or any other genre you enjoy. Reading can help you relax and learn new things.',
88+
});
89+
90+
store.add({
91+
name: 'Write a Journal',
92+
finished: false,
93+
description:
94+
'Take some time to write in your journal. Reflect on your day, your thoughts, and your feelings. Journaling can be a great way to process emotions and document your life.',
95+
});
96+
97+
store.add({
98+
name: 'Exercise',
99+
finished: false,
100+
description:
101+
'Do some physical exercise. It can be a workout, a run, or any other form of exercise you enjoy. Exercise is important for maintaining physical and mental health.',
102+
});
103+
104+
store.add({
105+
name: 'Cook a Meal',
106+
finished: false,
107+
description:
108+
'Prepare a meal for yourself or your family. Cooking can be a fun and rewarding activity. Try out a new recipe or make one of your favorite dishes.',
109+
});
110+
},
111+
})
112+
);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Component, effect, inject, input } from '@angular/core';
2+
import { MatCheckboxModule } from '@angular/material/checkbox';
3+
import { MatIconModule } from '@angular/material/icon';
4+
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
5+
import { SelectionModel } from '@angular/cdk/collections';
6+
import { Todo, TodoStore } from './todo-store';
7+
import { CategoryStore } from '../category.store';
8+
import { TodoDetailComponent } from './todo-detail.component';
9+
10+
@Component({
11+
selector: 'demo-todo',
12+
template: `
13+
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
14+
<!-- Checkbox Column -->
15+
<ng-container matColumnDef="finished">
16+
<mat-header-cell *matHeaderCellDef></mat-header-cell>
17+
<mat-cell *matCellDef="let row" class="actions">
18+
<mat-checkbox
19+
(click)="$event.stopPropagation()"
20+
(change)="checkboxLabel(row)"
21+
[checked]="row.finished"
22+
>
23+
</mat-checkbox>
24+
<mat-icon (click)="removeTodo(row)">delete</mat-icon>
25+
</mat-cell>
26+
</ng-container>
27+
28+
<!-- Name Column -->
29+
<ng-container matColumnDef="name">
30+
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
31+
<mat-cell *matCellDef="let element">{{ element.name }}</mat-cell>
32+
</ng-container>
33+
34+
<!-- Deadline Column -->
35+
<ng-container matColumnDef="deadline">
36+
<mat-header-cell mat-header-cell *matHeaderCellDef
37+
>Deadline
38+
</mat-header-cell>
39+
<mat-cell mat-cell *matCellDef="let element"
40+
>{{ element.deadline }}
41+
</mat-cell>
42+
</ng-container>
43+
44+
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
45+
<mat-row
46+
*matRowDef="let row; columns: displayedColumns"
47+
(click)="selection.toggle(row)"
48+
></mat-row>
49+
</mat-table>
50+
51+
<div class="details">
52+
@for (todo of todoStore.selectedTodos(); track todo) {
53+
<demo-todo-detail [todo]="todo"></demo-todo-detail>
54+
}
55+
</div>
56+
`,
57+
styles: `.actions {
58+
display: flex;
59+
align-items: center;
60+
}
61+
62+
.details {
63+
margin: 20px;
64+
display: flex;
65+
}
66+
`,
67+
imports: [
68+
MatCheckboxModule,
69+
MatIconModule,
70+
MatTableModule,
71+
TodoDetailComponent,
72+
],
73+
})
74+
export class TodoComponent {
75+
todoStore = inject(TodoStore);
76+
77+
displayedColumns: string[] = ['finished', 'name', 'deadline'];
78+
dataSource = new MatTableDataSource<Todo>([]);
79+
selection = new SelectionModel<Todo>(true, []);
80+
81+
constructor() {
82+
effect(() => {
83+
this.dataSource.data = this.todoStore.entities();
84+
});
85+
}
86+
87+
checkboxLabel(todo: Todo) {
88+
this.todoStore.toggleSelectTodo(todo.id);
89+
}
90+
91+
removeTodo(todo: Todo) {
92+
this.todoStore.remove(todo.id);
93+
}
94+
}

apps/demo/src/app/lazy-routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Route } from '@angular/router';
2-
import { TodoComponent } from './todo/todo.component';
32
import { FlightSearchComponent } from './flight-search/flight-search.component';
43
import { FlightSearchSimpleComponent } from './flight-search-data-service-simple/flight-search-simple.component';
54
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
@@ -9,6 +8,7 @@ import { TodoStorageSyncComponent } from './todo-storage-sync/todo-storage-sync.
98
import { FlightSearchWithPaginationComponent } from './flight-search-with-pagination/flight-search-with-pagination.component';
109
import { FlightSearchReducConnectorComponent } from './flight-search-redux-connector/flight-search.component';
1110
import { provideFlightStore } from './flight-search-redux-connector/+state/redux';
11+
import { TodoComponent } from './devtools/todo.component';
1212

1313
export const lazyRoutes: Route[] = [
1414
{ path: 'todo', component: TodoComponent },

0 commit comments

Comments
 (0)