Skip to content

Commit ef46f75

Browse files
authored
feat: add storage sync
* feat: add storage sync * doc: add storage sync docs
1 parent 164585b commit ef46f75

File tree

11 files changed

+588
-32
lines changed

11 files changed

+588
-32
lines changed

apps/demo/src/app/app.component.html

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
<demo-sidebar-cmp>
2-
32
<div class="nav">
43
<mat-nav-list>
54
<a mat-list-item routerLink="/todo">DevTools</a>
65
<a mat-list-item routerLink="/flight-search">withRedux</a>
76
<a mat-list-item routerLink="/flight-search-data-service-simple">withDataService (Simple)</a>
87
<a mat-list-item routerLink="/flight-search-data-service-dynamic">withDataService (Dynamic)</a>
98
<a mat-list-item routerLink="/flight-search-redux-connector">Redux Connector</a>
10-
9+
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
1110
</mat-nav-list>
1211
</div>
1312

@@ -20,5 +19,4 @@
2019
<router-outlet></router-outlet>
2120
</div>
2221
</div>
23-
2422
</demo-sidebar-cmp>

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@ import { FlightSearchSimpleComponent } from './flight-search-data-service-simple
55
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
66
import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component';
77
import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component';
8+
import { TodoStorageSyncComponent } from './todo-storage-sync/todo-storage-sync.component';
89
import { FlightSearchReducConnectorComponent } from './flight-search-redux-connector/flight-search.component';
910
import { provideFlightStore } from './flight-search-redux-connector/+state/redux';
1011

1112
export const appRoutes: Route[] = [
1213
{ path: 'todo', component: TodoComponent },
1314
{ path: 'flight-search', component: FlightSearchComponent },
14-
{ path: 'flight-search-data-service-simple', component: FlightSearchSimpleComponent },
15+
{
16+
path: 'flight-search-data-service-simple',
17+
component: FlightSearchSimpleComponent,
18+
},
1519
{ path: 'flight-edit-simple/:id', component: FlightEditSimpleComponent },
16-
{ path: 'flight-search-data-service-dynamic', component: FlightSearchDynamicComponent },
20+
{
21+
path: 'flight-search-data-service-dynamic',
22+
component: FlightSearchDynamicComponent,
23+
},
1724
{ path: 'flight-edit-dynamic/:id', component: FlightEditDynamicComponent },
25+
{ path: 'todo-storage-sync', component: TodoStorageSyncComponent },
1826
{
1927
path: 'flight-search-redux-connector',
2028
providers: [provideFlightStore()],
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { patchState, signalStore, withMethods } from '@ngrx/signals';
2+
import {
3+
withEntities,
4+
setEntity,
5+
removeEntity,
6+
updateEntity,
7+
} from '@ngrx/signals/entities';
8+
import { AddTodo, Todo } from '../todo-store';
9+
import { withStorageSync } from 'ngrx-toolkit';
10+
11+
export const SyncedTodoStore = signalStore(
12+
{ providedIn: 'root' },
13+
withEntities<Todo>(),
14+
withStorageSync({
15+
key: 'todos',
16+
}),
17+
withMethods((store) => {
18+
let currentId = 0;
19+
return {
20+
add(todo: AddTodo) {
21+
patchState(store, setEntity({ id: ++currentId, ...todo }));
22+
},
23+
24+
remove(id: number) {
25+
patchState(store, removeEntity(id));
26+
},
27+
28+
toggleFinished(id: number): void {
29+
const todo = store.entityMap()[id];
30+
patchState(
31+
store,
32+
updateEntity({ id, changes: { finished: !todo.finished } })
33+
);
34+
},
35+
};
36+
})
37+
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
2+
<!-- Checkbox Column -->
3+
<ng-container matColumnDef="finished">
4+
<mat-header-cell *matHeaderCellDef></mat-header-cell>
5+
<mat-cell *matCellDef="let row" class="actions">
6+
<mat-checkbox
7+
(click)="$event.stopPropagation()"
8+
(change)="checkboxLabel(row)"
9+
[checked]="row.finished"
10+
>
11+
</mat-checkbox>
12+
<mat-icon (click)="removeTodo(row)">delete</mat-icon>
13+
</mat-cell>
14+
</ng-container>
15+
16+
<!-- Name Column -->
17+
<ng-container matColumnDef="name">
18+
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
19+
<mat-cell *matCellDef="let element">{{ element.name }}</mat-cell>
20+
</ng-container>
21+
22+
<!-- Description Column -->
23+
<ng-container matColumnDef="description">
24+
<mat-header-cell *matHeaderCellDef>Description</mat-header-cell>
25+
<mat-cell *matCellDef="let element">{{ element.description }}</mat-cell>
26+
</ng-container>
27+
28+
<!-- Deadline Column -->
29+
<ng-container matColumnDef="deadline">
30+
<mat-header-cell mat-header-cell *matHeaderCellDef
31+
>Deadline</mat-header-cell
32+
>
33+
<mat-cell mat-cell *matCellDef="let element">{{
34+
element.deadline
35+
}}</mat-cell>
36+
</ng-container>
37+
38+
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
39+
<mat-row
40+
*matRowDef="let row; columns: displayedColumns"
41+
(click)="selection.toggle(row)"
42+
></mat-row>
43+
</mat-table>

apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.scss

Whitespace-only changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Component, effect, inject } 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 { SyncedTodoStore } from './synced-todo-store';
6+
import { SelectionModel } from '@angular/cdk/collections';
7+
import { CategoryStore } from '../category.store';
8+
import { Todo } from '../todo-store';
9+
10+
@Component({
11+
selector: 'demo-todo-storage-sync',
12+
standalone: true,
13+
imports: [MatCheckboxModule, MatIconModule, MatTableModule],
14+
templateUrl: './todo-storage-sync.component.html',
15+
styleUrl: './todo-storage-sync.component.scss',
16+
})
17+
export class TodoStorageSyncComponent {
18+
todoStore = inject(SyncedTodoStore);
19+
categoryStore = inject(CategoryStore);
20+
21+
displayedColumns: string[] = ['finished', 'name', 'description', 'deadline'];
22+
dataSource = new MatTableDataSource<Todo>([]);
23+
selection = new SelectionModel<Todo>(true, []);
24+
25+
constructor() {
26+
effect(() => {
27+
this.dataSource.data = this.todoStore.entities();
28+
});
29+
}
30+
31+
checkboxLabel(todo: Todo) {
32+
this.todoStore.toggleFinished(todo.id);
33+
}
34+
35+
removeTodo(todo: Todo) {
36+
this.todoStore.remove(todo.id);
37+
}
38+
}

apps/demo/src/app/todo-store.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface Todo {
1515
deadline?: Date;
1616
}
1717

18-
type AddTodo = Omit<Todo, 'id'>;
18+
export type AddTodo = Omit<Todo, 'id'>;
1919

2020
export const TodoStore = signalStore(
2121
{ providedIn: 'root' },

libs/ngrx-toolkit/README.md

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ This extension is very easy to use. Just add it to a `signalStore`. Example:
4040
export const FlightStore = signalStore(
4141
{ providedIn: 'root' },
4242
withDevtools('flights'), // <-- add this
43-
withState({ flights: [] as Flight[] }),
43+
withState({ flights: [] as Flight[] })
4444
// ...
4545
);
4646
```
@@ -76,18 +76,15 @@ export const FlightStore = signalStore(
7676
return {
7777
load$: create(actions.load).pipe(
7878
switchMap(({ from, to }) =>
79-
httpClient.get<Flight[]>(
80-
'https://demo.angulararchitects.io/api/flight',
81-
{
82-
params: new HttpParams().set('from', from).set('to', to),
83-
},
84-
),
79+
httpClient.get<Flight[]>('https://demo.angulararchitects.io/api/flight', {
80+
params: new HttpParams().set('from', from).set('to', to),
81+
})
8582
),
86-
tap((flights) => actions.loaded({ flights })),
83+
tap((flights) => actions.loaded({ flights }))
8784
),
8885
};
8986
},
90-
}),
87+
})
9188
);
9289
```
9390

@@ -103,18 +100,18 @@ export const SimpleFlightBookingStore = signalStore(
103100
withCallState(),
104101
withEntities<Flight>(),
105102
withDataService({
106-
dataServiceType: FlightService,
103+
dataServiceType: FlightService,
107104
filter: { from: 'Paris', to: 'New York' },
108105
}),
109-
withUndoRedo(),
106+
withUndoRedo()
110107
);
111108
```
112109

113-
The features ``withCallState`` and ``withUndoRedo`` are optional, but when present, they enrich each other.
110+
The features `withCallState` and `withUndoRedo` are optional, but when present, they enrich each other.
114111

115-
The Data Service needs to implement the ``DataService`` interface:
112+
The Data Service needs to implement the `DataService` interface:
116113

117-
```typescript
114+
```typescript
118115
@Injectable({
119116
providedIn: 'root'
120117
})
@@ -172,30 +169,30 @@ export class FlightSearchSimpleComponent {
172169

173170
## DataService with Dynamic Properties
174171

175-
To avoid naming conflicts, the properties set up by ``withDataService`` and the connected features can be configured in a typesafe way:
172+
To avoid naming conflicts, the properties set up by `withDataService` and the connected features can be configured in a typesafe way:
176173

177174
```typescript
178175
export const FlightBookingStore = signalStore(
179176
{ providedIn: 'root' },
180177
withCallState({
181-
collection: 'flight'
178+
collection: 'flight',
182179
}),
183-
withEntities({
184-
entity: type<Flight>(),
185-
collection: 'flight'
180+
withEntities({
181+
entity: type<Flight>(),
182+
collection: 'flight',
186183
}),
187184
withDataService({
188-
dataServiceType: FlightService,
185+
dataServiceType: FlightService,
189186
filter: { from: 'Graz', to: 'Hamburg' },
190-
collection: 'flight'
187+
collection: 'flight',
191188
}),
192189
withUndoRedo({
193190
collections: ['flight'],
194-
}),
191+
})
195192
);
196193
```
197194

198-
This setup makes them use ``flight`` as part of the used property names. As these implementations respect the Type Script type system, the compiler will make sure these properties are used in a typesafe way:
195+
This setup makes them use `flight` as part of the used property names. As these implementations respect the Type Script type system, the compiler will make sure these properties are used in a typesafe way:
199196

200197
```typescript
201198
@Component(...)
@@ -236,6 +233,44 @@ export class FlightSearchDynamicComponent {
236233
}
237234
```
238235

236+
## Storage Sync `withStorageSync()`
237+
238+
`withStorageSync` adds automatic or manual synchronization with Web Storage (`localstorage`/`sessionstorage`).
239+
240+
> [!WARNING]
241+
> As Web Storage only works in browser environments it will fallback to a stub implementation on server environments.
242+
243+
Example:
244+
245+
```ts
246+
const SyncStore = signalStore(
247+
withStorageSync<User>({
248+
key: 'synced', // key used when writing to/reading from storage
249+
autoSync: false, // read from storage on init and write on state changes - `true` by default
250+
select: (state: User) => Partial<User>, // projection to keep specific slices in sync
251+
parse: (stateString: string) => State, // custom parsing from storage - `JSON.parse` by default
252+
stringify: (state: User) => string, // custom stringification - `JSON.stringify` by default
253+
storage: () => sessionstorage, // factory to select storage to sync with
254+
})
255+
);
256+
```
257+
258+
```ts
259+
@Component(...)
260+
public class SyncedStoreComponent {
261+
private syncStore = inject(SyncStore);
262+
263+
updateFromStorage(): void {
264+
this.syncStore.readFromStorage(); // reads the stored item from storage and patches the state
265+
}
266+
267+
updateStorage(): void {
268+
this.syncStore.writeToStorage(); // writes the current state to storage
269+
}
270+
271+
clearStorage(): void {
272+
this.syncStore.clearStorage(); // clears the stored item in storage
273+
239274
## Redux Connector for the NgRx Signal Store `createReduxState()`
240275

241276
The Redux Connector turns any `signalStore()` into a Gobal State Management Slice following the Redux pattern.

libs/ngrx-toolkit/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export * from './lib/with-redux';
33

44
export * from './lib/with-call-state';
55
export * from './lib/with-undo-redo';
6-
export * from './lib/with-data-service';
7-
6+
export * from './lib/with-data-service'
7+
export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
88
export * from './lib/redux-connector';
9-
export * from './lib/redux-connector/rxjs-interop';
9+
export * from './lib/redux-connector/rxjs-interop';

0 commit comments

Comments
 (0)