Skip to content

Commit

Permalink
feat: add storage sync
Browse files Browse the repository at this point in the history
* feat: add storage sync
* doc: add storage sync docs
  • Loading branch information
bohoffi authored Feb 26, 2024
1 parent 164585b commit ef46f75
Show file tree
Hide file tree
Showing 11 changed files with 588 additions and 32 deletions.
4 changes: 1 addition & 3 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
<demo-sidebar-cmp>

<div class="nav">
<mat-nav-list>
<a mat-list-item routerLink="/todo">DevTools</a>
<a mat-list-item routerLink="/flight-search">withRedux</a>
<a mat-list-item routerLink="/flight-search-data-service-simple">withDataService (Simple)</a>
<a mat-list-item routerLink="/flight-search-data-service-dynamic">withDataService (Dynamic)</a>
<a mat-list-item routerLink="/flight-search-redux-connector">Redux Connector</a>

<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
</mat-nav-list>
</div>

Expand All @@ -20,5 +19,4 @@
<router-outlet></router-outlet>
</div>
</div>

</demo-sidebar-cmp>
12 changes: 10 additions & 2 deletions apps/demo/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,24 @@ import { FlightSearchSimpleComponent } from './flight-search-data-service-simple
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component';
import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component';
import { TodoStorageSyncComponent } from './todo-storage-sync/todo-storage-sync.component';
import { FlightSearchReducConnectorComponent } from './flight-search-redux-connector/flight-search.component';
import { provideFlightStore } from './flight-search-redux-connector/+state/redux';

export const appRoutes: Route[] = [
{ path: 'todo', component: TodoComponent },
{ path: 'flight-search', component: FlightSearchComponent },
{ path: 'flight-search-data-service-simple', component: FlightSearchSimpleComponent },
{
path: 'flight-search-data-service-simple',
component: FlightSearchSimpleComponent,
},
{ path: 'flight-edit-simple/:id', component: FlightEditSimpleComponent },
{ path: 'flight-search-data-service-dynamic', component: FlightSearchDynamicComponent },
{
path: 'flight-search-data-service-dynamic',
component: FlightSearchDynamicComponent,
},
{ path: 'flight-edit-dynamic/:id', component: FlightEditDynamicComponent },
{ path: 'todo-storage-sync', component: TodoStorageSyncComponent },
{
path: 'flight-search-redux-connector',
providers: [provideFlightStore()],
Expand Down
37 changes: 37 additions & 0 deletions apps/demo/src/app/todo-storage-sync/synced-todo-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import {
withEntities,
setEntity,
removeEntity,
updateEntity,
} from '@ngrx/signals/entities';
import { AddTodo, Todo } from '../todo-store';
import { withStorageSync } from 'ngrx-toolkit';

export const SyncedTodoStore = signalStore(
{ providedIn: 'root' },
withEntities<Todo>(),
withStorageSync({
key: 'todos',
}),
withMethods((store) => {
let currentId = 0;
return {
add(todo: AddTodo) {
patchState(store, setEntity({ id: ++currentId, ...todo }));
},

remove(id: number) {
patchState(store, removeEntity(id));
},

toggleFinished(id: number): void {
const todo = store.entityMap()[id];
patchState(
store,
updateEntity({ id, changes: { finished: !todo.finished } })
);
},
};
})
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<!-- Checkbox Column -->
<ng-container matColumnDef="finished">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row" class="actions">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="checkboxLabel(row)"
[checked]="row.finished"
>
</mat-checkbox>
<mat-icon (click)="removeTodo(row)">delete</mat-icon>
</mat-cell>
</ng-container>

<!-- Name Column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
<mat-cell *matCellDef="let element">{{ element.name }}</mat-cell>
</ng-container>

<!-- Description Column -->
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>Description</mat-header-cell>
<mat-cell *matCellDef="let element">{{ element.description }}</mat-cell>
</ng-container>

<!-- Deadline Column -->
<ng-container matColumnDef="deadline">
<mat-header-cell mat-header-cell *matHeaderCellDef
>Deadline</mat-header-cell
>
<mat-cell mat-cell *matCellDef="let element">{{
element.deadline
}}</mat-cell>
</ng-container>

<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="selection.toggle(row)"
></mat-row>
</mat-table>
Empty file.
38 changes: 38 additions & 0 deletions apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Component, effect, inject } from '@angular/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { SyncedTodoStore } from './synced-todo-store';
import { SelectionModel } from '@angular/cdk/collections';
import { CategoryStore } from '../category.store';
import { Todo } from '../todo-store';

@Component({
selector: 'demo-todo-storage-sync',
standalone: true,
imports: [MatCheckboxModule, MatIconModule, MatTableModule],
templateUrl: './todo-storage-sync.component.html',
styleUrl: './todo-storage-sync.component.scss',
})
export class TodoStorageSyncComponent {
todoStore = inject(SyncedTodoStore);
categoryStore = inject(CategoryStore);

displayedColumns: string[] = ['finished', 'name', 'description', 'deadline'];
dataSource = new MatTableDataSource<Todo>([]);
selection = new SelectionModel<Todo>(true, []);

constructor() {
effect(() => {
this.dataSource.data = this.todoStore.entities();
});
}

checkboxLabel(todo: Todo) {
this.todoStore.toggleFinished(todo.id);
}

removeTodo(todo: Todo) {
this.todoStore.remove(todo.id);
}
}
2 changes: 1 addition & 1 deletion apps/demo/src/app/todo-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface Todo {
deadline?: Date;
}

type AddTodo = Omit<Todo, 'id'>;
export type AddTodo = Omit<Todo, 'id'>;

export const TodoStore = signalStore(
{ providedIn: 'root' },
Expand Down
81 changes: 58 additions & 23 deletions libs/ngrx-toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This extension is very easy to use. Just add it to a `signalStore`. Example:
export const FlightStore = signalStore(
{ providedIn: 'root' },
withDevtools('flights'), // <-- add this
withState({ flights: [] as Flight[] }),
withState({ flights: [] as Flight[] })
// ...
);
```
Expand Down Expand Up @@ -76,18 +76,15 @@ export const FlightStore = signalStore(
return {
load$: create(actions.load).pipe(
switchMap(({ from, to }) =>
httpClient.get<Flight[]>(
'https://demo.angulararchitects.io/api/flight',
{
params: new HttpParams().set('from', from).set('to', to),
},
),
httpClient.get<Flight[]>('https://demo.angulararchitects.io/api/flight', {
params: new HttpParams().set('from', from).set('to', to),
})
),
tap((flights) => actions.loaded({ flights })),
tap((flights) => actions.loaded({ flights }))
),
};
},
}),
})
);
```

Expand All @@ -103,18 +100,18 @@ export const SimpleFlightBookingStore = signalStore(
withCallState(),
withEntities<Flight>(),
withDataService({
dataServiceType: FlightService,
dataServiceType: FlightService,
filter: { from: 'Paris', to: 'New York' },
}),
withUndoRedo(),
withUndoRedo()
);
```

The features ``withCallState`` and ``withUndoRedo`` are optional, but when present, they enrich each other.
The features `withCallState` and `withUndoRedo` are optional, but when present, they enrich each other.

The Data Service needs to implement the ``DataService`` interface:
The Data Service needs to implement the `DataService` interface:

```typescript
```typescript
@Injectable({
providedIn: 'root'
})
Expand Down Expand Up @@ -172,30 +169,30 @@ export class FlightSearchSimpleComponent {

## DataService with Dynamic Properties

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

```typescript
export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withCallState({
collection: 'flight'
collection: 'flight',
}),
withEntities({
entity: type<Flight>(),
collection: 'flight'
withEntities({
entity: type<Flight>(),
collection: 'flight',
}),
withDataService({
dataServiceType: FlightService,
dataServiceType: FlightService,
filter: { from: 'Graz', to: 'Hamburg' },
collection: 'flight'
collection: 'flight',
}),
withUndoRedo({
collections: ['flight'],
}),
})
);
```

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:
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:

```typescript
@Component(...)
Expand Down Expand Up @@ -236,6 +233,44 @@ export class FlightSearchDynamicComponent {
}
```

## Storage Sync `withStorageSync()`

`withStorageSync` adds automatic or manual synchronization with Web Storage (`localstorage`/`sessionstorage`).

> [!WARNING]
> As Web Storage only works in browser environments it will fallback to a stub implementation on server environments.
Example:

```ts
const SyncStore = signalStore(
withStorageSync<User>({
key: 'synced', // key used when writing to/reading from storage
autoSync: false, // read from storage on init and write on state changes - `true` by default
select: (state: User) => Partial<User>, // projection to keep specific slices in sync
parse: (stateString: string) => State, // custom parsing from storage - `JSON.parse` by default
stringify: (state: User) => string, // custom stringification - `JSON.stringify` by default
storage: () => sessionstorage, // factory to select storage to sync with
})
);
```

```ts
@Component(...)
public class SyncedStoreComponent {
private syncStore = inject(SyncStore);

updateFromStorage(): void {
this.syncStore.readFromStorage(); // reads the stored item from storage and patches the state
}

updateStorage(): void {
this.syncStore.writeToStorage(); // writes the current state to storage
}

clearStorage(): void {
this.syncStore.clearStorage(); // clears the stored item in storage

## Redux Connector for the NgRx Signal Store `createReduxState()`

The Redux Connector turns any `signalStore()` into a Gobal State Management Slice following the Redux pattern.
Expand Down
6 changes: 3 additions & 3 deletions libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from './lib/with-redux';

export * from './lib/with-call-state';
export * from './lib/with-undo-redo';
export * from './lib/with-data-service';

export * from './lib/with-data-service'
export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
export * from './lib/redux-connector';
export * from './lib/redux-connector/rxjs-interop';
export * from './lib/redux-connector/rxjs-interop';
Loading

0 comments on commit ef46f75

Please sign in to comment.