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 5dedd15d..639e7f7f 100644 --- a/libs/ngrx-toolkit/src/lib/with-data-service.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-data-service.spec.ts @@ -38,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(); @@ -64,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(); @@ -96,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(); @@ -128,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(); @@ -164,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(); @@ -198,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(); @@ -234,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(); @@ -267,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(); @@ -291,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(() => { @@ -414,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; @@ -422,6 +597,8 @@ type Flight = { delayed: boolean; }; +type FlightWithCustomId = Omit & { flightId: string }; + type FlightFilter = { from: string; to: string; @@ -472,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', }) @@ -562,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 4ae54f7c..2e83df2b 100644 --- a/libs/ngrx-toolkit/src/lib/with-data-service.ts +++ b/libs/ngrx-toolkit/src/lib/with-data-service.ts @@ -12,6 +12,7 @@ import { import { EntityId, NamedEntityState, + SelectEntityId, addEntity, removeEntity, setAllEntities, @@ -28,7 +29,7 @@ import { } from './with-call-state'; 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)))();