diff --git a/apps/example-app/src/app/examples/components/product-detail/product-detail.component.ts b/apps/example-app/src/app/examples/components/product-detail/product-detail.component.ts index 42de4eb8..4c423666 100644 --- a/apps/example-app/src/app/examples/components/product-detail/product-detail.component.ts +++ b/apps/example-app/src/app/examples/components/product-detail/product-detail.component.ts @@ -1,9 +1,10 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { ProductDetail } from '../../models'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatCardModule } from '@angular/material/card'; import { CurrencyPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { input } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +import { ProductDetail } from '../../models'; @Component({ selector: 'product-detail', @@ -13,22 +14,22 @@ import { input } from '@angular/core'; @if (product()) { - {{ product()?.name }} + {{ product().name }} Price: £{{ product()?.price | currency }} Released: - {{ product()?.releaseDate }} - - - -

{{ product()?.description }}

-
-
- } - } @else { - + >Price: £{{ product().price | currency }} Released: + {{ product().releaseDate }} + + + +

{{ product().description }}

+
+ } - `, + } @else { + + } + `, styles: [ ` mat-spinner { @@ -41,6 +42,6 @@ import { input } from '@angular/core'; imports: [MatCardModule, MatProgressSpinnerModule, CurrencyPipe], }) export class ProductDetailComponent { - product = input(); + product = input.required(); productLoading = input(false); } diff --git a/apps/example-app/src/app/examples/examples-routing.module.ts b/apps/example-app/src/app/examples/examples-routing.module.ts index dba0ac09..dcfa7546 100644 --- a/apps/example-app/src/app/examples/examples-routing.module.ts +++ b/apps/example-app/src/app/examples/examples-routing.module.ts @@ -25,6 +25,13 @@ const routes: Routes = [ './signals/product-list-paginated-page/signal-product-list-paginated-page-container.component' ).then((m) => m.SignalProductListPaginatedPageContainerComponent), }, + { + path: 'infinite-scroll', + loadComponent: () => + import( + './signals/infinete-scroll-page/infinite-scroll-page.component' + ).then((m) => m.InfiniteScrollPageComponent), + }, ], }, { diff --git a/apps/example-app/src/app/examples/models/index.ts b/apps/example-app/src/app/examples/models/index.ts index 84567fd6..f33b886a 100644 --- a/apps/example-app/src/app/examples/models/index.ts +++ b/apps/example-app/src/app/examples/models/index.ts @@ -36,6 +36,17 @@ export interface ProductsStoreDetail { manager: string; departments: Department[]; } +export type ProductsStoreQuery = { + search?: string | undefined; + sortColumn?: keyof ProductsStore | undefined; + sortAscending?: string | undefined; + skip?: string | undefined; + take?: string | undefined; +}; +export interface ProductsStoreResponse { + resultList: ProductsStore[]; + total: number; +} export interface ProductsStoreFilter { search?: string; diff --git a/apps/example-app/src/app/examples/services/mock-backend/product-stores.handler.ts b/apps/example-app/src/app/examples/services/mock-backend/product-stores.handler.ts index 45730e49..e4ebce88 100644 --- a/apps/example-app/src/app/examples/services/mock-backend/product-stores.handler.ts +++ b/apps/example-app/src/app/examples/services/mock-backend/product-stores.handler.ts @@ -1,18 +1,54 @@ -import { ProductsStore, ProductsStoreDetail } from '../../models'; -import { getRandomInteger } from '../../utils/form-utils'; +import { sortData } from '@ngrx-traits/common'; import { rest } from 'msw'; +import { + ProductsStore, + ProductsStoreDetail, + ProductsStoreQuery, + ProductsStoreResponse, +} from '../../models'; +import { getRandomInteger } from '../../utils/form-utils'; + export const storeHandlers = [ - rest.get('/stores', (req, res, context) => { - return res(context.status(200), context.json(mockStores)); - }), + rest.get( + '/stores', + (req, res, ctx) => { + let result = [...mockStores]; + const options = { + search: req.url.searchParams.get('search'), + sortColumn: req.url.searchParams.get('sortColumn'), + sortAscending: req.url.searchParams.get('sortAscending'), + skip: req.url.searchParams.get('skip'), + take: req.url.searchParams.get('take'), + }; + if (options?.search) + result = mockStores.filter((entity) => { + return options?.search + ? entity.name.toLowerCase().includes(options?.search.toLowerCase()) + : false; + }); + const total = result.length; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + if (options?.sortColumn) { + result = sortData(result, { + active: options.sortColumn as any, + direction: options.sortAscending === 'true' ? 'asc' : 'desc', + }); + } + return res(ctx.status(200), ctx.json({ resultList: result, total })); + }, + ), rest.get( '/stores/:id', (req, res, context) => { const id = +req.params.id; const storeDetail = mockStoresDetails.find((value) => value.id === id); return res(context.status(200), context.json(storeDetail)); - } + }, ), ]; @@ -81,7 +117,7 @@ const names = [ 'Wendi Ellis', ]; -const mockStoresDetails: ProductsStoreDetail[] = new Array(200) +const mockStoresDetails: ProductsStoreDetail[] = new Array(500) .fill(null) .map((_, index) => { return { @@ -114,5 +150,5 @@ const mockStores: ProductsStore[] = mockStoresDetails.map( id, name, address: address.line1 + ', ' + address.town + ', ' + address.postCode, - }) + }), ); diff --git a/apps/example-app/src/app/examples/services/products-store.service.ts b/apps/example-app/src/app/examples/services/products-store.service.ts index 69cd22fa..f6119e35 100644 --- a/apps/example-app/src/app/examples/services/products-store.service.ts +++ b/apps/example-app/src/app/examples/services/products-store.service.ts @@ -1,14 +1,31 @@ +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { ProductsStore, ProductsStoreDetail } from '../models'; import { delay, map } from 'rxjs/operators'; -import { HttpClient } from '@angular/common/http'; + +import { + Product, + ProductsStore, + ProductsStoreDetail, + ProductsStoreResponse, +} from '../models'; @Injectable({ providedIn: 'root' }) export class ProductsStoreService { constructor(private httpClient: HttpClient) {} - getStores() { - return this.httpClient.get('/stores/').pipe(delay(500)); + getStores(options?: { + search?: string | undefined; + sortColumn?: keyof Product | string | undefined; + sortAscending?: boolean | undefined; + skip?: number | undefined; + take?: number | undefined; + }) { + console.log('getStores', options); + return this.httpClient + .get('/stores', { + params: { ...options, search: options?.search ?? '' }, + }) + .pipe(delay(500)); } getStoreDetails(id: number) { return this.httpClient diff --git a/apps/example-app/src/app/examples/signals/infinete-scroll-page/components/products-branch-dropdown/products-branch-dropdown.component.ts b/apps/example-app/src/app/examples/signals/infinete-scroll-page/components/products-branch-dropdown/products-branch-dropdown.component.ts new file mode 100644 index 00000000..1b570c03 --- /dev/null +++ b/apps/example-app/src/app/examples/signals/infinete-scroll-page/components/products-branch-dropdown/products-branch-dropdown.component.ts @@ -0,0 +1,109 @@ +import { + CdkFixedSizeVirtualScroll, + CdkScrollableModule, + CdkVirtualForOf, + CdkVirtualScrollViewport, +} from '@angular/cdk/scrolling'; +import { CommonModule } from '@angular/common'; +import { Component, inject, input } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { getInfiniteScrollDataSource } from '@ngrx-traits/signals'; + +import { SearchOptionsComponent } from '../../../../components/search-options/search-options.component'; +import { ProductsStore } from '../../../../models'; +import { ProductsBranchStore } from './products-branch.store'; + +@Component({ + selector: 'products-branch-dropdown', + standalone: true, + imports: [ + CommonModule, + MatFormField, + MatLabel, + MatSelect, + SearchOptionsComponent, + MatOption, + MatProgressSpinner, + ReactiveFormsModule, + CdkVirtualScrollViewport, + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + ], + template: ` + {{ label() }} + + + + + {{ control.value.name }} + + + {{ item.name }} + + + + + + + `, + styles: [ + ` + .fact-scroll-viewport { + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; + height: 200px; + width: 100%; + } + :host { + display: inline-block; + } + .container { + width: 100%; + } + `, + ], + providers: [ProductsBranchStore], +}) +export class ProductsBranchDropdownComponent { + label = input('Branch'); + placeholder = input('Please Select'); + control = new FormControl(); + store = inject(ProductsBranchStore); + dataSource = getInfiniteScrollDataSource({ store: this.store }); + + search(query: string) { + this.store.filterEntities({ filter: { search: query } }); + } + + trackByFn(index: number, item: ProductsStore) { + return item.id; + } + compareById(value: ProductsStore, option: ProductsStore) { + return value && option && value.id == option.id; + } +} diff --git a/apps/example-app/src/app/examples/signals/infinete-scroll-page/components/products-branch-dropdown/products-branch.store.ts b/apps/example-app/src/app/examples/signals/infinete-scroll-page/components/products-branch-dropdown/products-branch.store.ts new file mode 100644 index 00000000..0ab55291 --- /dev/null +++ b/apps/example-app/src/app/examples/signals/infinete-scroll-page/components/products-branch-dropdown/products-branch.store.ts @@ -0,0 +1,52 @@ +import { inject } from '@angular/core'; +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesRemoteFilter, + withEntitiesRemoteScrollPagination, + withLogger, +} from '@ngrx-traits/signals'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { lastValueFrom } from 'rxjs'; + +import { ProductsStore } from '../../../../models'; +import { ProductsStoreService } from '../../../../services/products-store.service'; + +const entity = type(); +export const ProductsBranchStore = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemoteFilter({ + entity, + defaultFilter: { search: '' }, + }), + withEntitiesRemoteScrollPagination({ + pageSize: 10, + entity, + }), + + withEntitiesLoadingCall({ + fetchEntities: async ({ entitiesPagedRequest, entitiesFilter }) => { + console.log('fetchEntities', entitiesPagedRequest(), entitiesFilter()); + const res = await lastValueFrom( + inject(ProductsStoreService).getStores({ + search: entitiesFilter().search, + skip: entitiesPagedRequest().startIndex, + take: entitiesPagedRequest().size, + }), + ); + console.log({ + search: entitiesFilter().search, + skip: entitiesPagedRequest().startIndex, + take: entitiesPagedRequest().size, + entities: res.resultList.length, + total: res.total, + }); + return { entities: res.resultList, total: res.total }; + }, + }), + withLogger('branchStore'), +); diff --git a/apps/example-app/src/app/examples/signals/infinete-scroll-page/infinite-scroll-page.component.ts b/apps/example-app/src/app/examples/signals/infinete-scroll-page/infinite-scroll-page.component.ts new file mode 100644 index 00000000..ec02714f --- /dev/null +++ b/apps/example-app/src/app/examples/signals/infinete-scroll-page/infinite-scroll-page.component.ts @@ -0,0 +1,13 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; + +import { ProductsBranchDropdownComponent } from './components/products-branch-dropdown/products-branch-dropdown.component'; + +@Component({ + selector: 'infinite-scroll-page', + standalone: true, + imports: [CommonModule, ProductsBranchDropdownComponent], + template: ``, + styles: ``, +}) +export class InfiniteScrollPageComponent {} diff --git a/apps/example-app/src/app/examples/signals/product-list-paginated-page/signal-product-list-paginated-page-container.component.ts b/apps/example-app/src/app/examples/signals/product-list-paginated-page/signal-product-list-paginated-page-container.component.ts index 5e87552c..80bdb69e 100644 --- a/apps/example-app/src/app/examples/signals/product-list-paginated-page/signal-product-list-paginated-page-container.component.ts +++ b/apps/example-app/src/app/examples/signals/product-list-paginated-page/signal-product-list-paginated-page-container.component.ts @@ -24,7 +24,7 @@ import { ProductsLocalStore } from './product.store'; [searchProduct]="store.productsFilter()" (searchProductChange)="filter($event)" > - @if (store.productsLoading()) { + @if (store.isProductsLoading()) { } @else {
@@ -49,8 +49,8 @@ import { ProductsLocalStore } from './product.store';
} @@ -61,11 +61,11 @@ import { ProductsLocalStore } from './product.store'; color="primary" type="submit" [disabled]=" - !store.productsSelectedEntity() || store.checkoutLoading() + !store.productsSelectedEntity() || store.isCheckoutLoading() " (click)="checkout()" > - @if (store.checkoutLoading()) { + @if (store.isCheckoutLoading()) { } CHECKOUT diff --git a/apps/example-app/src/app/examples/signals/signal-examples.component.ts b/apps/example-app/src/app/examples/signals/signal-examples.component.ts index 831285d7..6e7d446b 100644 --- a/apps/example-app/src/app/examples/signals/signal-examples.component.ts +++ b/apps/example-app/src/app/examples/signals/signal-examples.component.ts @@ -14,6 +14,14 @@ import { RouterLink } from '@angular/router'; + +
Infinite Scroll Dropdown
+
+ Example using trait to load a product list with filtering and + sorting in memory +
+
+ diff --git a/apps/example-app/src/app/examples/utils/infinite-datasource.ts b/apps/example-app/src/app/examples/utils/infinite-datasource.ts new file mode 100644 index 00000000..9ce6a799 --- /dev/null +++ b/apps/example-app/src/app/examples/utils/infinite-datasource.ts @@ -0,0 +1,44 @@ +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { PageInfoModel } from '@ngrx-traits/common'; +import { ActionCreator, Selector, Store } from '@ngrx/store'; +import { TypedAction } from '@ngrx/store/src/models'; +import { Observable, Subscription, withLatestFrom } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +function getDataSource({ + store, + selectEntitiesList, + selectEntitiesPageInfo, + loadEntitiesNextPage, +}: { + store: Store; + selectEntitiesList: Selector; + selectEntitiesPageInfo: Selector; + loadEntitiesNextPage: ActionCreator TypedAction>; +}) { + class MyDataSource extends DataSource { + subscription?: Subscription; + connect(collectionViewer: CollectionViewer): Observable { + this.subscription = collectionViewer.viewChange + .pipe( + withLatestFrom(store.select(selectEntitiesPageInfo)), + filter( + ([{ end, start }, { total, hasNext, pageIndex, pageSize }]) => { + let endIndex = pageIndex * pageSize + pageSize; + // filter first request that is done by the cdkscroll, + // filter last request + // only do requests when you pass a specific threshold + return start != 0 && end <= total! && end >= endIndex; + }, + ), + ) + .subscribe(() => { + store.dispatch(loadEntitiesNextPage()); + }); + return store.select(selectEntitiesList); + } + + disconnect(collectionViewer: CollectionViewer): void {} + } + return new MyDataSource(); +} diff --git a/libs/ngrx-traits/signals/api-docs.md b/libs/ngrx-traits/signals/api-docs.md index d9bb6b0b..722ae20c 100644 --- a/libs/ngrx-traits/signals/api-docs.md +++ b/libs/ngrx-traits/signals/api-docs.md @@ -24,8 +24,6 @@ to the store using the setAllEntities method or the setEntitiesLoadResult 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.

-
withEntitiesMultiSelection(config)
-

Generates state, signals and methods for multi selection of entities

withEntitiesLocalPagination(config)

Generates necessary state, computed and methods for local pagination of entities in the store. Requires withEntities to be present in the store.

@@ -38,6 +36,19 @@ and changing the status errors manually or use withEntitiesLoadingCall to call the api with the [collection]PagedRequest params which handles setting the result and errors automatically. Requires withEntities and withCallStatus to be used. Requires withEntities and withCallStatus to be present in the store.

+
withEntitiesRemoteScrollPagination(config)
+

Generates necessary state, computed and methods for remote infinite scroll pagination of entities in the store. The +different between this and withEntitiesRemotePagination this will can only got to next and previous page, and the cache +of entities keeps growing, ideally for implementing infinite scroll style ui. +When the page changes, it will try to load the current page from cache if it's not present, +it will call set[collection]Loading(), and you should either create an effect that listens to is[Collection]Loading +and call the api with the [collection]PagedRequest params and use set[Collection]LoadResult to set the result +and changing the status errors manually +or use withEntitiesLoadingCall to call the api with the [collection]PagedRequest params which handles setting +the result and errors automatically. Requires withEntities and withCallStatus to be used. +Requires withEntities and withCallStatus to be present in the store.

+
withEntitiesMultiSelection(config)
+

Generates state, signals and methods for multi selection of entities

withEntitiesSingleSelection(config)

Generates state, computed and methods for single selection of entities. Requires withEntities to be present before this function.

withEntitiesLocalSort(config)
@@ -81,8 +92,8 @@ const store = signalStore( // generates the following signals store.usersCallStatus // 'init' | 'loading' | 'loaded' | { error: unknown } // generates the following computed signals - store.usersLoading // boolean - store.usersLoaded // boolean + store.isUsersLoading // boolean + store.isUsersLoaded // boolean store.usersError // unknown | null // generates the following methods store.setUsersLoading // () => void @@ -122,11 +133,11 @@ withCalls(({ productsSelectedEntity }) => ({ store.checkoutCallStatus // 'init' | 'loading' | 'loaded' | { error: unknown } store.checkoutResult // the result of the call // generates the following computed signals - store.loadProductDetailLoading // boolean - store.loadProductDetailLoaded // boolean + store.isLoadProductDetailLoading // boolean + store.isLoadProductDetailLoaded // boolean store.loadProductDetailError // string | null - store.checkoutLoading // boolean - store.checkoutLoaded // boolean + store.isCheckoutLoading // boolean + store.isCheckoutLoaded // boolean store.checkoutError // string | null // generates the following methods store.loadProductDetail // ({id: string}) => void @@ -168,10 +179,13 @@ const store = signalStore( }), ); - // generates the following signals +// generates the following signals store.productsFilter // { search: string } - // generates the following methods signals -store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + // generates the following computed signals + store.isProductsFilterChanged // boolean + // generates the following methods + store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + store.resetProductsFilter // () => void ``` @@ -221,11 +235,11 @@ export const store = signalStore( // withHooks(({ productsLoading, setProductsError, ...state }) => ({ // onInit: async () => { // effect(() => { -// if (productsLoading()) { +// if (isProductsLoading()) { // inject(ProductService) - .getProducts({ - search: productsFilter().name, - }) +// .getProducts({ +// search: productsFilter().name, +// }) // .pipe( // takeUntilDestroyed(), // tap((res) => @@ -244,10 +258,13 @@ export const store = signalStore( // }); // }, })), - // generates the following signals - store.productsFilter // { name: string } stored filter +// generates the following signals + store.productsFilter // { search: string } + // generates the following computed signals + store.isProductsFilterChanged // boolean // generates the following methods - store.filterProductsEntities // (options: { filter: { name: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + store.resetProductsFilter // () => void ``` @@ -316,40 +333,6 @@ export const ProductsRemoteStore = signalStore( }, }), ``` - - -## withEntitiesMultiSelection(config) -

Generates state, signals and methods for multi selection of entities

- -**Kind**: global function - -| Param | Description | -| --- | --- | -| config | | -| config.entity |

the entity type

| -| config.collection |

the collection name

| - -**Example** -```js -const entity = type(); -const collection = 'products'; -export const store = signalStore( - { providedIn: 'root' }, - withEntities({ entity, collection }), - withEntitiesMultiSelection({ entity, collection }), - ); - -// generates the following signals -store.productsSelectedIdsMap // Record; -// generates the following computed signals -store.productsSelectedEntities // Entity[]; -store.isAllProductsSelected // 'all' | 'none' | 'some'; -// generates the following methods -store.selectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; -store.deselectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; -store.toggleSelectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; -store.toggleSelectAllProducts // () => void; -``` ## withEntitiesLocalPagination(config) @@ -447,7 +430,7 @@ export const store = signalStore( // withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ // onInit: async () => { // effect(() => { -// if (productsLoading()) { +// if (isProductsLoading()) { // inject(ProductService) // .getProducts({ // take: productsPagedRequest().size, @@ -480,6 +463,140 @@ export const store = signalStore( store.loadProductsPage({ pageIndex: number, forceLoad?: boolean }) // loads the page and sets the requestPage to the pageIndex store.setProductsLoadResult(entities: Product[], total: number) // appends the entities to the cache of entities and total ``` + + +## withEntitiesRemoteScrollPagination(config) +

Generates necessary state, computed and methods for remote infinite scroll pagination of entities in the store. The +different between this and withEntitiesRemotePagination this will can only got to next and previous page, and the cache +of entities keeps growing, ideally for implementing infinite scroll style ui. +When the page changes, it will try to load the current page from cache if it's not present, +it will call set[collection]Loading(), and you should either create an effect that listens to is[Collection]Loading +and call the api with the [collection]PagedRequest params and use set[Collection]LoadResult to set the result +and changing the status errors manually +or use withEntitiesLoadingCall to call the api with the [collection]PagedRequest params which handles setting +the result and errors automatically. Requires withEntities and withCallStatus to be used. +Requires withEntities and withCallStatus to be present in the store.

+ +**Kind**: global function + +| Param | Description | +| --- | --- | +| config | | +| config.pageSize |

The number of entities to show per page

| +| config.pagesToCache |

The number of pages to cache

| +| config.entity |

The entity type

| +| config.collection |

The name of the collection

| + +**Example** +```js +const entity = type(); +const collection = 'products'; +export const store = signalStore( + { providedIn: 'root' }, + // required withEntities and withCallStatus + withEntities({ entity, collection }), + withCallStatus({ prop: collection, initialValue: 'loading' }), + + withEntitiesRemoteScrollPagination({ + entity, + collection, + pageSize: 5, + pagesToCache: 2, + }) + // after you can use withEntitiesLoadingCall to connect the filter to + // the api call, or do it manually as shown after + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsPagedRequest }) => { + return inject(ProductService) + .getProducts({ + take: productsPagedRequest().size, + skip: productsPagedRequest().startIndex, + }).pipe( + map((d) => ({ + entities: d.resultList, + total: d.total, + })), + ) + }, + }), +// withEntitiesLoadingCall is the same as doing the following: +// withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ +// onInit: async () => { +// effect(() => { +// if (isProductsLoading()) { +// inject(ProductService) +// .getProducts({ +// take: productsPagedRequest().size, +// skip: productsPagedRequest().startIndex, +// }) +// .pipe( +// takeUntilDestroyed(), +// tap((res) => +// patchState( +// state, +// setProductsLoadResult(res.resultList, res.total), +// ), +// ), +// catchError((error) => { +// setProductsError(error); +// return EMPTY; +// }), +// ) +// .subscribe(); +// } +// }); +// }, + })), + + // in your component add + store = inject(ProductsRemoteStore); + dataSource = getInfiniteScrollDataSource(store, { collecrion: 'products' }) // pass this to your cdkVirtualFor see examples section + // generates the following signals + store.productsPagination // { currentPage: number, requestPage: number, pageSize: 5, total: number, pagesToCache: number, cache: { start: number, end: number } } used internally + // generates the following computed signals + store.productsPageInfo // { pageIndex: number, total: number, pageSize: 5, pagesCount: number, hasPrevious: boolean, hasNext: boolean, loading: boolean } + store.productsPagedRequest // { startIndex: number, size: number, page: number } + // generates the following methods + store.loadProductsNextPage() // loads next page + store.loadProductsPreviousPage() // loads previous page + store.loadProductsFirstPage() // loads first page + store.setProductsLoadResult(entities: Product[], total: number) // appends the entities to the cache of entities and total +``` + + +## withEntitiesMultiSelection(config) +

Generates state, signals and methods for multi selection of entities

+ +**Kind**: global function + +| Param | Description | +| --- | --- | +| config | | +| config.entity |

the entity type

| +| config.collection |

the collection name

| + +**Example** +```js +const entity = type(); +const collection = 'products'; +export const store = signalStore( + { providedIn: 'root' }, + withEntities({ entity, collection }), + withEntitiesMultiSelection({ entity, collection }), + ); + +// generates the following signals +store.productsSelectedIdsMap // Record; +// generates the following computed signals +store.productsSelectedEntities // Entity[]; +store.isAllProductsSelected // 'all' | 'none' | 'some'; +// generates the following methods +store.selectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; +store.deselectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; +store.toggleSelectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; +store.toggleSelectAllProducts // () => void; +``` ## withEntitiesSingleSelection(config) @@ -514,9 +631,9 @@ export const store = signalStore( // generates the following computed signals store.productsSelectedEntity // Entity | undefined // generates the following methods - store.selectProductEntity // (options: { id: string | number }) => void - store.deselectProductEntity // (options: { id: string | number }) => void - store.toggleProductEntity // (options: { id: string | number }) => void + store.selectProductEntity // (config: { id: string | number }) => void + store.deselectProductEntity // (config: { id: string | number }) => void + store.toggleProductEntity // (config: { id: string | number }) => void ``` @@ -600,7 +717,7 @@ export const store = signalStore( // withHooks(({ productsSort, productsLoading, setProductsError, ...state }) => ({ // onInit: async () => { // effect(() => { -// if (productsLoading()) { +// if (isProductsLoading()) { // inject(ProductService) // .getProducts({ // sortColumn: productsSort().field, diff --git a/libs/ngrx-traits/signals/package.json b/libs/ngrx-traits/signals/package.json index 0ff22e38..36910e40 100644 --- a/libs/ngrx-traits/signals/package.json +++ b/libs/ngrx-traits/signals/package.json @@ -10,6 +10,9 @@ "@ngrx/signals": "^17.1.1", "rxjs": "^7.8.1" }, + "optionalDependencies": { + "@angular/cdk": "^17.1.0" + }, "sideEffects": false, "repository": { "type": "git", diff --git a/libs/ngrx-traits/signals/src/lib/index.ts b/libs/ngrx-traits/signals/src/lib/index.ts index afa3f411..9d5248f5 100644 --- a/libs/ngrx-traits/signals/src/lib/index.ts +++ b/libs/ngrx-traits/signals/src/lib/index.ts @@ -1,18 +1,28 @@ -export * from './util'; export * from './with-call-status/with-call-status'; +export * from './with-call-status/with-call-status.model'; export * from './with-entities-filter/with-entities-local-filter'; +export * from './with-entities-filter/with-entities-local-filter.model'; export * from './with-entities-filter/with-entities-remote-filter'; export * from './with-entities-pagination/with-entities-local-pagination'; +export * from './with-entities-pagination/with-entities-local-pagination.model'; export * from './with-entities-pagination/with-entities-remote-pagination'; +export * from './with-entities-pagination/with-entities-remote-pagination.model'; +export * from './with-entities-pagination/with-entities-remote-scroll-pagination'; +export * from './with-entities-pagination/with-entities-remote-scroll-pagination.model'; +export * from './with-entities-pagination/signal-infinite-datasource'; export { Sort, SortDirection, } from './with-entities-sort/with-entities-sort.utils'; export * from './with-entities-sort/with-entities-local-sort'; +export * from './with-entities-sort/with-entities-local-sort.model'; export * from './with-entities-sort/with-entities-remote-sort'; -export * from './with-entities-single-selection/with-entities-single-selection'; -export * from './with-entities-multi-selection/with-entities-multi-selection'; +export * from './with-entities-selection/with-entities-single-selection'; +export * from './with-entities-selection/with-entities-single-selection.model'; +export * from './with-entities-selection/with-entities-multi-selection'; +export * from './with-entities-selection/with-entities-multi-selection.model'; export * from './with-entities-loading-call/with-entities-loading-call'; export * from './with-logger/with-logger'; export * from './with-calls/with-calls'; +export * from './with-calls/with-calls.model'; export * from './with-sync-to-web-storage/with-sync-to-web-storage'; diff --git a/libs/ngrx-traits/signals/src/lib/util.ts b/libs/ngrx-traits/signals/src/lib/util.ts index 11dccceb..4734b77e 100644 --- a/libs/ngrx-traits/signals/src/lib/util.ts +++ b/libs/ngrx-traits/signals/src/lib/util.ts @@ -2,13 +2,41 @@ export function capitalize(name: string) { return name.charAt(0).toUpperCase() + name.slice(1); } -export type Prettify = { [K in keyof T]: T[K] } & {}; - export function getWithEntitiesKeys(config?: { collection?: string }) { const collection = config?.collection; return { idsKey: collection ? `${config.collection}Ids` : 'ids', entitiesKey: collection ? `${config.collection}Entities` : 'entities', entityMapKey: collection ? `${config.collection}EntityMap` : 'entityMap', + clearEntitiesCacheKey: collection + ? `clearEntities${config.collection}Cache` + : 'clearEntitiesCache', }; } + +export type OverridableFunction = { + (...args: unknown[]): void; + impl?: (...args: unknown[]) => void; +}; + +export function combineFunctions( + previous?: OverridableFunction, + next?: (...args: unknown[]) => void, +): OverridableFunction { + if (previous && !next) { + return previous; + } + const previousImplementation = previous?.impl; + const fun: OverridableFunction = + previous ?? + ((...args: unknown[]) => { + fun.impl?.(...args); + }); + fun.impl = next + ? (...args: unknown[]) => { + previousImplementation?.(...args); + next(...args); + } + : undefined; + return fun; +} diff --git a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.model.ts b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.model.ts new file mode 100644 index 00000000..eb293b09 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.model.ts @@ -0,0 +1,37 @@ +import { Signal } from '@angular/core'; + +export type CallStatus = 'init' | 'loading' | 'loaded' | { error: unknown }; +export type CallStatusState = { + callStatus: CallStatus; +}; +export type CallStatusComputed = { + isLoading: Signal; +} & { + isLoaded: Signal; +} & { + error: Signal; +}; +export type CallStatusMethods = { + setLoading: () => void; +} & { + setLoaded: () => void; +} & { + setError: (error?: unknown) => void; +}; +export type NamedCallStatusState = { + [K in Prop as `${K}CallStatus`]: CallStatus; +}; +export type NamedCallStatusComputed = { + [K in Prop as `is${Capitalize}Loading`]: Signal; +} & { + [K in Prop as `is${Capitalize}Loaded`]: Signal; +} & { + [K in Prop as `${K}Error`]: Signal; +}; +export type NamedCallStatusMethods = { + [K in Prop as `set${Capitalize}Loading`]: () => void; +} & { + [K in Prop as `set${Capitalize}Loaded`]: () => void; +} & { + [K in Prop as `set${Capitalize}Error`]: (error?: unknown) => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.ts b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.ts index 0d17b0bb..519ecf83 100644 --- a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.ts +++ b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.ts @@ -8,45 +8,17 @@ import { withState, } from '@ngrx/signals'; +import { + CallStatus, + CallStatusComputed, + CallStatusMethods, + CallStatusState, + NamedCallStatusComputed, + NamedCallStatusMethods, + NamedCallStatusState, +} from './with-call-status.model'; import { getWithCallStatusKeys } from './with-call-status.util'; -export type CallStatus = 'init' | 'loading' | 'loaded' | { error: unknown }; - -export type CallState = { - callStatus: CallStatus; -}; -export type CallStateComputed = { - loading: Signal; -} & { - loaded: Signal; -} & { - error: Signal; -}; -export type CallStateMethods = { - setLoading: () => void; -} & { - setLoaded: () => void; -} & { - setError: () => void; -}; -export type NamedCallState = { - [K in Prop as `${K}CallStatus`]: CallStatus; -}; -export type NamedCallStateComputed = { - [K in Prop as `${K}Loading`]: Signal; -} & { - [K in Prop as `${K}Loaded`]: Signal; -} & { - [K in Prop as `${K}Error`]: Signal; -}; -export type NamedCallStateMethods = { - [K in Prop as `set${Capitalize}Loading`]: () => void; -} & { - [K in Prop as `set${Capitalize}Loaded`]: () => void; -} & { - [K in Prop as `set${Capitalize}Error`]: () => void; -}; - /** * Generates necessary state, computed and methods for call progress status to the store * @param config - Configuration object @@ -67,8 +39,8 @@ export type NamedCallStateMethods = { * // generates the following signals * store.usersCallStatus // 'init' | 'loading' | 'loaded' | { error: unknown } * // generates the following computed signals - * store.usersLoading // boolean - * store.usersLoaded // boolean + * store.isUsersLoading // boolean + * store.isUsersLoaded // boolean * store.usersError // unknown | null * // generates the following methods * store.setUsersLoading // () => void @@ -80,9 +52,9 @@ export function withCallStatus(config?: { }): SignalStoreFeature< { state: {}; signals: {}; methods: {} }, { - state: CallState; - signals: CallStateComputed; - methods: CallStateMethods; + state: CallStatusState; + signals: CallStatusComputed; + methods: CallStatusMethods; } >; @@ -106,8 +78,8 @@ export function withCallStatus(config?: { * // generates the following signals * store.usersCallStatus // 'init' | 'loading' | 'loaded' | { error: unknown } * // generates the following computed signals - * store.usersLoading // boolean - * store.usersLoaded // boolean + * store.isUsersLoading // boolean + * store.isUsersLoaded // boolean * store.usersError // unknown | null * // generates the following methods * store.setUsersLoading // () => void @@ -127,9 +99,9 @@ export function withCallStatus( ): SignalStoreFeature< { state: {}; signals: {}; methods: {} }, { - state: NamedCallState; - signals: NamedCallStateComputed; - methods: NamedCallStateMethods; + state: NamedCallStatusState; + signals: NamedCallStatusComputed; + methods: NamedCallStatusMethods; } >; export function withCallStatus({ diff --git a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.util.ts b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.util.ts index 915786da..aad20a70 100644 --- a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.util.ts @@ -5,8 +5,8 @@ export function getWithCallStatusKeys(config?: { prop?: string }) { const capitalizedProp = prop && capitalize(prop); return { callStatusKey: prop ? `${config.prop}CallStatus` : 'callStatus', - loadingKey: prop ? `${config.prop}Loading` : 'loading', - loadedKey: prop ? `${config.prop}Loaded` : 'loaded', + loadingKey: prop ? `is${capitalizedProp}Loading` : 'isLoading', + loadedKey: prop ? `is${capitalizedProp}Loaded` : 'isLoaded', errorKey: prop ? `${config.prop}Error` : 'error', setLoadingKey: prop ? `set${capitalizedProp}Loading` : 'setLoading', setLoadedKey: prop ? `set${capitalizedProp}Loaded` : 'setLoaded', diff --git a/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.model.ts b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.model.ts new file mode 100644 index 00000000..6183e9f4 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.model.ts @@ -0,0 +1,22 @@ +import { Observable } from 'rxjs'; + +export type Call = ( + ...args: Params +) => Observable | Promise; +export type CallConfig< + Params extends readonly any[] = any[], + Result = any, + PropName extends string = string, +> = { + call: Call; + resultProp?: PropName; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; +}; +export type ExtractCallResultType = + T extends Call + ? R + : T extends CallConfig + ? R + : never; +export type ExtractCallParams = + T extends Call ? P : T extends CallConfig ? P : never; diff --git a/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.ts b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.ts index 6e7a9e11..0bc5d071 100644 --- a/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.ts +++ b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.ts @@ -26,7 +26,6 @@ import { first, from, map, - Observable, of, pipe, switchMap, @@ -34,34 +33,18 @@ import { import { CallStatus, - NamedCallState, - NamedCallStateComputed, -} from '../with-call-status/with-call-status'; + NamedCallStatusComputed, + NamedCallStatusState, +} from '../with-call-status/with-call-status.model'; import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; +import { + Call, + CallConfig, + ExtractCallParams, + ExtractCallResultType, +} from './with-calls.model'; import { getWithCallKeys } from './with-calls.util'; -type Call = ( - ...args: Params -) => Observable | Promise; -type CallConfig< - Params extends readonly any[] = any[], - Result = any, - PropName extends string = string, -> = { - call: Call; - resultProp?: PropName; - mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; -}; - -export type ExtractCallResultType = - T extends Call - ? R - : T extends CallConfig - ? R - : never; -export type ExtractCallParams = - T extends Call ? P : T extends CallConfig ? P : never; - /** * Generates necessary state, computed and methods to track the progress of the call * and store the result of the call @@ -87,11 +70,11 @@ export type ExtractCallParams = * store.checkoutCallStatus // 'init' | 'loading' | 'loaded' | { error: unknown } * store.checkoutResult // the result of the call * // generates the following computed signals - * store.loadProductDetailLoading // boolean - * store.loadProductDetailLoaded // boolean + * store.isLoadProductDetailLoading // boolean + * store.isLoadProductDetailLoaded // boolean * store.loadProductDetailError // string | null - * store.checkoutLoading // boolean - * store.checkoutLoaded // boolean + * store.isCheckoutLoading // boolean + * store.isCheckoutLoaded // boolean * store.checkoutError // string | null * // generates the following methods * store.loadProductDetail // ({id: string}) => void @@ -110,14 +93,14 @@ export function withCalls< ): SignalStoreFeature< Input, Input & { - state: NamedCallState & { + state: NamedCallStatusState & { [K in keyof Calls as Calls[K] extends CallConfig ? Calls[K]['resultProp'] extends string ? Calls[K]['resultProp'] : `${K & string}Result` - : `${K & string}Result`]?: ExtractCallResultType; + : `${K & string}Result`]: ExtractCallResultType; }; - signals: NamedCallStateComputed; + signals: NamedCallStatusComputed; methods: { [K in keyof Calls]: (...arg: ExtractCallParams) => void; }; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-filter.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-filter.util.ts index e01951dd..56c83f1c 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-filter.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-filter.util.ts @@ -19,6 +19,12 @@ export function getWithEntitiesFilterKeys(config?: { collection?: string }) { filterEntitiesKey: collection ? `filter${capitalizedProp}Entities` : 'filterEntities', + isEntitiesFilterChangedKey: collection + ? `is${capitalizedProp}FilterChanged` + : 'isEntitiesFilterChanged', + resetEntitiesFilterKey: collection + ? `reset${capitalizedProp}Filter` + : 'resetEntitiesFilter', }; } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.model.ts new file mode 100644 index 00000000..874de2ff --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.model.ts @@ -0,0 +1,49 @@ +import { Signal } from '@angular/core'; + +export type EntitiesFilterState = { entitiesFilter: Filter }; +export type NamedEntitiesFilterState = { + [K in Collection as `${K}Filter`]: Filter; +}; +export type EntitiesFilterComputed = { + isEntitiesFilterChanged: Signal; +}; +export type NamedEntitiesFilterComputed = { + [K in Collection as `is${Capitalize}FilterChanged`]: Signal; +}; +export type EntitiesFilterMethods = { + filterEntities: ( + options: + | { + filter: Filter; + debounce?: number; + patch?: false | undefined; + forceLoad?: boolean; + } + | { + filter: Partial; + debounce?: number; + patch: true; + forceLoad?: boolean; + }, + ) => void; + resetEntitiesFilter: () => void; +}; +export type NamedEntitiesFilterMethods = { + [K in Collection as `filter${Capitalize}Entities`]: ( + options: + | { + filter: Filter; + debounce?: number; + patch?: false | undefined; + forceLoad?: boolean; + } + | { + filter: Partial; + debounce?: number; + patch: true; + forceLoad?: boolean; + }, + ) => void; +} & { + [K in Collection as `reset${Capitalize}Filter`]: () => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.ts index 760447db..bac7268e 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.ts @@ -3,6 +3,7 @@ import { patchState, signalStoreFeature, SignalStoreFeature, + withComputed, withMethods, withState, } from '@ngrx/signals'; @@ -16,33 +17,19 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; import { pipe, tap } from 'rxjs'; -import { getWithEntitiesKeys } from '../util'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; import { debounceFilterPipe, getWithEntitiesFilterKeys, } from './with-entities-filter.util'; - -export type EntitiesFilterState = { entitiesFilter: Filter }; -export type NamedEntitiesFilterState = { - [K in Collection as `${K}Filter`]: Filter; -}; - -export type EntitiesFilterMethods = { - filterEntities: (options: { - filter: Filter; - debounce?: number; - patch?: boolean; - forceLoad?: boolean; - }) => void; -}; -export type NamedEntitiesFilterMethods = { - [K in Collection as `filter${Capitalize}Entities`]: (options: { - filter: Filter; - debounce?: number; - patch?: boolean; - forceLoad?: boolean; - }) => void; -}; +import { + EntitiesFilterComputed, + EntitiesFilterMethods, + EntitiesFilterState, + NamedEntitiesFilterComputed, + NamedEntitiesFilterMethods, + NamedEntitiesFilterState, +} from './with-entities-local-filter.model'; /** * Generates necessary state, computed and methods for locally filtering entities in the store, @@ -75,8 +62,11 @@ export type NamedEntitiesFilterMethods = { * * // generates the following signals * store.productsFilter // { search: string } - * // generates the following methods signals - * store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * // generates the following computed signals + * store.isProductsFilterChanged // boolean + * // generates the following methods + * store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * store.resetProductsFilter // () => void */ export function withEntitiesLocalFilter< Entity extends { id: string | number }, @@ -93,7 +83,7 @@ export function withEntitiesLocalFilter< }, { state: EntitiesFilterState; - signals: {}; + signals: EntitiesFilterComputed; methods: EntitiesFilterMethods; } >; @@ -126,10 +116,13 @@ export function withEntitiesLocalFilter< * }), * ); * - * // generates the following signals + * // generates the following signals * store.productsFilter // { search: string } - * // generates the following methods signals - * store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * // generates the following computed signals + * store.isProductsFilterChanged // boolean + * // generates the following methods + * store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * store.resetProductsFilter // () => void */ export function withEntitiesLocalFilter< Entity extends { id: string | number }, @@ -150,7 +143,7 @@ export function withEntitiesLocalFilter< }, { state: NamedEntitiesFilterState; - signals: {}; + signals: NamedEntitiesFilterComputed; methods: NamedEntitiesFilterMethods; } >; @@ -168,10 +161,24 @@ export function withEntitiesLocalFilter< entity?: Entity; collection?: Collection; }): SignalStoreFeature { - const { entityMapKey, idsKey } = getWithEntitiesKeys(config); - const { filterEntitiesKey, filterKey } = getWithEntitiesFilterKeys(config); + const { entityMapKey, idsKey, clearEntitiesCacheKey } = + getWithEntitiesKeys(config); + const { + filterEntitiesKey, + filterKey, + resetEntitiesFilterKey, + isEntitiesFilterChangedKey, + } = getWithEntitiesFilterKeys(config); return signalStoreFeature( withState({ [filterKey]: defaultFilter }), + withComputed((state: Record>) => { + const filter = state[filterKey] as Signal; + return { + [isEntitiesFilterChangedKey]: computed(() => { + return JSON.stringify(filter()) !== JSON.stringify(defaultFilter); + }), + }; + }), withMethods((state: Record>) => { const filter = state[filterKey] as Signal; const entitiesMap = state[entityMapKey] as Signal>; @@ -180,31 +187,38 @@ export function withEntitiesLocalFilter< // the ids array of the state with the filtered ids array, and the state.entities depends on it, // so hour filter function needs the full list of entities always which will be always so we get them from entityMap const entities = computed(() => Object.values(entitiesMap())); - return { - [filterEntitiesKey]: rxMethod<{ - filter: Filter; - debounce?: number; - patch?: boolean; - forceLoad?: boolean; - }>( - pipe( - debounceFilterPipe(filter), - tap((value) => { - const newEntities = entities().filter((entity) => { - return filterFn(entity, value.filter); - }); - patchState( - state as StateSignal>, - { - [filterKey]: value.filter, - }, - { - [idsKey]: newEntities.map((entity) => entity.id), - }, - ); - }), - ), + const clearEntitiesCache = combineFunctions(state[clearEntitiesCacheKey]); + const filterEntities = rxMethod<{ + filter: Filter; + debounce?: number; + patch?: boolean; + forceLoad?: boolean; + }>( + pipe( + debounceFilterPipe(filter), + tap((value) => { + const newEntities = entities().filter((entity) => { + return filterFn(entity, value.filter); + }); + patchState( + state as StateSignal>, + { + [filterKey]: value.filter, + }, + { + [idsKey]: newEntities.map((entity) => entity.id), + }, + ); + clearEntitiesCache(); + }), ), + ); + return { + [clearEntitiesCacheKey]: clearEntitiesCache, + [filterEntitiesKey]: filterEntities, + [resetEntitiesFilterKey]: () => { + filterEntities({ filter: defaultFilter }); + }, }; }), ); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.ts index 92453f12..b94b3376 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.ts @@ -1,8 +1,9 @@ -import { Signal } from '@angular/core'; +import { computed, Signal } from '@angular/core'; import { patchState, signalStoreFeature, SignalStoreFeature, + withComputed, withMethods, withState, } from '@ngrx/signals'; @@ -15,21 +16,22 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; import { pipe, tap } from 'rxjs'; -import type { - CallStateMethods, - NamedCallStateMethods, -} from '../with-call-status/with-call-status'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; +import { + CallStatusMethods, + NamedCallStatusMethods, +} from '../with-call-status/with-call-status.model'; import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; import { debounceFilterPipe, getWithEntitiesFilterKeys, } from './with-entities-filter.util'; -import type { +import { EntitiesFilterMethods, EntitiesFilterState, NamedEntitiesFilterMethods, NamedEntitiesFilterState, -} from './with-entities-local-filter'; +} from './with-entities-local-filter.model'; /** * Generates necessary state, computed and methods for remotely filtering entities in the store, @@ -71,7 +73,7 @@ import type { * // withHooks(({ productsLoading, setProductsError, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // search: productsFilter().name, @@ -94,10 +96,13 @@ import type { * // }); * // }, * })), - * // generates the following signals - * store.productsFilter // { name: string } stored filter + * // generates the following signals + * store.productsFilter // { search: string } + * // generates the following computed signals + * store.isProductsFilterChanged // boolean * // generates the following methods - * store.filterProductsEntities // (options: { filter: { name: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * store.resetProductsFilter // () => void */ export function withEntitiesRemoteFilter< Entity extends { id: string | number }, @@ -109,7 +114,7 @@ export function withEntitiesRemoteFilter< { state: EntityState; signals: EntitySignals; - methods: CallStateMethods; + methods: CallStatusMethods; }, { state: EntitiesFilterState; @@ -157,7 +162,7 @@ export function withEntitiesRemoteFilter< * // withHooks(({ productsLoading, setProductsError, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // search: productsFilter().name, @@ -180,10 +185,13 @@ export function withEntitiesRemoteFilter< * // }); * // }, * })), - * // generates the following signals - * store.productsFilter // { name: string } stored filter + * // generates the following signals + * store.productsFilter // { search: string } + * // generates the following computed signals + * store.isProductsFilterChanged // boolean * // generates the following methods - * store.filterProductsEntities // (options: { filter: { name: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * store.filterProductsEntities // (options: { filter: { search: string }, debounce?: number, patch?: boolean, forceLoad?: boolean }) => void + * store.resetProductsFilter // () => void */ export function withEntitiesRemoteFilter< @@ -198,7 +206,7 @@ export function withEntitiesRemoteFilter< { state: NamedEntityState; signals: NamedEntitySignals; - methods: NamedCallStateMethods; + methods: NamedCallStatusMethods; }, { state: NamedEntitiesFilterState; @@ -219,30 +227,53 @@ export function withEntitiesRemoteFilter< defaultFilter: Filter; }): SignalStoreFeature { const { setLoadingKey } = getWithCallStatusKeys({ prop: config.collection }); - const { filterKey, filterEntitiesKey } = getWithEntitiesFilterKeys(config); + const { + filterKey, + filterEntitiesKey, + resetEntitiesFilterKey, + isEntitiesFilterChangedKey, + } = getWithEntitiesFilterKeys(config); + const { clearEntitiesCacheKey } = getWithEntitiesKeys(config); + return signalStoreFeature( withState({ [filterKey]: defaultFilter }), + withComputed((state: Record>) => { + const filter = state[filterKey] as Signal; + return { + [isEntitiesFilterChangedKey]: computed(() => { + return JSON.stringify(filter()) !== JSON.stringify(defaultFilter); + }), + }; + }), withMethods((state: Record>) => { const setLoading = state[setLoadingKey] as () => void; const filter = state[filterKey] as Signal; - return { - [filterEntitiesKey]: rxMethod<{ - filter: Filter; - debounce?: number; - patch?: boolean; - forceLoad?: boolean; - }>( - pipe( - debounceFilterPipe(filter), - tap((value) => { - setLoading(); - patchState(state as StateSignal>, { - [filterKey]: value.filter, - }); - }), - ), + const clearEntitiesCache = combineFunctions(state[clearEntitiesCacheKey]); + + const filterEntities = rxMethod<{ + filter: Filter; + debounce?: number; + patch?: boolean; + forceLoad?: boolean; + }>( + pipe( + debounceFilterPipe(filter), + tap((value) => { + setLoading(); + patchState(state as StateSignal>, { + [filterKey]: value.filter, + }); + clearEntitiesCache(); + }), ), + ); + return { + [clearEntitiesCacheKey]: clearEntitiesCache, + [filterEntitiesKey]: filterEntities, + [resetEntitiesFilterKey]: () => { + filterEntities({ filter: defaultFilter }); + }, }; }), ); 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 61bb94b5..537d59b9 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 @@ -1,10 +1,10 @@ import { - effect, EnvironmentInjector, inject, runInInjectionContext, Signal, } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; import { patchState, signalStoreFeature, @@ -25,22 +25,29 @@ import type { SignalStoreFeatureResult, SignalStoreSlices, } from '@ngrx/signals/src/signal-store-models'; -import { catchError, first, from, map, Observable, of } from 'rxjs'; +import { + catchError, + concatMap, + exhaustMap, + first, + from, + map, + Observable, + of, + switchMap, +} from 'rxjs'; +import { filter } from 'rxjs/operators'; import { - CallState, - CallStateComputed, - CallStateMethods, - NamedCallState, - NamedCallStateComputed, - NamedCallStateMethods, -} from '../with-call-status/with-call-status'; + CallStatusComputed, + CallStatusMethods, + CallStatusState, + NamedCallStatusComputed, + NamedCallStatusMethods, + NamedCallStatusState, +} from '../with-call-status/with-call-status.model'; import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; -import { - EntitiesPaginationRemoteMethods, - NamedEntitiesPaginationRemoteMethods, - NamedEntitiesPaginationSetResultMethods, -} from '../with-entities-pagination/with-entities-remote-pagination'; +import { EntitiesPaginationRemoteMethods } from '../with-entities-pagination/with-entities-remote-pagination.model'; import { getWithEntitiesRemotePaginationKeys } from '../with-entities-pagination/with-entities-remote-pagination.util'; /** @@ -124,11 +131,12 @@ export function withEntitiesLoadingCall< ? { entities: Entity[]; total: number } : Entity[] | { entities: Entity[] } >; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; }): SignalStoreFeature< Input & { - state: EntityState & CallState; - signals: EntitySignals & CallStateComputed; - methods: CallStateMethods; + state: EntityState & CallStatusState; + signals: EntitySignals & CallStatusComputed; + methods: CallStatusMethods; }, EmptyFeatureResult >; @@ -230,12 +238,14 @@ export function withEntitiesLoadingCall< // ? { entities: Entity[]; total: number } // : Entity[] | { entities: Entity[] } >; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; }): SignalStoreFeature< Input & { - state: NamedEntityState & NamedCallState; + state: NamedEntityState & + NamedCallStatusState; signals: NamedEntitySignals & - NamedCallStateComputed; - methods: NamedCallStateMethods; + NamedCallStatusComputed; + methods: NamedCallStatusMethods; }, EmptyFeatureResult >; @@ -247,6 +257,7 @@ export function withEntitiesLoadingCall< >({ collection, fetchEntities, + ...config }: { entity?: Entity; // is this needed? entity can come from the method fetchEntities return type collection?: Collection; @@ -255,6 +266,7 @@ export function withEntitiesLoadingCall< Input['signals'] & Input['methods'], ) => Observable | Promise; + mapPipe?: 'switchMap' | 'concatMap' | 'exhaustMap'; }): SignalStoreFeature { const { loadingKey, setErrorKey, setLoadedKey } = getWithCallStatusKeys({ prop: collection, @@ -273,59 +285,68 @@ export function withEntitiesLoadingCall< return signalStoreFeature( withHooks({ - onInit: (input, environmentInjector = inject(EnvironmentInjector)) => { - effect(() => { - if (loading()) { - runInInjectionContext(environmentInjector, () => { - from( - fetchEntities({ - ...store.slices, - ...store.signals, - ...store.methods, - } as SignalStoreSlices & - Input['signals'] & - Input['methods']), - ) - .pipe( - map((result) => { - if (Array.isArray(result)) { + onInit: (state, environmentInjector = inject(EnvironmentInjector)) => { + const loading$ = toObservable(loading); + const mapPipe = config.mapPipe ? mapPipes[config.mapPipe] : switchMap; + + loading$ + .pipe( + filter(Boolean), + mapPipe(() => + runInInjectionContext(environmentInjector, () => + from( + fetchEntities({ + ...store.slices, + ...store.signals, + ...store.methods, + } as SignalStoreSlices & + Input['signals'] & + Input['methods']), + ), + ).pipe( + map((result) => { + if (Array.isArray(result)) { + patchState( + state, + collection + ? setAllEntities(result as Entity[], { + collection, + }) + : setAllEntities(result), + ); + } else { + const { entities, total } = result; + if (setEntitiesLoadResult) + setEntitiesLoadResult(entities, total); + else patchState( - input, + state, collection - ? setAllEntities(result as Entity[], { + ? setAllEntities(entities as Entity[], { collection, }) - : setAllEntities(result), + : setAllEntities(entities), ); - } else { - const { entities, total } = result; - if (setEntitiesLoadResult) - setEntitiesLoadResult(entities, total); - else - patchState( - input, - collection - ? setAllEntities(entities as Entity[], { - collection, - }) - : setAllEntities(entities), - ); - } - setLoaded(); - }), - catchError((error: unknown) => { - setError(error); - setLoaded(); - return of(); - }), - first(), - ) - .subscribe(); - }); - } - }); + } + setLoaded(); + }), + catchError((error: unknown) => { + setError(error); + setLoaded(); + return of(); + }), + first(), + ), + ), + ) + .subscribe(); }, }), )(store); // we execute the factory so we can pass the input }; } +const mapPipes = { + switchMap: switchMap, + concatMap: concatMap, + exhaustMap: exhaustMap, +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/signal-infinite-datasource.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/signal-infinite-datasource.ts new file mode 100644 index 00000000..d3072635 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/signal-infinite-datasource.ts @@ -0,0 +1,67 @@ +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { Signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import type { + EntitySignals, + NamedEntitySignals, +} from '@ngrx/signals/entities/src/models'; +import { Observable, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { getWithEntitiesKeys } from '../util'; +import { getWithEntitiesInfinitePaginationKeys } from './with-entities-infinite-pagination.util'; +import { InfinitePaginationState } from './with-entities-remote-scroll-pagination.model'; +import { + EntitiesPaginationInfiniteMethods, + NamedEntitiesPaginationInfiniteMethods, +} from './with-entities-remote-scroll-pagination.model'; + +export function getInfiniteScrollDataSource( + options: + | { + store: EntitySignals & + EntitiesPaginationInfiniteMethods; + } + | { + collection: Collection; + store: NamedEntitySignals & + NamedEntitiesPaginationInfiniteMethods; + }, +) { + const collection = 'collection' in options ? options.collection : undefined; + const { loadEntitiesNextPageKey, paginationKey } = + getWithEntitiesInfinitePaginationKeys({ + collection, + }); + const { entitiesKey } = getWithEntitiesKeys({ collection }); + const store = options.store as Record; + const entities = store[entitiesKey] as Signal; + const pagination = store[paginationKey] as Signal; + const loadEntitiesNextPage = store[loadEntitiesNextPageKey] as () => void; + + class MyDataSource extends DataSource { + subscription?: Subscription; + entitiesList = toObservable(entities); + connect(collectionViewer: CollectionViewer): Observable { + this.subscription = collectionViewer.viewChange + .pipe( + filter(({ end, start }) => { + const { pageSize, total, cache } = pagination(); + // filter first request that is done by the cdkscroll, + // filter last request + // only do requests when you pass a specific threshold + return start != 0 && end <= total! && end + pageSize >= cache.end; + }), + ) + .subscribe(() => { + loadEntitiesNextPage(); + }); + return this.entitiesList; + } + + disconnect(): void { + this.subscription?.unsubscribe(); + } + } + return new MyDataSource(); +} diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-infinite-pagination.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-infinite-pagination.util.ts new file mode 100644 index 00000000..d06e287c --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-infinite-pagination.util.ts @@ -0,0 +1,34 @@ +import { capitalize } from '../util'; + +export function getWithEntitiesInfinitePaginationKeys(config?: { + collection?: string; +}) { + const collection = config?.collection; + const capitalizedProp = collection && capitalize(collection); + return { + paginationKey: collection + ? `${config.collection}Pagination` + : 'entitiesPagination', + entitiesPageInfoKey: collection + ? `${config.collection}PageInfo` + : 'entitiesPageInfo', + entitiesPagedRequestKey: collection + ? `${config.collection}PagedRequest` + : 'entitiesPagedRequest', + loadEntitiesNextPageKey: collection + ? `load + ${capitalizedProp}NextPage` + : 'loadEntitiesNextPage', + loadEntitiesPreviousPageKey: collection + ? `load + ${capitalizedProp}PreviousPage` + : 'loadEntitiesPreviousPage', + loadEntitiesFirstPageKey: collection + ? `load + ${capitalizedProp}FirstPage` + : 'loadEntitiesFirstPage', + setEntitiesLoadResultKey: collection + ? `set${capitalizedProp}LoadedResult` + : 'setEntitiesLoadedResult', + }; +} diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.model.ts new file mode 100644 index 00000000..abe71426 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.model.ts @@ -0,0 +1,47 @@ +import { Signal } from '@angular/core'; + +export type EntitiesPaginationLocalState = { + entitiesPagination: { + currentPage: number; + pageSize: number; + }; +}; +export type NamedEntitiesPaginationLocalState = { + [K in Collection as `${K}Pagination`]: { + currentPage: number; + pageSize: number; + }; +}; +export type EntitiesPaginationLocalComputed = { + entitiesCurrentPage: Signal<{ + entities: Entity[]; + pageIndex: number; + total: number | undefined; + pageSize: number; + pagesCount: number | undefined; + hasPrevious: boolean; + hasNext: boolean; + }>; +}; +export type NamedEntitiesPaginationLocalComputed< + Entity, + Collection extends string, +> = { + [K in Collection as `${K}CurrentPage`]: Signal<{ + entities: Entity[]; + pageIndex: number; + total: number | undefined; + pageSize: number; + pagesCount: number | undefined; + hasPrevious: boolean; + hasNext: boolean; + }>; +}; +export type EntitiesPaginationLocalMethods = { + loadEntitiesPage: (options: { pageIndex: number }) => void; +}; +export type NamedEntitiesPaginationLocalMethods = { + [K in Collection as `load${Capitalize}Page`]: (options: { + pageIndex: number; + }) => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.ts index a3107f3a..4a5a3899 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.ts @@ -1,10 +1,9 @@ -import { computed, effect, Signal } from '@angular/core'; +import { computed, Signal } from '@angular/core'; import { patchState, signalStoreFeature, SignalStoreFeature, withComputed, - withHooks, withMethods, withState, } from '@ngrx/signals'; @@ -13,62 +12,18 @@ import { EntitySignals, NamedEntitySignals, } from '@ngrx/signals/entities/src/models'; +import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import { getWithEntitiesKeys } from '../util'; -import { getWithEntitiesFilterKeys } from '../with-entities-filter/with-entities-filter.util'; -import { getWithEntitiesSortKeys } from '../with-entities-sort/with-entities-sort.util'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; import { - getWithEntitiesLocalPaginationKeys, - gotoFirstPageIfFilterOrSortChanges, -} from './with-entities-local-pagination.util'; - -export type EntitiesPaginationLocalState = { - pagination: { - currentPage: number; - pageSize: number; - }; -}; -export type NamedEntitiesPaginationLocalState = { - [K in Collection as `${K}Pagination`]: { - currentPage: number; - pageSize: number; - }; -}; - -export type EntitiesPaginationLocalComputed = { - entitiesCurrentPage: Signal<{ - entities: Entity[]; - pageIndex: number; - total: number | undefined; - pageSize: number; - pagesCount: number | undefined; - hasPrevious: boolean; - hasNext: boolean; - }>; -}; -export type NamedEntitiesPaginationLocalComputed< - Entity, - Collection extends string, -> = { - [K in Collection as `${K}CurrentPage`]: Signal<{ - entities: Entity[]; - pageIndex: number; - total: number | undefined; - pageSize: number; - pagesCount: number | undefined; - hasPrevious: boolean; - hasNext: boolean; - }>; -}; - -export type EntitiesPaginationLocalMethods = { - loadEntitiesPage: (options: { pageIndex: number }) => void; -}; -export type NamedEntitiesPaginationLocalMethods = { - [K in Collection as `load${Capitalize}Page`]: (options: { - pageIndex: number; - }) => void; -}; + EntitiesPaginationLocalComputed, + EntitiesPaginationLocalMethods, + EntitiesPaginationLocalState, + NamedEntitiesPaginationLocalComputed, + NamedEntitiesPaginationLocalMethods, + NamedEntitiesPaginationLocalState, +} from './with-entities-local-pagination.model'; +import { getWithEntitiesLocalPaginationKeys } from './with-entities-local-pagination.util'; /** * Generates necessary state, computed and methods for local pagination of entities in the store. @@ -180,9 +135,7 @@ export function withEntitiesLocalPagination< entity?: Entity; collection?: Collection; } = {}): SignalStoreFeature { - const { entitiesKey } = getWithEntitiesKeys(config); - const { filterKey } = getWithEntitiesFilterKeys(config); - const { sortKey } = getWithEntitiesSortKeys(config); + const { entitiesKey, clearEntitiesCacheKey } = getWithEntitiesKeys(config); const { loadEntitiesPageKey, entitiesCurrentPageKey, paginationKey } = getWithEntitiesLocalPaginationKeys(config); @@ -233,9 +186,19 @@ export function withEntitiesLocalPagination< currentPage: number; }>; return { + [clearEntitiesCacheKey]: combineFunctions( + state[clearEntitiesCacheKey], + () => { + patchState(state as StateSignal, { + [paginationKey]: { + ...pagination(), + currentPage: 0, + }, + }); + }, + ), [loadEntitiesPageKey]: ({ pageIndex }: { pageIndex: number }) => { - patchState(state as any, { - // TODO this is a hack, we need to fix the type of state + patchState(state as StateSignal, { [paginationKey]: { ...pagination(), currentPage: pageIndex, @@ -244,16 +207,5 @@ export function withEntitiesLocalPagination< }, }; }), - withHooks({ - onInit: (input) => { - gotoFirstPageIfFilterOrSortChanges( - input, - filterKey, - sortKey, - entitiesCurrentPageKey, - loadEntitiesPageKey, - ); - }, - }), ); } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.util.ts index 524ad7c1..04dc4c45 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.util.ts @@ -1,5 +1,3 @@ -import { effect, Signal } from '@angular/core'; - import { capitalize } from '../util'; export function getWithEntitiesLocalPaginationKeys(config?: { @@ -8,7 +6,9 @@ export function getWithEntitiesLocalPaginationKeys(config?: { const collection = config?.collection; const capitalizedProp = collection && capitalize(collection); return { - paginationKey: collection ? `${config.collection}Pagination` : 'pagination', + paginationKey: collection + ? `${config.collection}Pagination` + : 'entitiesPagination', entitiesCurrentPageKey: collection ? `${config.collection}CurrentPage` : 'entitiesCurrentPage', @@ -17,37 +17,3 @@ export function getWithEntitiesLocalPaginationKeys(config?: { : 'loadEntitiesPage', }; } - -export function gotoFirstPageIfFilterOrSortChanges( - store: Record, - filterKey: string, - sortKey: string, - entitiesCurrentPageKey: string, - loadEntitiesPageKey: string, -) { - if (filterKey in store || sortKey in store) { - const filter = store[filterKey] as Signal; - const sort = store[sortKey] as Signal; - const entitiesCurrentPage = store[entitiesCurrentPageKey] as Signal<{ - pageIndex: number; - }>; - const loadEntitiesPage = store[loadEntitiesPageKey] as (options: { - pageIndex: number; - }) => void; - let lastFilter = filter?.(); - let lastSort = sort?.(); - effect( - () => { - if ( - entitiesCurrentPage().pageIndex > 0 && - (lastFilter !== filter?.() || lastSort !== sort?.()) - ) { - lastFilter = filter?.(); - lastSort = sort?.(); - loadEntitiesPage({ pageIndex: 0 }); - } - }, - { allowSignalWrites: true }, - ); - } -} diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.model.ts new file mode 100644 index 00000000..316357cf --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.model.ts @@ -0,0 +1,84 @@ +import { Signal } from '@angular/core'; + +import { + EntitiesPaginationLocalMethods, + NamedEntitiesPaginationLocalMethods, +} from './with-entities-local-pagination.model'; + +export type PaginationState = { + currentPage: number; + requestPage: number; + pageSize: number; + total: number | undefined; + pagesToCache: number; + cache: { + start: number; + end: number; + }; +}; +export type EntitiesPaginationRemoteState = { + entitiesPagination: PaginationState; +}; +export type NamedEntitiesPaginationRemoteState = { + [K in Collection as `${K}Pagination`]: PaginationState; +}; +export type EntitiesPaginationRemoteComputed = { + entitiesCurrentPage: Signal<{ + entities: Entity[]; + pageIndex: number; + total: number | undefined; + pageSize: number; + pagesCount: number | undefined; + hasPrevious: boolean; + hasNext: boolean; + loading: boolean; + }>; + entitiesPagedRequest: Signal<{ + startIndex: number; + size: number; + page: number; + }>; +}; +export type NamedEntitiesPaginationRemoteComputed< + Entity, + Collection extends string, +> = { + [K in Collection as `${K}PagedRequest`]: Signal<{ + startIndex: number; + size: number; + page: number; + }>; +} & { + [K in Collection as `${K}CurrentPage`]: Signal<{ + entities: Entity[]; + pageIndex: number; + total: number | undefined; + pageSize: number; + pagesCount: number | undefined; + hasPrevious: boolean; + hasNext: boolean; + loading: boolean; + }>; +}; +export type EntitiesPaginationRemoteMethods = + EntitiesPaginationLocalMethods & { + setEntitiesLoadedResult: (entities: Entity[], total: number) => void; + }; +export type NamedEntitiesPaginationSetResultMethods< + Entity, + Collection extends string, +> = { + [K in Collection as `set${Capitalize}LoadedResult`]: ( + entities: Entity[], + total: number, + ) => void; +}; +export type NamedEntitiesPaginationRemoteMethods< + Entity, + Collection extends string, +> = NamedEntitiesPaginationLocalMethods & { + [K in Collection as `set${Capitalize}LoadedResult`]: ( + entities: Entity[], + total: number, + ) => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.ts index 7262b1f0..6560b614 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.ts @@ -1,10 +1,9 @@ -import { computed, effect, Signal } from '@angular/core'; +import { computed, Signal } from '@angular/core'; import { patchState, signalStoreFeature, SignalStoreFeature, withComputed, - withHooks, withMethods, withState, } from '@ngrx/signals'; @@ -17,108 +16,30 @@ import type { EntitySignals, NamedEntitySignals, } from '@ngrx/signals/entities/src/models'; -import { rxMethod } from '@ngrx/signals/rxjs-interop'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import { pipe, tap } from 'rxjs'; -import { getWithEntitiesKeys } from '../util'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; import { - CallStateComputed, - CallStateMethods, - NamedCallStateComputed, - NamedCallStateMethods, -} from '../with-call-status/with-call-status'; + CallStatusComputed, + CallStatusMethods, + NamedCallStatusComputed, + NamedCallStatusMethods, +} from '../with-call-status/with-call-status.model'; import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; -import { getWithEntitiesFilterKeys } from '../with-entities-filter/with-entities-filter.util'; -import { getWithEntitiesSortKeys } from '../with-entities-sort/with-entities-sort.util'; -import type { - EntitiesPaginationLocalMethods, - NamedEntitiesPaginationLocalMethods, -} from './with-entities-local-pagination'; -import { gotoFirstPageIfFilterOrSortChanges } from './with-entities-local-pagination.util'; -import { getWithEntitiesRemotePaginationKeys } from './with-entities-remote-pagination.util'; - -export type PaginationState = { - currentPage: number; - requestPage: number; - pageSize: number; - total: number | undefined; - pagesToCache: number; - cache: { - start: number; - end: number; - }; -}; - -export type EntitiesPaginationRemoteState = { - pagination: PaginationState; -}; - -export type NamedEntitiesPaginationRemoteState = { - [K in Collection as `${K}Pagination`]: PaginationState; -}; - -export type EntitiesPaginationRemoteComputed = { - entitiesCurrentPage: Signal<{ - entities: Entity[]; - pageIndex: number; - total: number | undefined; - pageSize: number; - pagesCount: number | undefined; - hasPrevious: boolean; - hasNext: boolean; - loading: boolean; - }>; - entitiesPagedRequest: Signal<{ - startIndex: number; - size: number; - page: number; - }>; -}; -export type NamedEntitiesPaginationRemoteComputed< - Entity, - Collection extends string, -> = { - [K in Collection as `${K}PagedRequest`]: Signal<{ - startIndex: number; - size: number; - page: number; - }>; -} & { - [K in Collection as `${K}CurrentPage`]: Signal<{ - entities: Entity[]; - pageIndex: number; - total: number | undefined; - pageSize: number; - pagesCount: number | undefined; - hasPrevious: boolean; - hasNext: boolean; - loading: boolean; - }>; -}; -export type EntitiesPaginationRemoteMethods = - EntitiesPaginationLocalMethods & { - setEntitiesLoadedResult: (entities: Entity[], total: number) => void; - }; +import { + EntitiesPaginationRemoteComputed, + EntitiesPaginationRemoteMethods, + EntitiesPaginationRemoteState, + NamedEntitiesPaginationRemoteComputed, + NamedEntitiesPaginationRemoteMethods, + NamedEntitiesPaginationRemoteState, + PaginationState, +} from './with-entities-remote-pagination.model'; +import { + getWithEntitiesRemotePaginationKeys, + loadEntitiesPageFactory, +} from './with-entities-remote-pagination.util'; -export type NamedEntitiesPaginationSetResultMethods< - Entity, - Collection extends string, -> = { - [K in Collection as `set${Capitalize}LoadedResult`]: ( - entities: Entity[], - total: number, - ) => void; -}; -export type NamedEntitiesPaginationRemoteMethods< - Entity, - Collection extends string, -> = NamedEntitiesPaginationLocalMethods & { - [K in Collection as `set${Capitalize}LoadedResult`]: ( - entities: Entity[], - total: number, - ) => void; -}; /** * Generates necessary state, computed and methods for remote pagination of entities in the store. * When the page changes, it will try to load the current page from cache if it's not present, @@ -168,7 +89,7 @@ export type NamedEntitiesPaginationRemoteMethods< * // withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // take: productsPagedRequest().size, @@ -211,8 +132,8 @@ export function withEntitiesRemotePagination< }): SignalStoreFeature< { state: EntityState; - signals: EntitySignals & CallStateComputed; - methods: CallStateMethods; + signals: EntitySignals & CallStatusComputed; + methods: CallStatusMethods; }, { state: EntitiesPaginationRemoteState; @@ -271,7 +192,7 @@ export function withEntitiesRemotePagination< * // withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // take: productsPagedRequest().size, @@ -318,8 +239,8 @@ export function withEntitiesRemotePagination< { state: NamedEntityState; // if put Collection the some props get lost and can only be access ['prop'] weird bug signals: NamedEntitySignals & - NamedCallStateComputed; - methods: NamedCallStateMethods; + NamedCallStatusComputed; + methods: NamedCallStatusMethods; }, { state: NamedEntitiesPaginationRemoteState; @@ -379,7 +300,7 @@ export function withEntitiesRemotePagination< * // withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // take: productsPagedRequest().size, @@ -430,9 +351,7 @@ export function withEntitiesRemotePagination< const { loadingKey, setLoadingKey } = getWithCallStatusKeys({ prop: config.collection, }); - const { entitiesKey } = getWithEntitiesKeys(config); - const { filterKey } = getWithEntitiesFilterKeys(config); - const { sortKey } = getWithEntitiesSortKeys(config); + const { entitiesKey, clearEntitiesCacheKey } = getWithEntitiesKeys(config); const { loadEntitiesPageKey, @@ -502,10 +421,38 @@ export function withEntitiesRemotePagination< }; }), withMethods((state: Record>) => { + const entities = state[entitiesKey] as Signal; const pagination = state[paginationKey] as Signal; - const setLoading = state[setLoadingKey] as () => void; const entitiesList = state[entitiesKey] as Signal; + const { loadEntitiesPage } = loadEntitiesPageFactory( + state, + loadingKey, + paginationKey, + setLoadingKey, + ); return { + [clearEntitiesCacheKey]: combineFunctions( + state[clearEntitiesCacheKey], + () => { + patchState( + state as StateSignal, + config.collection + ? setAllEntities([], { + collection: config.collection, + }) + : setAllEntities([]), + { + [paginationKey]: { + ...pagination(), + total: entities.length, + cache: { start: 0, end: entities.length }, + currentPage: 0, + requestPage: 0, + }, + }, + ); + }, + ), [setEntitiesLoadResultKey]: (entities: Entity[], total: number) => { // TODO extract this function and test all egg cases, like preloading next pages and jumping page const isPreloadNextPagesReady = @@ -539,67 +486,8 @@ export function withEntitiesRemotePagination< }, ); }, - [loadEntitiesPageKey]: rxMethod<{ - pageIndex: number; - forceLoad?: boolean; - }>( - pipe( - tap(({ pageIndex, forceLoad }) => { - patchState(state as StateSignal, { - [paginationKey]: { - ...pagination(), - currentPage: pageIndex, - requestPage: pageIndex, - }, - }); - if ( - isEntitiesPageInCache(pageIndex, pagination()) && - !forceLoad - ) { - if (!isEntitiesPageInCache(pageIndex + 1, pagination())) { - // preload next page - patchState(state as StateSignal, { - [paginationKey]: { - ...pagination(), - currentPage: pageIndex, - requestPage: pageIndex + 1, - }, - }); - setLoading(); - } - return; - } - setLoading(); - }), - ), - ), + [loadEntitiesPageKey]: loadEntitiesPage, }; }), - withHooks({ - onInit: (input) => { - gotoFirstPageIfFilterOrSortChanges( - input, - filterKey, - sortKey, - entitiesCurrentPageKey, - loadEntitiesPageKey, - ); - }, - }), - ); -} - -function isEntitiesPageInCache( - page: number, - pagination: EntitiesPaginationRemoteState['pagination'], -) { - const startIndex = page * pagination.pageSize; - let endIndex = startIndex + pagination.pageSize - 1; - endIndex = - pagination.total && endIndex > pagination.total - ? pagination.total - 1 - : endIndex; - return ( - startIndex >= pagination.cache.start && endIndex <= pagination.cache.end ); } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.util.ts index cb66af66..fae505e3 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.util.ts @@ -1,4 +1,15 @@ +import { Signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { patchState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import type { StateSignal } from '@ngrx/signals/src/state-signal'; +import { distinctUntilChanged, exhaustMap, first, pipe, tap } from 'rxjs'; + import { capitalize } from '../util'; +import { + EntitiesPaginationRemoteState, + PaginationState, +} from './with-entities-remote-pagination.model'; export function getWithEntitiesRemotePaginationKeys(config?: { collection?: string; @@ -6,7 +17,9 @@ export function getWithEntitiesRemotePaginationKeys(config?: { const collection = config?.collection; const capitalizedProp = collection && capitalize(collection); return { - paginationKey: collection ? `${config.collection}Pagination` : 'pagination', + paginationKey: collection + ? `${config.collection}Pagination` + : 'entitiesPagination', entitiesCurrentPageKey: collection ? `${config.collection}CurrentPage` : 'entitiesCurrentPage', @@ -21,3 +34,96 @@ export function getWithEntitiesRemotePaginationKeys(config?: { : 'setEntitiesLoadedResult', }; } +export function isEntitiesInCache( + options: + | { + page: number; + pagination: EntitiesPaginationRemoteState['entitiesPagination']; + } + | { + start: number; + end: number; + pagination: EntitiesPaginationRemoteState['entitiesPagination']; + }, +) { + const pagination = options.pagination; + const startIndex = + 'start' in options ? options.start : options.page * pagination.pageSize; + let endIndex = + 'end' in options ? options.end : startIndex + pagination.pageSize - 1; + endIndex = + pagination.total && endIndex > pagination.total + ? pagination.total - 1 + : endIndex; + return ( + startIndex >= pagination.cache.start && endIndex <= pagination.cache.end + ); +} + +export function loadEntitiesPageFactory( + state: Record>, + loadingKey: string, + paginationKey: string, + setLoadingKey: string, +) { + const isLoading = state[loadingKey] as Signal; + const $loading = toObservable(isLoading); + const pagination = state[paginationKey] as Signal; + const setLoading = state[setLoadingKey] as () => void; + + const loadEntitiesPage = rxMethod<{ + pageIndex: number; + forceLoad?: boolean; + }>( + pipe( + distinctUntilChanged( + (previous, current) => + !current.forceLoad && previous.pageIndex === current.pageIndex, + ), + exhaustMap(({ pageIndex, forceLoad }) => + $loading.pipe( + first((loading) => !loading), + // the previous exhaustMap to not loading ensures the function + // can not be called multiple time before results are loaded, which could corrupt the cache + tap(() => { + patchState(state as StateSignal, { + [paginationKey]: { + ...pagination(), + currentPage: pageIndex, + requestPage: pageIndex, + }, + }); + if ( + isEntitiesInCache({ + page: pageIndex, + pagination: pagination(), + }) && + !forceLoad + ) { + if ( + !isEntitiesInCache({ + page: pageIndex + 1, + pagination: pagination(), + }) + ) { + // preload next page + patchState(state as StateSignal, { + [paginationKey]: { + ...pagination(), + currentPage: pageIndex, + requestPage: pageIndex + 1, + }, + }); + setLoading(); + } + return; + } + setLoading(); + }), + ), + ), + ), + ); + + return { loadEntitiesPage }; +} diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.model.ts new file mode 100644 index 00000000..8f39b66b --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.model.ts @@ -0,0 +1,80 @@ +import { Signal } from '@angular/core'; + +import { EntitiesPaginationLocalMethods } from './with-entities-local-pagination.model'; + +export type InfinitePaginationState = { + currentPage: number; + requestPage: number; + pageSize: number; + total: number | undefined; + pagesToCache: number; + cache: { + start: number; + end: number; + }; +}; +export type EntitiesPaginationInfiniteState = { + entitiesPagination: InfinitePaginationState; +}; +export type NamedEntitiesPaginationInfiniteState = { + [K in Collection as `${K}Pagination`]: InfinitePaginationState; +}; +export type EntitiesPaginationInfiniteComputed = { + entitiesPageInfo: Signal<{ + pageIndex: number; + total: number | undefined; + pageSize: number; + pagesCount: number | undefined; + hasPrevious: boolean; + hasNext: boolean; + loading: boolean; + }>; + entitiesPagedRequest: Signal<{ + startIndex: number; + size: number; + page: number; + }>; +}; +export type NamedEntitiesPaginationInfiniteComputed< + Entity, + Collection extends string, +> = { + [K in Collection as `${K}PagedRequest`]: Signal<{ + startIndex: number; + size: number; + page: number; + }>; +} & { + [K in Collection as `${K}PageInfo`]: Signal<{ + entities: Entity[]; + pageIndex: number; + total: number | undefined; + pageSize: number; + pagesCount: number | undefined; + hasPrevious: boolean; + hasNext: boolean; + loading: boolean; + }>; +}; +export type EntitiesPaginationInfiniteMethods = + EntitiesPaginationLocalMethods & { + setEntitiesLoadedResult: (entities: Entity[], total: number) => void; + loadEntitiesNextPage: () => void; + loadEntitiesPreviousPage: () => void; + loadEntitiesFirstPage: () => void; + }; +export type NamedEntitiesPaginationInfiniteMethods< + Entity, + Collection extends string, +> = { + [K in Collection as `set${Capitalize}LoadedResult`]: ( + entities: Entity[], + total: number, + ) => void; +} & { + [K in Collection as `load${Capitalize}NextPage`]: () => void; +} & { + [K in Collection as `load${Capitalize}PreviousPage`]: () => void; +} & { + [K in Collection as `load${Capitalize}FirstPage`]: () => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.ts new file mode 100644 index 00000000..0fec3652 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.ts @@ -0,0 +1,415 @@ +import { computed, Signal } from '@angular/core'; +import { + patchState, + signalStoreFeature, + SignalStoreFeature, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { + addEntities, + EntityState, + NamedEntityState, + setAllEntities, +} from '@ngrx/signals/entities'; +import type { + EntitySignals, + NamedEntitySignals, +} from '@ngrx/signals/entities/src/models'; +import type { StateSignal } from '@ngrx/signals/src/state-signal'; + +import { combineFunctions, getWithEntitiesKeys } from '../util'; +import { + CallStatusComputed, + CallStatusMethods, + NamedCallStatusComputed, + NamedCallStatusMethods, +} from '../with-call-status/with-call-status.model'; +import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; +import { getWithEntitiesInfinitePaginationKeys } from './with-entities-infinite-pagination.util'; +import { loadEntitiesPageFactory } from './with-entities-remote-pagination.util'; +import { + EntitiesPaginationInfiniteComputed, + EntitiesPaginationInfiniteMethods, + EntitiesPaginationInfiniteState, + InfinitePaginationState, + NamedEntitiesPaginationInfiniteComputed, + NamedEntitiesPaginationInfiniteMethods, + NamedEntitiesPaginationInfiniteState, +} from './with-entities-remote-scroll-pagination.model'; + +/** + * Generates necessary state, computed and methods for remote infinite scroll pagination of entities in the store. The + * different between this and withEntitiesRemotePagination this will can only got to next and previous page, and the cache + * of entities keeps growing, ideally for implementing infinite scroll style ui. + * When the page changes, it will try to load the current page from cache if it's not present, + * it will call set[collection]Loading(), and you should either create an effect that listens to is[Collection]Loading + * and call the api with the [collection]PagedRequest params and use set[Collection]LoadResult to set the result + * and changing the status errors manually + * or use withEntitiesLoadingCall to call the api with the [collection]PagedRequest params which handles setting + * the result and errors automatically. Requires withEntities and withCallStatus to be used. + * Requires withEntities and withCallStatus to be present in the store. + * @param config + * @param config.pageSize - The number of entities to show per page + * @param config.pagesToCache - The number of pages to cache + * @param config.entity - The entity type + * @param config.collection - The name of the collection + * + * @example + * const entity = type(); + * const collection = 'products'; + * export const store = signalStore( + * { providedIn: 'root' }, + * // required withEntities and withCallStatus + * withEntities({ entity, collection }), + * withCallStatus({ prop: collection, initialValue: 'loading' }), + * + * withEntitiesRemoteScrollPagination({ + * entity, + * collection, + * pageSize: 5, + * pagesToCache: 2, + * }) + * // after you can use withEntitiesLoadingCall to connect the filter to + * // the api call, or do it manually as shown after + * withEntitiesLoadingCall({ + * collection, + * fetchEntities: ({ productsPagedRequest }) => { + * return inject(ProductService) + * .getProducts({ + * take: productsPagedRequest().size, + * skip: productsPagedRequest().startIndex, + * }).pipe( + * map((d) => ({ + * entities: d.resultList, + * total: d.total, + * })), + * ) + * }, + * }), + * // withEntitiesLoadingCall is the same as doing the following: + * // withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ + * // onInit: async () => { + * // effect(() => { + * // if (isProductsLoading()) { + * // inject(ProductService) + * // .getProducts({ + * // take: productsPagedRequest().size, + * // skip: productsPagedRequest().startIndex, + * // }) + * // .pipe( + * // takeUntilDestroyed(), + * // tap((res) => + * // patchState( + * // state, + * // setProductsLoadResult(res.resultList, res.total), + * // ), + * // ), + * // catchError((error) => { + * // setProductsError(error); + * // return EMPTY; + * // }), + * // ) + * // .subscribe(); + * // } + * // }); + * // }, + * })), + * + * // in your component add + * store = inject(ProductsRemoteStore); + * dataSource = getInfiniteScrollDataSource(store, { collecrion: 'products' }) // pass this to your cdkVirtualFor see examples section + * // generates the following signals + * store.productsPagination // { currentPage: number, requestPage: number, pageSize: 5, total: number, pagesToCache: number, cache: { start: number, end: number } } used internally + * // generates the following computed signals + * store.productsPageInfo // { pageIndex: number, total: number, pageSize: 5, pagesCount: number, hasPrevious: boolean, hasNext: boolean, loading: boolean } + * store.productsPagedRequest // { startIndex: number, size: number, page: number } + * // generates the following methods + * store.loadProductsNextPage() // loads next page + * store.loadProductsPreviousPage() // loads previous page + * store.loadProductsFirstPage() // loads first page + * store.setProductsLoadResult(entities: Product[], total: number) // appends the entities to the cache of entities and total + */ +export function withEntitiesRemoteScrollPagination< + Entity extends { id: string | number }, +>(config: { + pageSize?: number; + currentPage?: number; + pagesToCache?: number; + entity?: Entity; +}): SignalStoreFeature< + { + state: EntityState; + signals: EntitySignals & CallStatusComputed; + methods: CallStatusMethods; + }, + { + state: EntitiesPaginationInfiniteState; + signals: EntitiesPaginationInfiniteComputed; + methods: EntitiesPaginationInfiniteMethods; + } +>; + +/** + * Generates necessary state, computed and methods for remote infinite scroll pagination of entities in the store. The + * different between this and withEntitiesRemotePagination this will can only got to next and previous page, and the cache + * of entities keeps growing, ideally for implementing infinite scroll style ui. + * When the page changes, it will try to load the current page from cache if it's not present, + * it will call set[collection]Loading(), and you should either create an effect that listens to is[Collection]Loading + * and call the api with the [collection]PagedRequest params and use set[Collection]LoadResult to set the result + * and changing the status errors manually + * or use withEntitiesLoadingCall to call the api with the [collection]PagedRequest params which handles setting + * the result and errors automatically. Requires withEntities and withCallStatus to be used. + * Requires withEntities and withCallStatus to be present in the store. + * @param config + * @param config.pageSize - The number of entities to show per page + * @param config.pagesToCache - The number of pages to cache + * @param config.entity - The entity type + * @param config.collection - The name of the collection + * + * @example + * const entity = type(); + * const collection = 'products'; + * export const store = signalStore( + * { providedIn: 'root' }, + * // required withEntities and withCallStatus + * withEntities({ entity, collection }), + * withCallStatus({ prop: collection, initialValue: 'loading' }), + * + * withEntitiesRemoteScrollPagination({ + * entity, + * collection, + * pageSize: 5, + * pagesToCache: 2, + * }) + * // after you can use withEntitiesLoadingCall to connect the filter to + * // the api call, or do it manually as shown after + * withEntitiesLoadingCall({ + * collection, + * fetchEntities: ({ productsPagedRequest }) => { + * return inject(ProductService) + * .getProducts({ + * take: productsPagedRequest().size, + * skip: productsPagedRequest().startIndex, + * }).pipe( + * map((d) => ({ + * entities: d.resultList, + * total: d.total, + * })), + * ) + * }, + * }), + * // withEntitiesLoadingCall is the same as doing the following: + * // withHooks(({ productsLoading, setProductsError, setProductsLoadResult, ...state }) => ({ + * // onInit: async () => { + * // effect(() => { + * // if (isProductsLoading()) { + * // inject(ProductService) + * // .getProducts({ + * // take: productsPagedRequest().size, + * // skip: productsPagedRequest().startIndex, + * // }) + * // .pipe( + * // takeUntilDestroyed(), + * // tap((res) => + * // patchState( + * // state, + * // setProductsLoadResult(res.resultList, res.total), + * // ), + * // ), + * // catchError((error) => { + * // setProductsError(error); + * // return EMPTY; + * // }), + * // ) + * // .subscribe(); + * // } + * // }); + * // }, + * })), + * + * // in your component add + * store = inject(ProductsRemoteStore); + * dataSource = getInfiniteScrollDataSource(store, { collecrion: 'products' }) // pass this to your cdkVirtualFor see examples section + * // generates the following signals + * store.productsPagination // { currentPage: number, requestPage: number, pageSize: 5, total: number, pagesToCache: number, cache: { start: number, end: number } } used internally + * // generates the following computed signals + * store.productsPageInfo // { pageIndex: number, total: number, pageSize: 5, pagesCount: number, hasPrevious: boolean, hasNext: boolean, loading: boolean } + * store.productsPagedRequest // { startIndex: number, size: number, page: number } + * // generates the following methods + * store.loadProductsNextPage() // loads next page + * store.loadProductsPreviousPage() // loads previous page + * store.loadProductsFirstPage() // loads first page + * store.setProductsLoadResult(entities: Product[], total: number) // appends the entities to the cache of entities and total + */ +export function withEntitiesRemoteScrollPagination< + Entity extends { id: string | number }, + Collection extends string, +>(config: { + pageSize?: number; + currentPage?: number; + pagesToCache?: number; + entity?: Entity; + collection?: Collection; +}): SignalStoreFeature< + { + state: NamedEntityState; // if put Collection the some props get lost and can only be access ['prop'] weird bug + signals: NamedEntitySignals & + NamedCallStatusComputed; + methods: NamedCallStatusMethods; + }, + { + state: NamedEntitiesPaginationInfiniteState; + signals: NamedEntitiesPaginationInfiniteComputed; + methods: NamedEntitiesPaginationInfiniteMethods; + } +>; + +export function withEntitiesRemoteScrollPagination< + Entity extends { id: string | number }, + Collection extends string, +>({ + pageSize = 10, + pagesToCache = 3, + ...config +}: { + pageSize?: number; + pagesToCache?: number; + entity?: Entity; + collection?: Collection; +} = {}): SignalStoreFeature { + const { loadingKey, setLoadingKey } = getWithCallStatusKeys({ + prop: config.collection, + }); + const { clearEntitiesCacheKey } = getWithEntitiesKeys(config); + + const { + loadEntitiesNextPageKey, + loadEntitiesFirstPageKey, + loadEntitiesPreviousPageKey, + entitiesPageInfoKey, + setEntitiesLoadResultKey, + entitiesPagedRequestKey, + paginationKey, + } = getWithEntitiesInfinitePaginationKeys(config); + + return signalStoreFeature( + withState({ + [paginationKey]: { + pageSize, + currentPage: 0, + requestPage: 0, + pagesToCache, + total: undefined, + cache: { + start: 0, + end: 0, + }, + }, + }), + withComputed((state: Record>) => { + const loading = state[loadingKey] as Signal; + const pagination = state[ + paginationKey + ] as Signal; + + const entitiesPageInfo = computed(() => { + const pagesCount = + pagination().total && pagination().total! > 0 + ? Math.ceil(pagination().total! / pagination().pageSize) + : undefined; + return { + pageIndex: pagination().currentPage, + total: pagination().total, + pageSize: pagination().pageSize, + pagesCount, + hasPrevious: pagination().currentPage - 1 >= 0, + hasNext: + pagesCount && pagination().total && pagination().total! > 0 + ? pagination().currentPage + 1 < pagesCount + : true, + loading: + loading() && pagination().requestPage === pagination().currentPage, + }; + }); + const entitiesPagedRequest = computed(() => ({ + startIndex: pagination().pageSize * pagination().requestPage, + size: pagination().pageSize * pagination().pagesToCache, + page: pagination().requestPage, + })); + return { + [entitiesPageInfoKey]: entitiesPageInfo, + [entitiesPagedRequestKey]: entitiesPagedRequest, + }; + }), + withMethods((state: Record>) => { + const pagination = state[ + paginationKey + ] as Signal; + const { loadEntitiesPage } = loadEntitiesPageFactory( + state, + loadingKey, + paginationKey, + setLoadingKey, + ); + // TODO refactor some of this code is repeated in remote pagination + return { + [clearEntitiesCacheKey]: combineFunctions( + state[clearEntitiesCacheKey], + () => { + patchState( + state as StateSignal, + config.collection + ? setAllEntities([], { + collection: config.collection, + }) + : setAllEntities([]), + { + [paginationKey]: { + ...pagination(), + total: 0, + cache: { start: 0, end: 0 }, + currentPage: 0, + requestPage: 0, + }, + }, + ); + }, + ), + [setEntitiesLoadResultKey]: (entities: Entity[], total: number) => { + patchState( + state as StateSignal, + config.collection + ? addEntities(entities, { + collection: config.collection, + }) + : addEntities(entities), + { + [paginationKey]: { + ...pagination(), + total, + cache: { + ...pagination().cache, + end: pagination().cache.end + entities.length, + }, + }, + }, + ); + }, + [loadEntitiesNextPageKey]: () => { + loadEntitiesPage({ pageIndex: pagination().currentPage + 1 }); + }, + [loadEntitiesPreviousPageKey]: () => { + loadEntitiesPage({ + pageIndex: + pagination().currentPage > 0 ? pagination().currentPage - 1 : 0, + }); + }, + [loadEntitiesFirstPageKey]: () => { + loadEntitiesPage({ pageIndex: 0 }); + }, + }; + }), + ); +} diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.model.ts new file mode 100644 index 00000000..34838dc4 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.model.ts @@ -0,0 +1,52 @@ +import { Signal } from '@angular/core'; + +export type EntitiesMultiSelectionState = { + entitiesSelectedIdsMap: Record; +}; +export type NamedEntitiesMultiSelectionState = { + [K in Collection as `${K}SelectedIdsMap`]: Record; +}; +export type EntitiesMultiSelectionComputed = { + entitiesSelected: Signal; + isAllEntitiesSelected: Signal<'all' | 'none' | 'some'>; +}; +export type NamedEntitiesMultiSelectionComputed< + Entity, + Collection extends string, +> = { + [K in Collection as `${K}SelectedEntities`]: Signal; +} & { + [K in Collection as `isAll${Capitalize}Selected`]: Signal< + 'all' | 'none' | 'some' + >; +}; +export type EntitiesMultiSelectionMethods = { + selectEntities: ( + options: { id: string | number } | { ids: (string | number)[] }, + ) => void; + deselectEntities: ( + options: { id: string | number } | { ids: (string | number)[] }, + ) => void; + toggleSelectEntities: ( + options: { id: string | number } | { ids: (string | number)[] }, + ) => void; + toggleSelectAllEntities: () => void; + clearEntitiesSelection: () => void; +}; +export type NamedEntitiesMultiSelectionMethods = { + [K in Collection as `select${Capitalize}Entities`]: ( + options: { id: string | number } | { ids: (string | number)[] }, + ) => void; +} & { + [K in Collection as `deselect${Capitalize}Entities`]: ( + options: { id: string | number } | { ids: (string | number)[] }, + ) => void; +} & { + [K in Collection as `toggleSelect${Capitalize}Entities`]: ( + options: { id: string | number } | { ids: (string | number)[] }, + ) => void; +} & { + [K in Collection as `toggleSelectAll${Capitalize< + string & K + >}Entities`]: () => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-multi-selection/with-entities-multi-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts similarity index 69% rename from libs/ngrx-traits/signals/src/lib/with-entities-multi-selection/with-entities-multi-selection.ts rename to libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts index 818ff609..824385fd 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-multi-selection/with-entities-multi-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts @@ -1,10 +1,9 @@ -import { computed, effect, Signal } from '@angular/core'; +import { computed, Signal } from '@angular/core'; import { patchState, signalStoreFeature, SignalStoreFeature, withComputed, - withHooks, withMethods, withState, } from '@ngrx/signals'; @@ -17,62 +16,18 @@ import { } from '@ngrx/signals/entities/src/models'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import { capitalize, getWithEntitiesKeys } from '../util'; +import { capitalize, combineFunctions, getWithEntitiesKeys } from '../util'; import { getWithEntitiesFilterKeys } from '../with-entities-filter/with-entities-filter.util'; import { getWithEntitiesLocalPaginationKeys } from '../with-entities-pagination/with-entities-local-pagination.util'; import { getWithEntitiesSortKeys } from '../with-entities-sort/with-entities-sort.util'; - -export type EntitiesMultiSelectionState = { - selectedIdsMap: Record; -}; -export type NamedEntitiesMultiSelectionState = { - [K in Collection as `${K}SelectedIdsMap`]: Record; -}; - -export type EntitiesMultiSelectionComputed = { - selectedEntities: Signal; - isAllEntitiesSelected: Signal<'all' | 'none' | 'some'>; -}; -export type NamedEntitiesMultiSelectionComputed< - Entity, - Collection extends string, -> = { - [K in Collection as `${K}SelectedEntities`]: Signal; -} & { - [K in Collection as `isAll${Capitalize}Selected`]: Signal< - 'all' | 'none' | 'some' - >; -}; -export type EntitiesMultiSelectionMethods = { - selectEntities: ( - options: { id: string | number } | { ids: (string | number)[] }, - ) => void; - deselectEntities: ( - options: { id: string | number } | { ids: (string | number)[] }, - ) => void; - toggleSelectEntities: ( - options: { id: string | number } | { ids: (string | number)[] }, - ) => void; - toggleSelectAllEntities: () => void; - clearEntitiesSelection: () => void; -}; -export type NamedEntitiesMultiSelectionMethods = { - [K in Collection as `select${Capitalize}Entities`]: ( - options: { id: string | number } | { ids: (string | number)[] }, - ) => void; -} & { - [K in Collection as `deselect${Capitalize}Entities`]: ( - options: { id: string | number } | { ids: (string | number)[] }, - ) => void; -} & { - [K in Collection as `toggleSelect${Capitalize}Entities`]: ( - options: { id: string | number } | { ids: (string | number)[] }, - ) => void; -} & { - [K in Collection as `toggleSelectAll${Capitalize< - string & K - >}Entities`]: () => void; -}; +import { + EntitiesMultiSelectionComputed, + EntitiesMultiSelectionMethods, + EntitiesMultiSelectionState, + NamedEntitiesMultiSelectionComputed, + NamedEntitiesMultiSelectionMethods, + NamedEntitiesMultiSelectionState, +} from './with-entities-multi-selection.model'; function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { const collection = config?.collection; @@ -80,10 +35,10 @@ function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { return { selectedIdsMapKey: collection ? `${config.collection}SelectedIdsMap` - : 'selectedIdsMap', + : 'entitiesSelectedIdsMap', selectedEntitiesKey: collection ? `${config.collection}SelectedEntities` - : 'selectedEntities', + : 'entitiesSelected', selectEntitiesKey: collection ? `select${capitalizedProp}Entities` : 'selectEntities', @@ -126,15 +81,15 @@ function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { * store.productsSelectedEntities // Entity[]; * store.isAllProductsSelected // 'all' | 'none' | 'some'; * // generates the following methods - * store.selectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; - * store.deselectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; - * store.toggleSelectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; + * store.selectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; + * store.deselectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; + * store.toggleSelectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; * store.toggleSelectAllProducts // () => void; */ export function withEntitiesMultiSelection< Entity extends { id: string | number }, Collection extends string, ->(options: { +>(config: { entity?: Entity; collection?: Collection; }): SignalStoreFeature< @@ -188,15 +143,15 @@ export function withEntitiesMultiSelection< * store.productsSelectedEntities // Entity[]; * store.isAllProductsSelected // 'all' | 'none' | 'some'; * // generates the following methods - * store.selectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; - * store.deselectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; - * store.toggleSelectProducts // (options: { id: string | number } | { ids: (string | number)[] }) => void; + * store.selectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; + * store.deselectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; + * store.toggleSelectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; * store.toggleSelectAllProducts // () => void; */ export function withEntitiesMultiSelection< Entity extends { id: string | number }, Collection extends string, ->(options: { +>(config: { entity?: Entity; collection?: Collection; }): SignalStoreFeature< @@ -220,7 +175,8 @@ export function withEntitiesMultiSelection< entity?: Entity; collection?: Collection; }): SignalStoreFeature { - const { entityMapKey, idsKey } = getWithEntitiesKeys(config); + const { entityMapKey, idsKey, clearEntitiesCacheKey } = + getWithEntitiesKeys(config); const { selectedIdsMapKey, selectedEntitiesKey, @@ -270,7 +226,18 @@ export function withEntitiesMultiSelection< const idsArray = state[idsKey] as Signal; + const clearEntitiesSelection = () => { + patchState(state as StateSignal, { + [selectedIdsMapKey]: {}, + }); + }; return { + [clearEntitiesCacheKey]: combineFunctions( + state[clearEntitiesCacheKey], + () => { + clearEntitiesSelection(); + }, + ), [selectEntitiesKey]: ( options: { id: string | number } | { ids: (string | number)[] }, ) => { @@ -318,11 +285,7 @@ export function withEntitiesMultiSelection< [selectedIdsMapKey]: { ...oldIdsMap, ...idsMap }, }); }, - [clearEntitiesSelectionKey]: () => { - patchState(state as StateSignal, { - [selectedIdsMapKey]: {}, - }); - }, + [clearEntitiesSelectionKey]: clearEntitiesSelection, [toggleSelectAllEntitiesKey]: () => { const allSelected = isAllEntitiesSelected(); if (allSelected === 'all') { @@ -344,35 +307,5 @@ export function withEntitiesMultiSelection< }, }; }), - withHooks({ - onInit: (store) => { - // we need reset the selections if to 0 when the filter changes or sorting changes and is paginated - if (filterKey in store || sortKey in store) { - const filter = store[filterKey] as Signal; - const sort = store[sortKey] as Signal; - const clearEntitiesSelection = store[ - clearEntitiesSelectionKey - ] as EntitiesMultiSelectionMethods['clearEntitiesSelection']; - let lastFilter = filter?.(); - let lastSort = sort?.(); - /** TODO: there is a small problem here when used together with withSyncToWebStorage an filter or sorting - the stored selection will get cleared because this logic detects the sorting and the filtering changing - from the default value to the stored value and resets the selection */ - effect( - () => { - if ( - (store[paginationKey] && lastSort !== sort?.()) || - filter?.() !== lastFilter - ) { - lastFilter = filter?.(); - lastSort = sort?.(); - clearEntitiesSelection(); - } - }, - { allowSignalWrites: true }, - ); - } - }, - }), ); } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.model.ts new file mode 100644 index 00000000..49ca3bc0 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.model.ts @@ -0,0 +1,35 @@ +import { Signal } from '@angular/core'; + +export type EntitiesSingleSelectionState = { + entitiesSelectedId?: string | number; +}; +export type NamedEntitiesSingleSelectionState = { + [K in Collection as `${K}SelectedId`]?: string | number; +}; +export type EntitiesSingleSelectionComputed = { + entitiesSelectedEntity: Signal; +}; +export type NamedEntitiesSingleSelectionComputed< + Entity, + Collection extends string, +> = { + [K in Collection as `${K}SelectedEntity`]: Signal; +}; +export type EntitiesSingleSelectionMethods = { + selectEntity: (options: { id: string | number }) => void; + deselectEntity: (options: { id: string | number }) => void; + toggleEntity: (options: { id: string | number }) => void; +}; +export type NamedEntitiesSingleSelectionMethods = { + [K in Collection as `select${Capitalize}Entity`]: (options: { + id: string | number; + }) => void; +} & { + [K in Collection as `deselect${Capitalize}Entity`]: (options: { + id: string | number; + }) => void; +} & { + [K in Collection as `toggle${Capitalize}Entity`]: (options: { + id: string | number; + }) => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-single-selection/with-entities-single-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts similarity index 56% rename from libs/ngrx-traits/signals/src/lib/with-entities-single-selection/with-entities-single-selection.ts rename to libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts index 3e3dda91..4f53a8c8 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-single-selection/with-entities-single-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts @@ -1,10 +1,9 @@ -import { computed, effect, Signal } from '@angular/core'; +import { computed, Signal } from '@angular/core'; import { patchState, signalStoreFeature, SignalStoreFeature, withComputed, - withHooks, withMethods, withState, } from '@ngrx/signals'; @@ -16,54 +15,26 @@ import { } from '@ngrx/signals/entities/src/models'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import { capitalize, getWithEntitiesKeys } from '../util'; -import { getWithEntitiesFilterKeys } from '../with-entities-filter/with-entities-filter.util'; -import { getWithEntitiesLocalPaginationKeys } from '../with-entities-pagination/with-entities-local-pagination.util'; -import { getWithEntitiesSortKeys } from '../with-entities-sort/with-entities-sort.util'; - -export type EntitiesSingleSelectionState = { - selectedId?: string | number; -}; -export type NamedEntitiesSingleSelectionState = { - [K in Collection as `${K}SelectedId`]?: string | number; -}; - -export type EntitiesSingleSelectionComputed = { - selectedEntity: Signal; -}; -export type NamedEntitiesSingleSelectionComputed< - Entity, - Collection extends string, -> = { - [K in Collection as `${K}SelectedEntity`]: Signal; -}; -export type EntitiesSingleSelectionMethods = { - selectEntity: (options: { id: string | number }) => void; - deselectEntity: (options: { id: string | number }) => void; - toggleEntity: (options: { id: string | number }) => void; -}; -export type NamedEntitiesSingleSelectionMethods = { - [K in Collection as `select${Capitalize}Entity`]: (options: { - id: string | number; - }) => void; -} & { - [K in Collection as `deselect${Capitalize}Entity`]: (options: { - id: string | number; - }) => void; -} & { - [K in Collection as `toggle${Capitalize}Entity`]: (options: { - id: string | number; - }) => void; -}; +import { capitalize, combineFunctions, getWithEntitiesKeys } from '../util'; +import { + EntitiesSingleSelectionComputed, + EntitiesSingleSelectionMethods, + EntitiesSingleSelectionState, + NamedEntitiesSingleSelectionComputed, + NamedEntitiesSingleSelectionMethods, + NamedEntitiesSingleSelectionState, +} from './with-entities-single-selection.model'; function getEntitiesSingleSelectionKeys(config?: { collection?: string }) { const collection = config?.collection; const capitalizedProp = collection && capitalize(collection); return { - selectedIdKey: collection ? `${config.collection}SelectedId` : 'selectedId', + selectedIdKey: collection + ? `${config.collection}SelectedId` + : 'entitiesSelectedId', selectedEntityKey: collection ? `${config.collection}SelectedEntity` - : 'selectedEntity', + : 'entitiesSelectedEntity', selectEntityKey: collection ? `select${capitalizedProp}Entity` : 'selectEntity', @@ -102,14 +73,14 @@ function getEntitiesSingleSelectionKeys(config?: { collection?: string }) { * // generates the following computed signals * store.productsSelectedEntity // Entity | undefined * // generates the following methods - * store.selectProductEntity // (options: { id: string | number }) => void - * store.deselectProductEntity // (options: { id: string | number }) => void - * store.toggleProductEntity // (options: { id: string | number }) => void + * store.selectProductEntity // (config: { id: string | number }) => void + * store.deselectProductEntity // (config: { id: string | number }) => void + * store.toggleProductEntity // (config: { id: string | number }) => void */ export function withEntitiesSingleSelection< Entity extends { id: string | number }, ->(options: { +>(config: { entity?: Entity; }): SignalStoreFeature< { @@ -149,14 +120,14 @@ export function withEntitiesSingleSelection< * // generates the following computed signals * store.productsSelectedEntity // Entity | undefined * // generates the following methods - * store.selectProductEntity // (options: { id: string | number }) => void - * store.deselectProductEntity // (options: { id: string | number }) => void - * store.toggleProductEntity // (options: { id: string | number }) => void + * store.selectProductEntity // (config: { id: string | number }) => void + * store.deselectProductEntity // (config: { id: string | number }) => void + * store.toggleProductEntity // (config: { id: string | number }) => void */ export function withEntitiesSingleSelection< Entity extends { id: string | number }, Collection extends string, ->(options: { +>(config: { entity?: Entity; collection?: Collection; }): SignalStoreFeature< @@ -180,10 +151,7 @@ export function withEntitiesSingleSelection< entity?: Entity; collection?: Collection; }): SignalStoreFeature { - const { entityMapKey } = getWithEntitiesKeys(config); - const { filterKey } = getWithEntitiesFilterKeys(config); - const { sortKey } = getWithEntitiesSortKeys(config); - const { paginationKey } = getWithEntitiesLocalPaginationKeys(config); + const { entityMapKey, clearEntitiesCacheKey } = getWithEntitiesKeys(config); const { selectedEntityKey, selectEntityKey, @@ -209,57 +177,30 @@ export function withEntitiesSingleSelection< const selectedId = state[selectedIdKey] as Signal< string | number | undefined >; + const deselectEntity = () => { + patchState(state as StateSignal, { + [selectedIdKey]: undefined, + }); + }; return { [selectEntityKey]: ({ id }: { id: string | number }) => { patchState(state as StateSignal, { [selectedIdKey]: id, }); }, - [deselectEntityKey]: () => { - patchState(state as StateSignal, { - [selectedIdKey]: undefined, - }); - }, + [deselectEntityKey]: deselectEntity, [toggleEntityKey]: ({ id }: { id: string | number }) => { patchState(state as StateSignal, { [selectedIdKey]: selectedId() === id ? undefined : id, }); }, + [clearEntitiesCacheKey]: combineFunctions( + state[clearEntitiesCacheKey], + () => { + deselectEntity(); + }, + ), }; }), - withHooks((store) => ({ - onInit: () => { - // we need reset the selections if to 0 when the filter changes or sorting changes and is paginated - if (filterKey in store || sortKey in store) { - const filter = store[filterKey] as Signal; - const sort = store[sortKey] as Signal; - const deselectEntity = store[deselectEntityKey] as () => void; - let lastFilter = filter?.(); - let lastSort = sort?.(); - /** TODO: there is a small problem here when used together with withSyncToWebStorage an filter or sorting - the stored selection will get cleared because this logic detects the sorting and the filtering changing - from the default value to the stored value and resets the selection */ - effect( - () => { - if ( - (store[paginationKey] && lastSort !== sort?.()) || - lastFilter !== filter?.() - ) { - console.log( - 'resetting selection', - store[paginationKey] && lastSort !== sort?.(), - lastFilter !== filter?.(), - { filter: filter?.(), sort: sort?.(), lastFilter, lastSort }, - ); - lastFilter = filter?.(); - lastSort = sort?.(); - deselectEntity(); - } - }, - { allowSignalWrites: true }, - ); - } - }, - })), ); } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.model.ts b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.model.ts new file mode 100644 index 00000000..470cf12e --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.model.ts @@ -0,0 +1,16 @@ +import { Sort } from './with-entities-sort.utils'; + +export type EntitiesSortState = { + entitiesSort: Sort; +}; +export type NamedEntitiesSortState = { + [K in Collection as `${K}Sort`]: Sort; +}; +export type EntitiesSortMethods = { + sortEntities: (options: { sort: Sort }) => void; +}; +export type NamedEntitiesSortMethods = { + [K in Collection as `sort${Capitalize}Entities`]: (options: { + sort: Sort; + }) => void; +}; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.ts b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.ts index 00ded227..fcaa2015 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.ts @@ -18,25 +18,16 @@ import { import type { StateSignal } from '@ngrx/signals/src/state-signal'; import { getWithEntitiesKeys } from '../util'; +import { + EntitiesSortMethods, + EntitiesSortState, + NamedEntitiesSortMethods, + NamedEntitiesSortState, +} from './with-entities-local-sort.model'; import { getWithEntitiesSortKeys } from './with-entities-sort.util'; import { Sort, sortData, SortDirection } from './with-entities-sort.utils'; -export type EntitiesSortState = { - entitiesSort: Sort; -}; -export type NamedEntitiesSortState = { - [K in Collection as `${K}Sort`]: Sort; -}; - export { SortDirection }; -export type EntitiesSortMethods = { - sortEntities: (options: { sort: Sort }) => void; -}; -export type NamedEntitiesSortMethods = { - [K in Collection as `sort${Capitalize}Entities`]: (options: { - sort: Sort; - }) => void; -}; /** * Generates necessary state, computed and methods for sorting locally entities in the store. Requires withEntities to be present before this function diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.ts b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.ts index 3be8e16f..e57d65ba 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.ts @@ -13,17 +13,18 @@ import { } from '@ngrx/signals/entities/src/models'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import type { - CallStateMethods, - NamedCallStateMethods, -} from '../with-call-status/with-call-status'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; +import { + CallStatusMethods, + NamedCallStatusMethods, +} from '../with-call-status/with-call-status.model'; import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; -import type { +import { EntitiesSortMethods, EntitiesSortState, NamedEntitiesSortMethods, NamedEntitiesSortState, -} from './with-entities-local-sort'; +} from './with-entities-local-sort.model'; import { getWithEntitiesSortKeys } from './with-entities-sort.util'; import type { Sort } from './with-entities-sort.utils'; @@ -70,7 +71,7 @@ import type { Sort } from './with-entities-sort.utils'; * // withHooks(({ productsSort, productsLoading, setProductsError, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // sortColumn: productsSort().field, @@ -109,7 +110,7 @@ export function withEntitiesRemoteSort< { state: EntityState; signals: EntitySignals; - methods: CallStateMethods; + methods: CallStatusMethods; }, { state: EntitiesSortState; @@ -160,7 +161,7 @@ export function withEntitiesRemoteSort< * // withHooks(({ productsSort, productsLoading, setProductsError, ...state }) => ({ * // onInit: async () => { * // effect(() => { - * // if (productsLoading()) { + * // if (isProductsLoading()) { * // inject(ProductService) * // .getProducts({ * // sortColumn: productsSort().field, @@ -201,7 +202,7 @@ export function withEntitiesRemoteSort< { state: NamedEntityState; signals: NamedEntitySignals; - methods: NamedCallStateMethods; + methods: NamedCallStatusMethods; }, { state: NamedEntitiesSortState; @@ -224,17 +225,19 @@ export function withEntitiesRemoteSort< prop: config.collection, }); const { sortKey, sortEntitiesKey } = getWithEntitiesSortKeys(config); + const { clearEntitiesCacheKey } = getWithEntitiesKeys(config); return signalStoreFeature( withState({ [sortKey]: defaultSort }), withMethods((state: Record>) => { const setLoading = state[setLoadingKey] as () => void; - + const clearEntitiesCache = combineFunctions(state[clearEntitiesCacheKey]); return { [sortEntitiesKey]: ({ sort: newSort }: { sort: Sort }) => { patchState(state as StateSignal, { [sortKey]: newSort, }); setLoading(); + clearEntitiesCache(); }, }; }), diff --git a/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.ts b/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.ts index 0a3b30e1..aef386f7 100644 --- a/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.ts +++ b/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.ts @@ -73,7 +73,6 @@ export const withSyncToWebStorage = ({ }), withHooks(({ loadFromStorage, saveToStorage, ...store }) => ({ onInit() { - console.log('init'); if (restoreOnInit) loadFromStorage(); if (saveStateChangesAfterMs) {