diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index e6dba2d..09f0fcd 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -1,5 +1,4 @@ - @@ -20,5 +19,4 @@ - diff --git a/apps/demo/src/app/app.routes.ts b/apps/demo/src/app/app.routes.ts index 8e05941..7d26ae6 100644 --- a/apps/demo/src/app/app.routes.ts +++ b/apps/demo/src/app/app.routes.ts @@ -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()], diff --git a/apps/demo/src/app/todo-storage-sync/synced-todo-store.ts b/apps/demo/src/app/todo-storage-sync/synced-todo-store.ts new file mode 100644 index 0000000..bf4f1f4 --- /dev/null +++ b/apps/demo/src/app/todo-storage-sync/synced-todo-store.ts @@ -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(), + 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 } }) + ); + }, + }; + }) +); diff --git a/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.html b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.html new file mode 100644 index 0000000..d3ecb59 --- /dev/null +++ b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.html @@ -0,0 +1,43 @@ + + + + + + + + delete + + + + + + Name + {{ element.name }} + + + + + Description + {{ element.description }} + + + + + Deadline + {{ + element.deadline + }} + + + + + diff --git a/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.scss b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts new file mode 100644 index 0000000..d8146b3 --- /dev/null +++ b/apps/demo/src/app/todo-storage-sync/todo-storage-sync.component.ts @@ -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([]); + selection = new SelectionModel(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); + } +} diff --git a/apps/demo/src/app/todo-store.ts b/apps/demo/src/app/todo-store.ts index 1baea17..115bc46 100644 --- a/apps/demo/src/app/todo-store.ts +++ b/apps/demo/src/app/todo-store.ts @@ -15,7 +15,7 @@ export interface Todo { deadline?: Date; } -type AddTodo = Omit; +export type AddTodo = Omit; export const TodoStore = signalStore( { providedIn: 'root' }, diff --git a/libs/ngrx-toolkit/README.md b/libs/ngrx-toolkit/README.md index cc317c8..e8f1218 100644 --- a/libs/ngrx-toolkit/README.md +++ b/libs/ngrx-toolkit/README.md @@ -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[] }) // ... ); ``` @@ -76,18 +76,15 @@ export const FlightStore = signalStore( return { load$: create(actions.load).pipe( switchMap(({ from, to }) => - httpClient.get( - 'https://demo.angulararchitects.io/api/flight', - { - params: new HttpParams().set('from', from).set('to', to), - }, - ), + httpClient.get('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 })) ), }; }, - }), + }) ); ``` @@ -103,18 +100,18 @@ export const SimpleFlightBookingStore = signalStore( withCallState(), withEntities(), 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' }) @@ -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(), - collection: 'flight' + withEntities({ + entity: type(), + 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(...) @@ -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({ + 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, // 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. diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 3ffd679..bd0a466 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -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'; \ No newline at end of file diff --git a/libs/ngrx-toolkit/src/lib/with-storage-sync.spec.ts b/libs/ngrx-toolkit/src/lib/with-storage-sync.spec.ts new file mode 100644 index 0000000..abc7b1c --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-storage-sync.spec.ts @@ -0,0 +1,237 @@ +import { getState, patchState, signalStore, withState } from '@ngrx/signals'; +import { withStorageSync } from './with-storage-sync'; +import { TestBed } from '@angular/core/testing'; + +interface StateObject { + foo: string; + age: number; +} +const initialState: StateObject = { + foo: 'bar', + age: 18, +}; +const key = 'FooBar'; + +describe('withStorageSync', () => { + beforeEach(() => { + // make sure to start with a clean storage + localStorage.removeItem(key); + }); + + it('adds methods for storage access to the store', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore(withStorageSync({ key })); + const store = new Store(); + + expect(Object.keys(store)).toEqual([ + 'clearStorage', + 'readFromStorage', + 'writeToStorage', + ]); + }); + }); + + it('offers manual sync using provided methods', () => { + TestBed.runInInjectionContext(() => { + // prefill storage + localStorage.setItem( + key, + JSON.stringify({ + foo: 'baz', + age: 99, + } as StateObject) + ); + + const Store = signalStore(withStorageSync({ key, autoSync: false })); + const store = new Store(); + expect(getState(store)).toEqual({}); + + store.readFromStorage(); + expect(getState(store)).toEqual({ + foo: 'baz', + age: 99, + }); + + patchState(store, { ...initialState }); + TestBed.flushEffects(); + + let storeItem = JSON.parse(localStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + foo: 'baz', + age: 99, + }); + + store.writeToStorage(); + storeItem = JSON.parse(localStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + ...initialState, + }); + + store.clearStorage(); + storeItem = localStorage.getItem(key); + expect(storeItem).toEqual(null); + }); + }); + + describe('autoSync', () => { + it('inits from storage and write to storage on changes when set to `true`', () => { + TestBed.runInInjectionContext(() => { + // prefill storage + localStorage.setItem( + key, + JSON.stringify({ + foo: 'baz', + age: 99, + } as StateObject) + ); + + const Store = signalStore(withStorageSync(key)); + const store = new Store(); + expect(getState(store)).toEqual({ + foo: 'baz', + age: 99, + }); + + patchState(store, { ...initialState }); + TestBed.flushEffects(); + + expect(getState(store)).toEqual({ + ...initialState, + }); + const storeItem = JSON.parse(localStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + ...initialState, + }); + }); + }); + + it('does not init from storage and does write to storage on changes when set to `false`', () => { + TestBed.runInInjectionContext(() => { + // prefill storage + localStorage.setItem( + key, + JSON.stringify({ + foo: 'baz', + age: 99, + } as StateObject) + ); + + const Store = signalStore(withStorageSync({ key, autoSync: false })); + const store = new Store(); + expect(getState(store)).toEqual({}); + + patchState(store, { ...initialState }); + const storeItem = JSON.parse(localStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + foo: 'baz', + age: 99, + }); + }); + }); + }); + + describe('select', () => { + it('syncs the whole state by default', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore(withStorageSync(key)); + const store = new Store(); + + patchState(store, { ...initialState }); + TestBed.flushEffects(); + + const storeItem = JSON.parse(localStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + ...initialState, + }); + }); + }); + + it('syncs selected slices when specified', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(initialState), + withStorageSync({ key, select: ({ foo }) => ({ foo }) }) + ); + const store = new Store(); + + patchState(store, { foo: 'baz' }); + TestBed.flushEffects(); + + const storeItem = JSON.parse(localStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + foo: 'baz', + }); + }); + }); + }); + + describe('parse/stringify', () => { + it('uses custom parsing/stringification when specified', () => { + const parse = (stateString: string) => { + const [foo, age] = stateString.split('_'); + return { + foo, + age: +age, + }; + }; + + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withState(initialState), + withStorageSync({ + key, + parse, + stringify: (state) => `${state.foo}_${state.age}`, + }) + ); + const store = new Store(); + + patchState(store, { foo: 'baz' }); + TestBed.flushEffects(); + + const storeItem = parse(localStorage.getItem(key) || ''); + expect(storeItem).toEqual({ + ...initialState, + foo: 'baz', + }); + }); + }); + }); + + describe('storage factory', () => { + it('uses specified storage', () => { + TestBed.runInInjectionContext(() => { + // prefill storage + sessionStorage.setItem( + key, + JSON.stringify({ + foo: 'baz', + age: 99, + } as StateObject) + ); + + const Store = signalStore( + withStorageSync({ key, storage: () => sessionStorage }) + ); + const store = new Store(); + expect(getState(store)).toEqual({ + foo: 'baz', + age: 99, + }); + + patchState(store, { ...initialState }); + TestBed.flushEffects(); + + expect(getState(store)).toEqual({ + ...initialState, + }); + const storeItem = JSON.parse(sessionStorage.getItem(key) || '{}'); + expect(storeItem).toEqual({ + ...initialState, + }); + + store.clearStorage(); + }); + }); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/with-storage-sync.ts b/libs/ngrx-toolkit/src/lib/with-storage-sync.ts new file mode 100644 index 0000000..cc775e7 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-storage-sync.ts @@ -0,0 +1,160 @@ +import { isPlatformServer } from '@angular/common'; +import { PLATFORM_ID, effect, inject } from '@angular/core'; +import { + SignalStoreFeature, + getState, + patchState, + signalStoreFeature, + withHooks, + withMethods, +} from '@ngrx/signals'; +import { Emtpy } from './shared/empty'; + +type SignalStoreFeatureInput = Pick< + Parameters[0], + 'signals' | 'methods' +> & { + state: State; +}; + +const NOOP = () => {}; + +type WithStorageSyncFeatureResult = { + state: Emtpy; + signals: Emtpy; + methods: { + clearStorage(): void; + readFromStorage(): void; + writeToStorage(): void; + }; +}; + +const StorageSyncStub: Pick< + WithStorageSyncFeatureResult, + 'methods' +>['methods'] = { + clearStorage: NOOP, + readFromStorage: NOOP, + writeToStorage: NOOP, +}; + +export type SyncConfig = { + /** + * The key which is used to access the storage. + */ + key: string; + /** + * Flag indicating if the store should read from storage on init and write to storage on every state change. + * + * `true` by default + */ + autoSync?: boolean; + /** + * Function to select that portion of the state which should be stored. + * + * Returns the whole state object by default + */ + select?: (state: State) => Partial; + /** + * Function used to parse the state coming from storage. + * + * `JSON.parse()` by default + */ + parse?: (stateString: string) => State; + /** + * Function used to tranform the state into a string representation. + * + * `JSON.stringify()` by default + */ + stringify?: (state: State) => string; + /** + * Factory function used to select the storage. + * + * `localstorage` by default + */ + storage?: () => Storage; +}; + +/** + * Enables store synchronization with storage. + * + * Only works on browser platform. + */ +export function withStorageSync< + State extends object, + Input extends SignalStoreFeatureInput +>(key: string): SignalStoreFeature; +export function withStorageSync< + State extends object, + Input extends SignalStoreFeatureInput +>( + config: SyncConfig +): SignalStoreFeature; +export function withStorageSync< + State extends object, + Input extends SignalStoreFeatureInput +>( + configOrKey: SyncConfig | string +): SignalStoreFeature { + const { + key, + autoSync = true, + select = (state: State) => state, + parse = JSON.parse, + stringify = JSON.stringify, + storage: storageFactory = () => localStorage, + } = typeof configOrKey === 'string' ? { key: configOrKey } : configOrKey; + + return signalStoreFeature( + withMethods((store, platformId = inject(PLATFORM_ID)) => { + if (isPlatformServer(platformId)) { + console.warn( + `'withStorageSync' provides non-functional implementation due to server-side execution` + ); + return StorageSyncStub; + } + + const storage = storageFactory(); + + return { + /** + * Removes the item stored in storage. + */ + clearStorage(): void { + storage.removeItem(key); + }, + /** + * Reads item from storage and patches the state. + */ + readFromStorage(): void { + const stateString = storage.getItem(key); + if (stateString) { + patchState(store, parse(stateString)); + } + }, + /** + * Writes selected portion to storage. + */ + writeToStorage(): void { + const slicedState = select(getState(store) as State); + storage.setItem(key, stringify(slicedState)); + }, + }; + }), + withHooks({ + onInit(store, platformId = inject(PLATFORM_ID)) { + if (isPlatformServer(platformId)) { + return; + } + + if (autoSync) { + store.readFromStorage(); + + effect(() => { + store.writeToStorage(); + }); + } + }, + }) + ); +}