diff --git a/libs/ngrx-toolkit/src/lib/with-data-service.spec.ts b/libs/ngrx-toolkit/src/lib/with-data-service.spec.ts index 075e064..3abcf2c 100644 --- a/libs/ngrx-toolkit/src/lib/with-data-service.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-data-service.spec.ts @@ -1,9 +1,8 @@ import { Injectable } from '@angular/core'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { Observable, firstValueFrom, of, delay } from 'rxjs'; import { signalStore, type } from '@ngrx/signals'; -import { withEntities } from '@ngrx/signals/entities'; -import { EntityId } from '@ngrx/signals/entities'; +import { EntityId, withEntities } from '@ngrx/signals/entities'; +import { delay, firstValueFrom, Observable, of } from 'rxjs'; import { withCallState } from './with-call-state'; import { DataService, withDataService } from './with-data-service'; @@ -39,6 +38,19 @@ describe('withDataService', () => { expect(store.flightEntities().length).toBe(1); }); })); + it('should load from a service and set entities in the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + expect(store.entities().length).toBe(0); + + store.load(); + tick(); + + expect(store.entities().length).toBe(1); + }); + })); it('should load by ID from a service and set entities in the store', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -65,6 +77,21 @@ describe('withDataService', () => { expect(store.currentFlight()).toEqual(createFlight({ id: 2 })); }); })); + it('should load by ID from a service and set entities in the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + store.loadById(2); + + tick(); + + expect(store.current()).toEqual( + createFlightWithCustomId({ flightId: '2' }) + ); + }); + })); it('should create from a service and set an entity in the store', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -97,6 +124,24 @@ describe('withDataService', () => { expect(store.currentFlight()).toEqual(createFlight({ id: 3 })); }); })); + it('should create from a service and set an entity in the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + expect(store.entities().length).toBe(0); + + store.create(createFlightWithCustomId({ flightId: '3' })); + + tick(); + + expect(store.entities().length).toBe(1); + expect(store.current()).toEqual( + createFlightWithCustomId({ flightId: '3' }) + ); + }); + })); it('should update from a service and update an entity in the store', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -129,6 +174,26 @@ describe('withDataService', () => { expect(store.currentFlight()).toEqual(createFlight({ id: 3 })); }); })); + it('should update from a service and update an entity in the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + expect(store.entities().length).toBe(0); + + store.create( + createFlightWithCustomId({ flightId: '3', from: 'Wadena MN' }) + ); + tick(); + store.update(createFlightWithCustomId({ flightId: '3' })); + tick(); + + expect(store.current()).toEqual( + createFlightWithCustomId({ flightId: '3' }) + ); + }); + })); it('should update all from a service and update all entities in the store', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -165,6 +230,35 @@ describe('withDataService', () => { expect(store.flightEntities().at(1)).toEqual(createFlight({ id: 4 })); }); })); + it('should update all from a service and update all entities in the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + expect(store.entities().length).toBe(0); + + store.create( + createFlightWithCustomId({ flightId: '3', from: 'Wadena MN' }) + ); + store.create( + createFlightWithCustomId({ flightId: '4', from: 'Wadena MN' }) + ); + tick(); + store.updateAll([ + createFlightWithCustomId({ flightId: '3' }), + createFlightWithCustomId({ flightId: '4' }), + ]); + tick(); + expect(store.entities().length).toBe(2); + expect(store.entities().at(0)).toEqual( + createFlightWithCustomId({ flightId: '3' }) + ); + expect(store.entities().at(1)).toEqual( + createFlightWithCustomId({ flightId: '4' }) + ); + }); + })); it('should delete from a service and update that entity in the store', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -199,6 +293,25 @@ describe('withDataService', () => { expect(store.flightEntities().length).toBe(0); }); })); + it('should delete from a service and update that entity in the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + expect(store.entities().length).toBe(0); + + store.create(createFlightWithCustomId({ flightId: '3' })); + tick(); + expect(store.entities().length).toBe(1); + expect(store.entities().at(0)).toEqual( + createFlightWithCustomId({ flightId: '3' }) + ); + store.delete(createFlightWithCustomId({ flightId: '3' })); + tick(); + expect(store.entities().length).toBe(0); + }); + })); it('should update the selected flight of the store', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -235,6 +348,25 @@ describe('withDataService', () => { ); }); })); + it('should update selected flight of the store (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + store.create(createFlightWithCustomId({ flightId: '3' })); + expect(store.selectedEntities().length).toBe(0); + + store.updateSelected('3', true); + + tick(); + + expect(store.selectedEntities().length).toBe(1); + expect(store.selectedEntities()).toContainEqual( + createFlightWithCustomId({ flightId: '3' }) + ); + }); + })); it('should update the filter of the service', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -268,6 +400,21 @@ describe('withDataService', () => { }); }); })); + it('should update the filter of the service (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + + tick(); + + expect(store.filter()).toEqual({ from: 'Paris', to: 'New York' }); + + store.updateFilter({ from: 'Wadena MN', to: 'New York' }); + + tick(); + + expect(store.filter()).toEqual({ from: 'Wadena MN', to: 'New York' }); + }); + })); it('should set the current entity', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); @@ -292,6 +439,20 @@ describe('withDataService', () => { expect(store.currentFlight()).toEqual(createFlight({ id: 4 })); }); })); + it('should set the current entity (with named selectId)', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new StoreWithSelectId(); + tick(); + + store.create(createFlightWithCustomId({ flightId: '3' })); + + store.setCurrent(createFlightWithCustomId({ flightId: '4' })); + + expect(store.current()).toEqual( + createFlightWithCustomId({ flightId: '4' }) + ); + }); + })); it('handles loading state', fakeAsync(() => { TestBed.runInInjectionContext(() => { @@ -415,6 +576,19 @@ const createFlight = (flight: Partial = {}) => ({ }, ...flight, }); + +let currentCustomFlightId = 0; +const createFlightWithCustomId = ( + flight: Partial = {} +): FlightWithCustomId => ({ + flightId: `${++currentCustomFlightId}`, + from: 'Paris', + to: 'New York', + date: new Date().toDateString(), + delayed: false, + ...flight, +}); + type Flight = { id: number; from: string; @@ -423,6 +597,8 @@ type Flight = { delayed: boolean; }; +type FlightWithCustomId = Omit & { flightId: string }; + type FlightFilter = { from: string; to: string; @@ -473,6 +649,53 @@ class MockFlightService implements DataService { } } +@Injectable({ + providedIn: 'root', +}) +class MockFlightWithSelectIdService + implements DataService +{ + loadById(id: EntityId): Promise { + return firstValueFrom(this.findById('' + id)); + } + + create(entity: FlightWithCustomId): Promise { + return firstValueFrom(this.save(entity)); + } + + update(entity: FlightWithCustomId): Promise { + return firstValueFrom(this.save(entity)); + } + + updateAll(entity: FlightWithCustomId[]): Promise { + return firstValueFrom(of(entity)); + } + + delete(entity: FlightWithCustomId): Promise { + return firstValueFrom(this.remove(entity)); + } + + load(filter: FlightFilter): Promise { + return firstValueFrom(this.find(filter.from, filter.to)); + } + + private find(_from: string, _to: string): Observable { + return of([createFlightWithCustomId()]); + } + + private findById(id: string): Observable { + return of(createFlightWithCustomId({ flightId: id })); + } + + private save(flight: FlightWithCustomId): Observable { + return of(flight); + } + + private remove(_flight: FlightWithCustomId): Observable { + return of(undefined); + } +} + @Injectable({ providedIn: 'root', }) @@ -563,3 +786,15 @@ const StoreWithNamedCollectionForLoading = signalStore( collection: 'flight', }) ); + +const StoreWithSelectId = signalStore( + withCallState(), + withEntities({ + entity: type(), + }), + withDataService({ + dataServiceType: MockFlightWithSelectIdService, + filter: { from: 'Paris', to: 'New York' }, + selectId: (flight: FlightWithCustomId) => flight.flightId, + }) +); diff --git a/libs/ngrx-toolkit/src/lib/with-data-service.ts b/libs/ngrx-toolkit/src/lib/with-data-service.ts index b63185a..6b4816b 100644 --- a/libs/ngrx-toolkit/src/lib/with-data-service.ts +++ b/libs/ngrx-toolkit/src/lib/with-data-service.ts @@ -1,14 +1,24 @@ import { ProviderToken, Signal, computed, inject } from '@angular/core'; import { + EmptyFeatureResult, SignalStoreFeature, + WritableStateSource, patchState, signalStoreFeature, withComputed, withMethods, withState, - EmptyFeatureResult, - WritableStateSource, } from '@ngrx/signals'; +import { + EntityId, + NamedEntityState, + SelectEntityId, + addEntity, + removeEntity, + setAllEntities, + updateEntity, +} from '@ngrx/signals/entities'; +import { EntityState } from './shared/signal-store-models'; import { CallState, NamedCallStateSlice, @@ -17,18 +27,9 @@ import { setLoaded, setLoading, } from './with-call-state'; -import { - NamedEntityState, - setAllEntities, - EntityId, - addEntity, - updateEntity, - removeEntity, -} from '@ngrx/signals/entities'; -import { EntityState } from './shared/signal-store-models'; export type Filter = Record; -export type Entity = { id: EntityId }; +export type Entity = Record; export interface DataService { load(filter: F): Promise; @@ -121,6 +122,16 @@ export function getDataServiceKeys(options: { collection?: string }) { }; } +const selectEntityId = ( + selectId?: SelectEntityId +): SelectEntityId => { + if (typeof selectId === 'function') { + return selectId; + } + + return (entity: E) => entity['id'] as EntityId; +}; + export type NamedDataServiceState< E extends Entity, F extends Filter, @@ -202,6 +213,7 @@ export function withDataService< dataServiceType: ProviderToken>; filter: F; collection: Collection; + selectId?: SelectEntityId; }): SignalStoreFeature< EmptyFeatureResult & { state: NamedCallStateSlice & NamedEntityState; @@ -215,6 +227,7 @@ export function withDataService< export function withDataService(options: { dataServiceType: ProviderToken>; filter: F; + selectId?: SelectEntityId; }): SignalStoreFeature< EmptyFeatureResult & { state: { callState: CallState } & EntityState }, { @@ -232,6 +245,7 @@ export function withDataService< dataServiceType: ProviderToken>; filter: F; collection?: Collection; + selectId?: SelectEntityId; }): /* eslint-disable @typescript-eslint/no-explicit-any */ SignalStoreFeature { const { dataServiceType, filter, collection: prefix } = options; @@ -243,7 +257,6 @@ SignalStoreFeature { selectedIdsKey, updateFilterKey, updateSelectedKey, - currentKey, createKey, updateKey, @@ -253,6 +266,8 @@ SignalStoreFeature { setCurrentKey, } = getDataServiceKeys(options); + const selectId = selectEntityId(options.selectId); + const { callStateKey } = getCallStateKeys({ collection: prefix }); return signalStoreFeature( @@ -269,7 +284,7 @@ SignalStoreFeature { return { [selectedEntitiesKey]: computed(() => - entities().filter((e) => selectedIds()[e.id]) + entities().filter((e) => selectedIds()[selectId(e)]) ), }; }), @@ -298,8 +313,8 @@ SignalStoreFeature { patchState( store, prefix - ? setAllEntities(result, { collection: prefix }) - : setAllEntities(result) + ? setAllEntities(result, { collection: prefix, selectId }) + : setAllEntities(result, { selectId }) ); (() => store[callStateKey] && patchState(store, setLoaded(prefix)))(); @@ -340,8 +355,8 @@ SignalStoreFeature { patchState( store, prefix - ? addEntity(created, { collection: prefix }) - : addEntity(created) + ? addEntity(created, { collection: prefix, selectId }) + : addEntity(created, { selectId }) ); (() => store[callStateKey] && patchState(store, setLoaded(prefix)))(); @@ -362,12 +377,12 @@ SignalStoreFeature { patchState(store, { [currentKey]: updated }); const updateArg = { - id: updated.id, + id: selectId(entity), changes: updated, }; const updater = (collection: string) => - updateEntity(updateArg, { collection }); + updateEntity(updateArg, { collection, selectId }); patchState( store, @@ -391,8 +406,8 @@ SignalStoreFeature { patchState( store, prefix - ? setAllEntities(result, { collection: prefix }) - : setAllEntities(result) + ? setAllEntities(result, { collection: prefix, selectId }) + : setAllEntities(result, { selectId }) ); (() => store[callStateKey] && patchState(store, setLoaded(prefix)))(); @@ -409,13 +424,15 @@ SignalStoreFeature { store[callStateKey] && patchState(store, setLoading(prefix)))(); try { + const id = selectId(entity); + await dataService.delete(entity); patchState(store, { [currentKey]: undefined }); patchState( store, prefix - ? removeEntity(entity.id, { collection: prefix }) - : removeEntity(entity.id) + ? removeEntity(id, { collection: prefix }) + : removeEntity(id) ); (() => store[callStateKey] && patchState(store, setLoaded(prefix)))();