From 6ec7c616f8c86a13e6ddd7be4d4515720f241c24 Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Thu, 6 Jun 2024 13:04:16 +0100 Subject: [PATCH] feat(signals): allow withEntitiesLoadingCall to receive a factory function for config the factory function config will receive the store , and allow access to it in the fetchEntities, onSuccess and onErro methods Fix #95 --- .../products-branch.store.ts | 6 +- .../product-shop-page/products-shop.store.ts | 38 +- libs/ngrx-traits/signals/api-docs.md | 2 +- .../with-entities-loading-call.spec.ts | 779 ++++++++++++------ .../with-entities-loading-call.ts | 350 ++++++-- 5 files changed, 805 insertions(+), 370 deletions(-) diff --git a/apps/example-app/src/app/examples/signals/infinete-scroll-page/products-branch.store.ts b/apps/example-app/src/app/examples/signals/infinete-scroll-page/products-branch.store.ts index 36097c47..f877fd4e 100644 --- a/apps/example-app/src/app/examples/signals/infinete-scroll-page/products-branch.store.ts +++ b/apps/example-app/src/app/examples/signals/infinete-scroll-page/products-branch.store.ts @@ -30,8 +30,8 @@ export const ProductsBranchStore = signalStore( pageSize: 10, entity, }), - withEntitiesLoadingCall({ - fetchEntities: async ({ entitiesPagedRequest, entitiesFilter }) => { + withEntitiesLoadingCall(({ entitiesPagedRequest, entitiesFilter }) => ({ + fetchEntities: async () => { const res = await lastValueFrom( inject(BranchService).getBranches({ search: entitiesFilter().search, @@ -41,7 +41,7 @@ export const ProductsBranchStore = signalStore( ); return { entities: res.resultList }; }, - }), + })), ), signalStoreFeature( withEntities({ diff --git a/apps/example-app/src/app/examples/signals/product-shop-page/products-shop.store.ts b/apps/example-app/src/app/examples/signals/product-shop-page/products-shop.store.ts index 603074f3..6aa7eac0 100644 --- a/apps/example-app/src/app/examples/signals/product-shop-page/products-shop.store.ts +++ b/apps/example-app/src/app/examples/signals/product-shop-page/products-shop.store.ts @@ -118,26 +118,24 @@ export const ProductsShopStore = signalStore( { providedIn: 'root' }, productsStoreFeature, orderItemsStoreFeature, - withEntitiesLoadingCall({ - collection: productsCollection, - fetchEntities: async ({ - productsPagedRequest, - productsFilter, - productsSort, - }) => { - const res = await lastValueFrom( - inject(ProductService).getProducts({ - search: productsFilter().search, - skip: productsPagedRequest().startIndex, - take: productsPagedRequest().size, - sortAscending: productsSort().direction === 'asc', - sortColumn: productsSort().field, - }), - ); - return { entities: res.resultList, total: res.total }; - }, - mapError: (error) => (error as Error).message, - }), + withEntitiesLoadingCall( + ({ productsPagedRequest, productsFilter, productsSort }) => ({ + collection: productsCollection, + fetchEntities: async () => { + const res = await lastValueFrom( + inject(ProductService).getProducts({ + search: productsFilter().search, + skip: productsPagedRequest().startIndex, + take: productsPagedRequest().size, + sortAscending: productsSort().direction === 'asc', + sortColumn: productsSort().field, + }), + ); + return { entities: res.resultList, total: res.total }; + }, + mapError: (error) => (error as Error).message, + }), + ), withCalls(({ orderItemsEntities }, snackBar = inject(MatSnackBar)) => ({ loadProductDetail: ({ id }: { id: string }) => inject(ProductService).getProductDetail(id), diff --git a/libs/ngrx-traits/signals/api-docs.md b/libs/ngrx-traits/signals/api-docs.md index 581fcd7f..72b22735 100644 --- a/libs/ngrx-traits/signals/api-docs.md +++ b/libs/ngrx-traits/signals/api-docs.md @@ -339,7 +339,7 @@ if an error occurs it will set the error to the store using set[Collection]Error | Param | Description | | --- | --- | -| config |

Configuration object

| +| config |

Configuration object or factory function that returns the configuration object

| | config.fetchEntities |

A function that fetches the entities from a remote source the return type

| | config.collection |

The collection name

| | config.onSuccess |

A function that is called when the fetchEntities is successful

| diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts index 32c66313..9207297a 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts @@ -14,270 +14,539 @@ import { Product } from '../test.model'; describe('withEntitiesLoadingCall', () => { const entity = type(); const collection = 'products'; + describe('using config as object', () => { + describe('without collection setLoading should call fetch entities', () => { + it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall({ + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + }); + })); - describe('without collection setLoading should call fetch entities', () => { - it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - }), - withCallStatus(), - withEntitiesLoadingCall({ - fetchEntities: () => { - let result = [...mockProducts]; - return of(result); - }, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.entities()).toEqual([]); - store.setLoading(); - tick(); - expect(store.entities()).toEqual(mockProducts); - }); - })); + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall({ + fetchEntities: () => { + let result = [...mockProducts]; + return of({ entities: result }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + }); + })); - it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - }), - withCallStatus(), - withEntitiesLoadingCall({ - fetchEntities: () => { - let result = [...mockProducts]; - return of({ entities: result }); - }, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.entities()).toEqual([]); - store.setLoading(); - tick(); - expect(store.entities()).toEqual(mockProducts); - }); - })); + it('should setEntitiesPagedResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesRemotePagination({ + entity, + pageSize: 10, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesPagedRequest }) => { + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts.slice(0, 30)); + }); + })); - it('should setEntitiesPagedResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - }), - withCallStatus(), - withEntitiesRemotePagination({ - entity, - pageSize: 10, - }), - withEntitiesLoadingCall({ - fetchEntities: ({ entitiesPagedRequest }) => { - let result = [...mockProducts]; - const total = result.length; - const options = { - skip: entitiesPagedRequest()?.startIndex, - take: entitiesPagedRequest()?.size, - }; - if (options?.skip || options?.take) { - const skip = +(options?.skip ?? 0); - const take = +(options?.take ?? 0); - result = result.slice(skip, skip + take); - } - return of({ entities: result, total }); - }, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.entities()).toEqual([]); - store.setLoading(); - tick(); - expect(store.entities()).toEqual(mockProducts.slice(0, 30)); - }); - })); + it('should call setLoaded and onSuccess if fetchEntities call is successful', fakeAsync(() => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall({ + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + onSuccess, + onError, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + expect(store.isLoaded()).toBeTruthy(); + expect(onSuccess).toHaveBeenCalledWith(mockProducts); + expect(onError).not.toHaveBeenCalled(); + }); + })); - it('should call setLoaded and onSuccess if fetchEntities call is successful', fakeAsync(() => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - }), - withCallStatus(), - withEntitiesLoadingCall({ - fetchEntities: () => { - let result = [...mockProducts]; - return of(result); - }, - onSuccess, - onError, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.entities()).toEqual([]); - store.setLoading(); - tick(); - expect(store.entities()).toEqual(mockProducts); - expect(store.isLoaded()).toBeTruthy(); - expect(onSuccess).toHaveBeenCalledWith(mockProducts); - expect(onError).not.toHaveBeenCalled(); - }); - })); + it('should call setError and onError if fetchEntities call fails ', fakeAsync(() => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall({ + fetchEntities: () => { + return throwError(() => new Error('fail')); + }, + onSuccess, + onError, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual([]); + expect(store.error()).toEqual(new Error('fail')); + expect(onError).toHaveBeenCalledWith(new Error('fail')); + expect(onSuccess).not.toHaveBeenCalled(); + }); + })); - it('should call setError and onError if fetchEntities call fails ', fakeAsync(() => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - }), - withCallStatus(), - withEntitiesLoadingCall({ - fetchEntities: () => { - return throwError(() => new Error('fail')); - }, - onSuccess, - onError, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.entities()).toEqual([]); - store.setLoading(); - tick(); - expect(store.entities()).toEqual([]); - expect(store.error()).toEqual(new Error('fail')); - expect(onError).toHaveBeenCalledWith(new Error('fail')); - expect(onSuccess).not.toHaveBeenCalled(); - }); - })); + it('should call setError and onError if fetchEntities call fails with correct type if mapError is used', fakeAsync(() => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ errorType: type() }), + withEntitiesLoadingCall({ + fetchEntities: () => { + return throwError(() => new Error('fail')); + }, + onSuccess, + mapError: (error) => (error as Error).message, + onError, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual([]); + expect(store.error()).toEqual('fail'); + expect(onError).toHaveBeenCalledWith('fail'); + expect(onSuccess).not.toHaveBeenCalled(); + }); + })); + }); - it('should call setError and onError if fetchEntities call fails with correct type if mapError is used', fakeAsync(() => { - const onSuccess = jest.fn(); - const onError = jest.fn(); - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - }), - withCallStatus({ errorType: type() }), - withEntitiesLoadingCall({ - fetchEntities: () => { - return throwError(() => new Error('fail')); - }, - onSuccess, - mapError: (error) => (error as Error).message, - onError, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.entities()).toEqual([]); - store.setLoading(); - tick(); - expect(store.entities()).toEqual([]); - expect(store.error()).toEqual('fail'); - expect(onError).toHaveBeenCalledWith('fail'); - expect(onSuccess).not.toHaveBeenCalled(); - }); - })); + describe('with collection set[Collection]Loading should call fetch entities', () => { + it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesLoadingCall({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts); + }); + })); + + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesLoadingCall({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + return of({ entities: result }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts); + }); + })); + + it('should set[Collection]Result if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesRemotePagination({ + entity, + collection, + pageSize: 10, + }), + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsPagedRequest }) => { + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: productsPagedRequest()?.startIndex, + take: productsPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts.slice(0, 30)); + }); + })); + }); }); - describe('with collection set[Collection]Loading should call fetch entities', () => { - it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - collection, - }), - withCallStatus({ collection }), - withEntitiesLoadingCall({ - collection, - fetchEntities: () => { - let result = [...mockProducts]; - return of(result); - }, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.productsEntities()).toEqual([]); - store.setProductsLoading(); - tick(); - expect(store.productsEntities()).toEqual(mockProducts); - }); - })); + describe('using config as factory', () => { + describe('without collection setLoading should call fetch entities', () => { + it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall(() => ({ + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + }); + })); + + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall(() => ({ + fetchEntities: () => { + let result = [...mockProducts]; + return of({ entities: result }); + }, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + }); + })); + + it('should setEntitiesPagedResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesRemotePagination({ + entity, + pageSize: 10, + }), + withEntitiesLoadingCall(({ entitiesPagedRequest }) => ({ + fetchEntities: () => { + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts.slice(0, 30)); + }); + })); + + it('should call setLoaded and onSuccess if fetchEntities call is successful', fakeAsync(() => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall(() => ({ + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + onSuccess, + onError, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + expect(store.isLoaded()).toBeTruthy(); + expect(onSuccess).toHaveBeenCalledWith(mockProducts); + expect(onError).not.toHaveBeenCalled(); + }); + })); + + it('should call setError and onError if fetchEntities call fails ', fakeAsync(() => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall(() => ({ + fetchEntities: () => { + return throwError(() => new Error('fail')); + }, + onSuccess, + onError, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual([]); + expect(store.error()).toEqual(new Error('fail')); + expect(onError).toHaveBeenCalledWith(new Error('fail')); + expect(onSuccess).not.toHaveBeenCalled(); + }); + })); + + it('should call setError and onError if fetchEntities call fails with correct type if mapError is used', fakeAsync(() => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ errorType: type() }), + withEntitiesLoadingCall(() => ({ + fetchEntities: () => { + return throwError(() => new Error('fail')); + }, + onSuccess, + mapError: (error) => (error as Error).message, + onError, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual([]); + expect(store.error()).toEqual('fail'); + expect(onError).toHaveBeenCalledWith('fail'); + expect(onSuccess).not.toHaveBeenCalled(); + }); + })); + }); + + describe('with collection set[Collection]Loading should call fetch entities', () => { + it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesLoadingCall(() => ({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts); + }); + })); - it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - collection, - }), - withCallStatus({ collection }), - withEntitiesLoadingCall({ - collection, - fetchEntities: () => { - let result = [...mockProducts]; - return of({ entities: result }); - }, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.productsEntities()).toEqual([]); - store.setProductsLoading(); - tick(); - expect(store.productsEntities()).toEqual(mockProducts); - }); - })); + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesLoadingCall(() => ({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + return of({ entities: result }); + }, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts); + }); + })); - it('should set[Collection]Result if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { - TestBed.runInInjectionContext(() => { - const Store = signalStore( - withEntities({ - entity, - collection, - }), - withCallStatus({ collection }), - withEntitiesRemotePagination({ - entity, - collection, - pageSize: 10, - }), - withEntitiesLoadingCall({ - collection, - fetchEntities: ({ productsPagedRequest }) => { - let result = [...mockProducts]; - const total = result.length; - const options = { - skip: productsPagedRequest()?.startIndex, - take: productsPagedRequest()?.size, - }; - if (options?.skip || options?.take) { - const skip = +(options?.skip ?? 0); - const take = +(options?.take ?? 0); - result = result.slice(skip, skip + take); - } - return of({ entities: result, total }); - }, - }), - ); - const store = new Store(); - TestBed.flushEffects(); - expect(store.productsEntities()).toEqual([]); - store.setProductsLoading(); - tick(); - expect(store.productsEntities()).toEqual(mockProducts.slice(0, 30)); - }); - })); + it('should set[Collection]Result if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesRemotePagination({ + entity, + collection, + pageSize: 10, + }), + withEntitiesLoadingCall(({ productsPagedRequest }) => ({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: productsPagedRequest()?.startIndex, + take: productsPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + })), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts.slice(0, 30)); + }); + })); + }); }); }); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.ts b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.ts index b11fd1aa..79742c40 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.ts @@ -65,7 +65,7 @@ import { getWithEntitiesRemotePaginationKeys } from '../with-entities-pagination * * Requires withEntities and withCallStatus to be present in the store. * - * @param config - Configuration object + * @param config - Configuration object or factory function that returns the configuration object * @param config.fetchEntities - A function that fetches the entities from a remote source the return type * @param config.collection - The collection name * @param config.onSuccess - A function that is called when the fetchEntities is successful @@ -170,7 +170,7 @@ export function withEntitiesLoadingCall< * * Requires withEntities and withCallStatus to be present in the store. * - * @param config - Configuration object + * @param config - Configuration object or factory function that returns the configuration object * @param config.fetchEntities - A function that fetches the entities from a remote source the return type * @param config.collection - The collection name * @param config.onSuccess - A function that is called when the fetchEntities is successful @@ -279,105 +279,273 @@ export function withEntitiesLoadingCall< EmptyFeatureResult >; +/** + * Generates a onInit hook that fetches entities from a remote source + * when the [collection]Loading is true, by calling the fetchEntities function + * and if successful, it will call set[Collection]Loaded and also set the entities + * to the store using the setAllEntities method or the setEntitiesPagedResult method + * if it exists (comes from withEntitiesRemotePagination), + * if an error occurs it will set the error to the store using set[Collection]Error with the error. + * + * Requires withEntities and withCallStatus to be present in the store. + * + * @param config - Configuration object or factory function that returns the configuration object + * @param config.fetchEntities - A function that fetches the entities from a remote source the return type + * @param config.collection - The collection name + * @param config.onSuccess - A function that is called when the fetchEntities is successful + * @param config.mapError - A function to transform the error before setting it to the store, requires withCallStatus errorType to be set + * @param config.onError - A function that is called when the fetchEntities fails + * can be an array of entities or an object with entities and total + * + * @example + * export const ProductsRemoteStore = signalStore( + * { providedIn: 'root' }, + * // requires at least withEntities and withCallStatus + * withEntities({ entity, collection }), + * withCallStatus({ prop: collection, initialValue: 'loading' }), + * // other features + * withEntitiesRemoteFilter({ + * entity, + * collection, + * defaultFilter: { name: '' }, + * }), + * withEntitiesRemotePagination({ + * entity, + * collection, + * pageSize: 5, + * pagesToCache: 2, + * }), + * withEntitiesRemoteSort({ + * entity, + * collection, + * defaultSort: { field: 'name', direction: 'asc' }, + * }), + * // now we add the withEntitiesLoadingCall, in this case any time the filter, + * // pagination or sort changes they call set[Collection]Loading() which then + * // triggers the onInit effect that checks if [collection]Loading(), if true + * // then calls fetchEntities function + * withEntitiesLoadingCall({ + * collection, + * fetchEntities: ({ productsFilter, productsPagedRequest, productsSort }) => { + * return inject(ProductService) + * .getProducts({ + * search: productsFilter().name, + * take: productsPagedRequest().size, + * skip: productsPagedRequest().startIndex, + * sortColumn: productsSort().field, + * sortAscending: productsSort().direction === 'asc', + * }) + * .pipe( + * map((d) => ({ + * entities: d.resultList, + * total: d.total, + * })), + * ); + * }, + * }), + */ export function withEntitiesLoadingCall< Input extends SignalStoreFeatureResult, Entity extends { id: string | number }, - Collection extends string, + const Collection extends string = '', Error = unknown, ->({ - collection, - fetchEntities, - ...config -}: { - collection?: Collection; - fetchEntities: ( - store: SignalStoreSlices & - Input['signals'] & - Input['methods'] & - StateSignal>, - ) => Observable | Promise; - mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; - onSuccess?: (result: any) => void; - mapError?: (error: unknown) => Error; - onError?: (error: Error) => void; -}): SignalStoreFeature { - const { loadingKey, setErrorKey, setLoadedKey } = getWithCallStatusKeys({ - prop: collection, - }); - const { setEntitiesPagedResultKey } = getWithEntitiesRemotePaginationKeys({ - collection, - }); +>( + config: ( + store: Prettify< + SignalStoreSlices & + Input['signals'] & + Input['methods'] & + StateSignal> + >, + ) => { + collection?: Collection; + fetchEntities: () => + | Observable< + Collection extends '' + ? Input['methods'] extends SetEntitiesResult + ? ResultParam + : Entity[] | { entities: Entity[] } + : Input['methods'] extends NamedSetEntitiesResult< + Collection, + infer ResultParam + > + ? ResultParam + : Entity[] | { entities: Entity[] } + > + | Promise< + Collection extends '' + ? Input['methods'] extends SetEntitiesResult + ? ResultParam + : Entity[] | { entities: Entity[] } + : Input['methods'] extends NamedSetEntitiesResult< + Collection, + infer ResultParam + > + ? ResultParam + : Entity[] | { entities: Entity[] } + >; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; + onSuccess?: ( + result: Input['methods'] extends NamedSetEntitiesResult< + Collection, + infer ResultParam + > + ? ResultParam + : Entity[] | { entities: Entity[] }, + ) => void; + mapError?: (error: unknown) => Error; + onError?: (error: Error) => void; + }, +): SignalStoreFeature< + Input & + (Collection extends '' + ? { + state: EntityState & CallStatusState; + signals: EntitySignals & CallStatusComputed; + methods: CallStatusMethods; + } + : { + state: NamedEntityState & + NamedCallStatusState; + signals: NamedEntitySignals & + NamedCallStatusComputed; + methods: NamedCallStatusMethods; + }), + EmptyFeatureResult +>; - return signalStoreFeature( - withHooks( - ( - store: Record>, - environmentInjector = inject(EnvironmentInjector), +export function withEntitiesLoadingCall< + Input extends SignalStoreFeatureResult, + Entity extends { id: string | number }, + Collection extends string, + Error = unknown, +>( + configFactory: + | { + collection?: Collection; + fetchEntities: ( + store: SignalStoreSlices & + Input['signals'] & + Input['methods'] & + StateSignal>, + ) => Observable | Promise; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; + onSuccess?: (result: any) => void; + mapError?: (error: unknown) => Error; + onError?: (error: Error) => void; + } + | (( + store: Prettify< + SignalStoreSlices & + Input['signals'] & + Input['methods'] & + StateSignal> + >, ) => { - const loading = store[loadingKey] as Signal; - const setLoaded = store[setLoadedKey] as () => void; - const setError = store[setErrorKey] as (error: unknown) => void; - const setEntitiesPagedResult = store[ - setEntitiesPagedResultKey - ] as (result: { entities: Entity[] }) => void; - return { - onInit: () => { - const loading$ = toObservable(loading); - const mapPipe = config.mapPipe - ? mapPipes[config.mapPipe] - : switchMap; + collection?: Collection; + fetchEntities: () => Observable | Promise; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; + onSuccess?: (result: any) => void; + mapError?: (error: unknown) => Error; + onError?: (error: Error) => void; + }), +): SignalStoreFeature { + return (store) => { + const { slices, methods, signals, hooks, ...rest } = store; + const { + collection, + fetchEntities, + onSuccess, + onError, + mapError, + mapPipe: mapPipeType, + } = typeof configFactory === 'function' + ? configFactory({ + ...slices, + ...signals, + ...methods, + ...rest, + } as Prettify< + SignalStoreSlices & + Input['signals'] & + Input['methods'] & + StateSignal> + >) + : configFactory; + const { loadingKey, setErrorKey, setLoadedKey } = getWithCallStatusKeys({ + prop: collection, + }); + const { setEntitiesPagedResultKey } = getWithEntitiesRemotePaginationKeys({ + collection, + }); + + return signalStoreFeature( + withHooks( + ( + store: Record>, + environmentInjector = inject(EnvironmentInjector), + ) => { + const loading = store[loadingKey] as Signal; + const setLoaded = store[setLoadedKey] as () => void; + const setError = store[setErrorKey] as (error: unknown) => void; + const setEntitiesPagedResult = store[ + setEntitiesPagedResultKey + ] as (result: { entities: Entity[] }) => void; + return { + onInit: () => { + const loading$ = toObservable(loading); + const mapPipe = mapPipeType ? mapPipes[mapPipeType] : switchMap; - loading$ - .pipe( - filter(Boolean), - mapPipe(() => - runInInjectionContext(environmentInjector, () => - from( - fetchEntities( - store as SignalStoreSlices & - Input['signals'] & - Input['methods'] & - StateSignal>, + loading$ + .pipe( + filter(Boolean), + mapPipe(() => + runInInjectionContext(environmentInjector, () => + from( + fetchEntities( + store as SignalStoreSlices & + Input['signals'] & + Input['methods'] & + StateSignal>, + ), ), + ).pipe( + map((result) => { + if (setEntitiesPagedResult) + setEntitiesPagedResult(result); + else { + const entities = Array.isArray(result) + ? result + : result.entities; + patchState( + store as StateSignal, + collection + ? setAllEntities(entities as Entity[], { + collection, + }) + : setAllEntities(entities), + ); + } + setLoaded(); + if (onSuccess) onSuccess(result); + }), + first(), + catchError((error: unknown) => { + const e = mapError ? mapError(error) : error; + setError(e); + if (onError) onError(e as Error); + return of(); + }), ), - ).pipe( - map((result) => { - if (setEntitiesPagedResult) - setEntitiesPagedResult(result); - else { - const entities = Array.isArray(result) - ? result - : result.entities; - patchState( - store as StateSignal, - collection - ? setAllEntities(entities as Entity[], { - collection, - }) - : setAllEntities(entities), - ); - } - setLoaded(); - if (config.onSuccess) config.onSuccess(result); - }), - first(), - catchError((error: unknown) => { - const e = config.mapError - ? config.mapError(error) - : error; - setError(e); - if (config.onError) config.onError(e as Error); - return of(); - }), ), - ), - ) - .subscribe(); - }, - }; - }, - ), - ); + ) + .subscribe(); + }, + }; + }, + ), + )(store); + }; } const mapPipes = { switchMap: switchMap,