From 5a0edbe33dfd40cd9ae305b88a4092e838293b42 Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Wed, 17 Apr 2024 14:37:09 +0100 Subject: [PATCH 1/9] refactor: refactor example using ProductStore to Branch to not confuse with ngrx store --- .../cache-and-dropdowns-page.component.ts | 21 ++--- .../branch-dropdown.component.ts} | 81 +++++++++--------- .../store.local-traits.ts | 45 +++++----- .../department-dropdown.component.ts | 80 ++++++++--------- .../department.local-traits.ts | 35 ++++---- .../src/app/examples/models/index.ts | 14 +-- ...cts-store.service.ts => branch.service.ts} | 27 +++--- ...-stores.handler.ts => branches.handler.ts} | 85 +++++++++---------- .../examples/services/mock-backend/index.ts | 5 +- .../products-branch-dropdown.component.ts | 6 +- .../products-branch.store.ts | 8 +- libs/ngrx-traits/core/api-docs.md | 50 +++++++---- 12 files changed, 236 insertions(+), 221 deletions(-) rename apps/example-app/src/app/examples/cache-and-dropdowns-page/components/{store-dropdown/store-dropdown.component.ts => branch-dropdown/branch-dropdown.component.ts} (70%) rename apps/example-app/src/app/examples/cache-and-dropdowns-page/components/{store-dropdown => branch-dropdown}/store.local-traits.ts (65%) rename apps/example-app/src/app/examples/services/{products-store.service.ts => branch.service.ts} (56%) rename apps/example-app/src/app/examples/services/mock-backend/{product-stores.handler.ts => branches.handler.ts} (55%) diff --git a/apps/example-app/src/app/examples/cache-and-dropdowns-page/cache-and-dropdowns-page.component.ts b/apps/example-app/src/app/examples/cache-and-dropdowns-page/cache-and-dropdowns-page.component.ts index 33146c28..96d17ac2 100644 --- a/apps/example-app/src/app/examples/cache-and-dropdowns-page/cache-and-dropdowns-page.component.ts +++ b/apps/example-app/src/app/examples/cache-and-dropdowns-page/cache-and-dropdowns-page.component.ts @@ -1,28 +1,29 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { + ReactiveFormsModule, UntypedFormControl, UntypedFormGroup, - ReactiveFormsModule, } from '@angular/forms'; -import { DepartmentDropdownComponent } from './components/department-dropdown/department-dropdown.component'; -import { StoreDropdownComponent } from './components/store-dropdown/store-dropdown.component'; import { MatCardModule } from '@angular/material/card'; +import { BranchDropdownComponent } from './components/branch-dropdown/branch-dropdown.component'; +import { DepartmentDropdownComponent } from './components/department-dropdown/department-dropdown.component'; + @Component({ selector: 'ngrx-traits-cache-and-dropdowns-page', template: `
- + formControlName="branch" + >
@@ -35,13 +36,13 @@ import { MatCardModule } from '@angular/material/card'; imports: [ MatCardModule, ReactiveFormsModule, - StoreDropdownComponent, + BranchDropdownComponent, DepartmentDropdownComponent, ], }) export class CacheAndDropdownsPageComponent { form = new UntypedFormGroup({ - store: new UntypedFormControl(), + branch: new UntypedFormControl(), department: new UntypedFormControl(), }); } diff --git a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/store-dropdown/store-dropdown.component.ts b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/branch-dropdown/branch-dropdown.component.ts similarity index 70% rename from apps/example-app/src/app/examples/cache-and-dropdowns-page/components/store-dropdown/store-dropdown.component.ts rename to apps/example-app/src/app/examples/cache-and-dropdowns-page/components/branch-dropdown/branch-dropdown.component.ts index e993f507..e5e6dbd2 100644 --- a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/store-dropdown/store-dropdown.component.ts +++ b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/branch-dropdown/branch-dropdown.component.ts @@ -1,51 +1,46 @@ +import { AsyncPipe } from '@angular/common'; import { - Component, - OnInit, ChangeDetectionStrategy, + Component, Input, - Output, OnDestroy, + OnInit, + Output, } from '@angular/core'; -import { ProductsStore } from '../../../models'; -import { ProductsStoreLocalTraits } from './store.local-traits'; -import { createSelector, Store } from '@ngrx/store'; +import { input } from '@angular/core'; import { ControlValueAccessor, - UntypedFormControl, NG_VALUE_ACCESSOR, ReactiveFormsModule, + UntypedFormControl, } from '@angular/forms'; +import { MatOptionModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { createSelector, Store } from '@ngrx/store'; import { Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatOptionModule } from '@angular/material/core'; + import { SearchOptionsComponent } from '../../../components/search-options/search-options.component'; -import { MatSelectModule } from '@angular/material/select'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { AsyncPipe } from '@angular/common'; -import { input } from '@angular/core'; +import { Branch } from '../../../models'; +import { BranchLocalTraits } from './store.local-traits'; @Component({ - selector: 'store-dropdown', + selector: 'branch-dropdown', template: ` @if (data$ | async; as data) { - - Store + + Branch + > @for (item of data.stores; track item) { - + {{ item.name }} } @@ -57,7 +52,7 @@ import { input } from '@angular/core'; } - `, + `, styles: [ ` :host { @@ -70,10 +65,10 @@ import { input } from '@angular/core'; ], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ - ProductsStoreLocalTraits, + BranchLocalTraits, { provide: NG_VALUE_ACCESSOR, - useExisting: StoreDropdownComponent, + useExisting: BranchDropdownComponent, multi: true, }, ], @@ -85,36 +80,38 @@ import { input } from '@angular/core'; SearchOptionsComponent, MatOptionModule, MatProgressSpinnerModule, - AsyncPipe -], + AsyncPipe, + ], }) -export class StoreDropdownComponent +export class BranchDropdownComponent implements OnInit, ControlValueAccessor, OnDestroy { control = new UntypedFormControl(); data$ = this.store.select( createSelector( - this.traits.localSelectors.isStoresLoading, - this.traits.localSelectors.selectStoresList, - (isLoading, stores) => ({ isLoading, stores }) - ) + this.traits.localSelectors.isBranchesLoading, + this.traits.localSelectors.selectBranchesList, + (isLoading, stores) => ({ isLoading, stores }), + ), ); private onTouch: any; destroy = new Subject(); - @Input() set value(value: ProductsStore) { + @Input() set value(value: Branch) { this.control.setValue(value); } - @Output() valueChanges = this.control - .valueChanges as Observable; + @Output() valueChanges = this.control.valueChanges as Observable; - constructor(private store: Store, private traits: ProductsStoreLocalTraits) {} + constructor( + private store: Store, + private traits: BranchLocalTraits, + ) {} ngOnInit(): void { - this.store.dispatch(this.traits.localActions.loadStores()); + this.store.dispatch(this.traits.localActions.loadBranches()); } - writeValue(value: ProductsStore): void { + writeValue(value: Branch): void { this.control.setValue(value); } @@ -132,12 +129,12 @@ export class StoreDropdownComponent this.destroy.next(); this.destroy.complete(); } - compareById(value: ProductsStore, option: ProductsStore) { + compareById(value: Branch, option: Branch) { return value && option && value.id == option.id; } search(text: string | undefined) { this.store.dispatch( - this.traits.localActions.filterStores({ filters: { search: text } }) + this.traits.localActions.filterBranches({ filters: { search: text } }), ); } } diff --git a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/store-dropdown/store.local-traits.ts b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/branch-dropdown/store.local-traits.ts similarity index 65% rename from apps/example-app/src/app/examples/cache-and-dropdowns-page/components/store-dropdown/store.local-traits.ts rename to apps/example-app/src/app/examples/cache-and-dropdowns-page/components/branch-dropdown/store.local-traits.ts index 7bcf6148..9f2d5f5f 100644 --- a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/store-dropdown/store.local-traits.ts +++ b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/branch-dropdown/store.local-traits.ts @@ -1,19 +1,20 @@ +import { Injectable } from '@angular/core'; +import { + addFilterEntitiesTrait, + addLoadEntitiesTrait, +} from '@ngrx-traits/common'; import { cache, createEntityFeatureFactory, LocalTraitsConfig, TraitsLocalStore, } from '@ngrx-traits/core'; -import { - addFilterEntitiesTrait, - addLoadEntitiesTrait, -} from '@ngrx-traits/common'; -import { ProductsStore, ProductsStoreFilter } from '../../../models'; -import { Injectable } from '@angular/core'; -import { ProductsStoreService } from '../../../services/products-store.service'; -import { catchError, exhaustMap, map } from 'rxjs/operators'; import { createEffect, ofType } from '@ngrx/effects'; import { of } from 'rxjs'; +import { catchError, exhaustMap, map } from 'rxjs/operators'; + +import { Branch, BranchFilter } from '../../../models'; +import { BranchService } from '../../../services/branch.service'; export const storeCacheKeys = { all: ['stores'], @@ -22,9 +23,9 @@ export const storeCacheKeys = { }; const storeFeatureFactory = createEntityFeatureFactory( - { entityName: 'store' }, - addLoadEntitiesTrait(), - addFilterEntitiesTrait({ + { entityName: 'branch', entitiesName: 'branches' }, + addLoadEntitiesTrait(), + addFilterEntitiesTrait({ filterFn: (filter, entity) => { const searchString = filter?.search?.toLowerCase?.(); return ( @@ -33,32 +34,34 @@ const storeFeatureFactory = createEntityFeatureFactory( entity.address.toLowerCase().includes(searchString) ); }, - }) + }), ); @Injectable() -export class ProductsStoreLocalTraits extends TraitsLocalStore< +export class BranchLocalTraits extends TraitsLocalStore< typeof storeFeatureFactory > { - constructor(private storeService: ProductsStoreService) { + constructor(private storeService: BranchService) { super(); this.traits.addEffects(this); } - loadStores$ = createEffect(() => { + loadBranches$ = createEffect(() => { return this.actions$.pipe( - ofType(this.localActions.loadStores), + ofType(this.localActions.loadBranches), exhaustMap(() => cache({ key: storeCacheKeys.list(), store: this.store, - source: this.storeService.getStores(), + source: this.storeService.getBranches(), // no expire param so is stored forever }).pipe( - map((res) => this.localActions.loadStoresSuccess({ entities: res })), - catchError(() => of(this.localActions.loadStoresFail())) - ) - ) + map((res) => + this.localActions.loadBranchesSuccess({ entities: res.resultList }), + ), + catchError(() => of(this.localActions.loadBranchesFail())), + ), + ), ); }); diff --git a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department-dropdown.component.ts b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department-dropdown.component.ts index c0d6dbca..db7e1e6a 100644 --- a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department-dropdown.component.ts +++ b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department-dropdown.component.ts @@ -1,3 +1,4 @@ +import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -5,49 +6,50 @@ import { OnDestroy, Output, } from '@angular/core'; +import { input } from '@angular/core'; import { ControlValueAccessor, - UntypedFormControl, NG_VALUE_ACCESSOR, ReactiveFormsModule, + UntypedFormControl, } from '@angular/forms'; +import { MatOptionModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; import { createSelector, Store } from '@ngrx/store'; -import { ProductsStore } from '../../../models'; import { Observable, Subject } from 'rxjs'; -import { DepartmentLocalTraits } from './department.local-traits'; import { takeUntil } from 'rxjs/operators'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatOptionModule } from '@angular/material/core'; + import { SearchOptionsComponent } from '../../../components/search-options/search-options.component'; -import { MatSelectModule } from '@angular/material/select'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { AsyncPipe } from '@angular/common'; -import { input } from '@angular/core'; +import { Branch } from '../../../models'; +import { DepartmentLocalTraits } from './department.local-traits'; @Component({ selector: 'department-dropdown', template: ` @if (data$ | async; as data) { - - Department - - - @for (item of data.stores; track item) { - - {{ item.name }} - - } @if (data.isLoading) { - - - - } - - + + Department + + + @for (item of data.stores; track item) { + + {{ item.name }} + + } + @if (data.isLoading) { + + + + } + + } `, styles: [ @@ -92,13 +94,13 @@ export class DepartmentDropdownComponent createSelector({ isLoading: this.traits.localSelectors.isDepartmentsLoading, stores: this.traits.localSelectors.selectDepartmentsList, - }) + }), ); private onTouch: any; destroy = new Subject(); - @Input() set value(value: ProductsStore) { + @Input() set value(value: Branch) { this.writeValue(value); } @Input() set storeId(storeId: number | undefined) { @@ -107,16 +109,18 @@ export class DepartmentDropdownComponent this.store.dispatch( this.traits.localActions.filterDepartments({ filters: { storeId }, - }) + }), ); } - @Output() valueChanges = this.control - .valueChanges as Observable; + @Output() valueChanges = this.control.valueChanges as Observable; - constructor(private store: Store, private traits: DepartmentLocalTraits) {} + constructor( + private store: Store, + private traits: DepartmentLocalTraits, + ) {} - writeValue(value: ProductsStore): void { + writeValue(value: Branch): void { this.control.setValue(value); } @@ -135,7 +139,7 @@ export class DepartmentDropdownComponent this.destroy.complete(); } - compareById(value: ProductsStore, option: ProductsStore) { + compareById(value: Branch, option: Branch) { return value && option && value.id == option.id; } @@ -144,7 +148,7 @@ export class DepartmentDropdownComponent this.traits.localActions.filterDepartments({ filters: { search: text }, patch: true, - }) + }), ); } } diff --git a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department.local-traits.ts b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department.local-traits.ts index a8dbfe45..a4e28c3b 100644 --- a/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department.local-traits.ts +++ b/apps/example-app/src/app/examples/cache-and-dropdowns-page/components/department-dropdown/department.local-traits.ts @@ -1,21 +1,22 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { Injectable } from '@angular/core'; +import { + addFilterEntitiesTrait, + addLoadEntitiesTrait, +} from '@ngrx-traits/common'; import { cache, createEntityFeatureFactory, LocalTraitsConfig, TraitsLocalStore, } from '@ngrx-traits/core'; -import { - addFilterEntitiesTrait, - addLoadEntitiesTrait, -} from '@ngrx-traits/common'; -import { Department, DepartmentFilter } from '../../../models'; -import { Injectable } from '@angular/core'; -import { ProductsStoreService } from '../../../services/products-store.service'; -import { catchError, exhaustMap, map } from 'rxjs/operators'; import { concatLatestFrom, createEffect, ofType } from '@ngrx/effects'; import { of } from 'rxjs'; -import { storeCacheKeys } from '../store-dropdown/store.local-traits'; +import { catchError, exhaustMap, map } from 'rxjs/operators'; + +import { Department, DepartmentFilter } from '../../../models'; +import { BranchService } from '../../../services/branch.service'; +import { storeCacheKeys } from '../branch-dropdown/store.local-traits'; const departmentFeatureFactory = createEntityFeatureFactory( { entityName: 'department' }, @@ -29,14 +30,14 @@ const departmentFeatureFactory = createEntityFeatureFactory( }, isRemoteFilter: (previous, current) => previous?.storeId !== current?.storeId, - }) + }), ); @Injectable() export class DepartmentLocalTraits extends TraitsLocalStore< typeof departmentFeatureFactory > { - constructor(private storeService: ProductsStoreService) { + constructor(private storeService: BranchService) { super(); this.traits.addEffects(this); } @@ -45,24 +46,24 @@ export class DepartmentLocalTraits extends TraitsLocalStore< return this.actions$.pipe( ofType(this.localActions.loadDepartments), concatLatestFrom(() => - this.store.select(this.localSelectors.selectDepartmentsFilter) + this.store.select(this.localSelectors.selectDepartmentsFilter), ), exhaustMap(([, filters]) => cache({ key: storeCacheKeys.departments(filters!.storeId), store: this.store, - source: this.storeService.getStoreDepartments(filters!.storeId), + source: this.storeService.getBranchDepartments(filters!.storeId), expires: 1000 * 60 * 3, maxCacheSize: 3, }).pipe( map((res) => this.localActions.loadDepartmentsSuccess({ entities: res, - }) + }), ), - catchError(() => of(this.localActions.loadDepartmentsFail())) - ) - ) + catchError(() => of(this.localActions.loadDepartmentsFail())), + ), + ), ); }); diff --git a/apps/example-app/src/app/examples/models/index.ts b/apps/example-app/src/app/examples/models/index.ts index f33b886a..f3760c33 100644 --- a/apps/example-app/src/app/examples/models/index.ts +++ b/apps/example-app/src/app/examples/models/index.ts @@ -17,13 +17,13 @@ export interface ProductDetail extends Product { image: string; } -export interface ProductsStore { +export interface Branch { id: number; name: string; address: string; } -export interface ProductsStoreDetail { +export interface BranchDetail { id: number; name: string; phone: string; @@ -36,19 +36,19 @@ export interface ProductsStoreDetail { manager: string; departments: Department[]; } -export type ProductsStoreQuery = { +export type BranchQuery = { search?: string | undefined; - sortColumn?: keyof ProductsStore | undefined; + sortColumn?: keyof Branch | undefined; sortAscending?: string | undefined; skip?: string | undefined; take?: string | undefined; }; -export interface ProductsStoreResponse { - resultList: ProductsStore[]; +export interface BranchResponse { + resultList: Branch[]; total: number; } -export interface ProductsStoreFilter { +export interface BranchFilter { search?: string; } diff --git a/apps/example-app/src/app/examples/services/products-store.service.ts b/apps/example-app/src/app/examples/services/branch.service.ts similarity index 56% rename from apps/example-app/src/app/examples/services/products-store.service.ts rename to apps/example-app/src/app/examples/services/branch.service.ts index f6119e35..19e2e671 100644 --- a/apps/example-app/src/app/examples/services/products-store.service.ts +++ b/apps/example-app/src/app/examples/services/branch.service.ts @@ -1,39 +1,36 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; import { delay, map } from 'rxjs/operators'; -import { - Product, - ProductsStore, - ProductsStoreDetail, - ProductsStoreResponse, -} from '../models'; +import { Branch, BranchDetail, BranchResponse, Product } from '../models'; @Injectable({ providedIn: 'root' }) -export class ProductsStoreService { +export class BranchService { constructor(private httpClient: HttpClient) {} - getStores(options?: { + getBranches(options?: { search?: string | undefined; sortColumn?: keyof Product | string | undefined; sortAscending?: boolean | undefined; skip?: number | undefined; take?: number | undefined; - }) { - console.log('getStores', options); + }): Observable { return this.httpClient - .get('/stores', { + .get('/branches', { params: { ...options, search: options?.search ?? '' }, }) .pipe(delay(500)); } - getStoreDetails(id: number) { + getBranchDetails(id: number) { return this.httpClient - .get('/stores/' + id) + .get('/branches/' + id) .pipe(delay(500)); } - getStoreDepartments(storeId: number) { - return this.getStoreDetails(storeId).pipe(map((v) => v?.departments || [])); + getBranchDepartments(storeId: number) { + return this.getBranchDetails(storeId).pipe( + map((v) => v?.departments || []), + ); } } 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/branches.handler.ts similarity index 55% rename from apps/example-app/src/app/examples/services/mock-backend/product-stores.handler.ts rename to apps/example-app/src/app/examples/services/mock-backend/branches.handler.ts index e4ebce88..c9777f86 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/branches.handler.ts @@ -2,51 +2,48 @@ import { sortData } from '@ngrx-traits/common'; import { rest } from 'msw'; import { - ProductsStore, - ProductsStoreDetail, - ProductsStoreQuery, - ProductsStoreResponse, + Branch, + BranchDetail, + BranchQuery, + BranchResponse, } from '../../models'; import { getRandomInteger } from '../../utils/form-utils'; -export const storeHandlers = [ - 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', +export const branchesHandlers = [ + rest.get('/branches', (req, res, ctx) => { + let result = [...mockBranches]; + 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 = mockBranches.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( + '/branches/:id', (req, res, context) => { const id = +req.params.id; - const storeDetail = mockStoresDetails.find((value) => value.id === id); + const storeDetail = mockBranchesDetails.find((value) => value.id === id); return res(context.status(200), context.json(storeDetail)); }, ), @@ -117,12 +114,12 @@ const names = [ 'Wendi Ellis', ]; -const mockStoresDetails: ProductsStoreDetail[] = new Array(500) +const mockBranchesDetails: BranchDetail[] = new Array(500) .fill(null) .map((_, index) => { return { id: index, - name: 'SuperStore ' + index, + name: 'Branch ' + index, phone: getRandomInteger(100, 300) + ' ' + @@ -141,11 +138,11 @@ const mockStoresDetails: ProductsStoreDetail[] = new Array(500) manager: names[getRandomInteger(0, names.length - 1)], departments: new Array(200).fill(null).map((value, i) => ({ id: i, - name: 'Departament ' + i + ' of SuperStore ' + index, + name: 'Department ' + i + ' of Branch ' + index, })), }; }); -const mockStores: ProductsStore[] = mockStoresDetails.map( +const mockBranches: Branch[] = mockBranchesDetails.map( ({ id, name, address }) => ({ id, name, diff --git a/apps/example-app/src/app/examples/services/mock-backend/index.ts b/apps/example-app/src/app/examples/services/mock-backend/index.ts index 06469437..b4811237 100644 --- a/apps/example-app/src/app/examples/services/mock-backend/index.ts +++ b/apps/example-app/src/app/examples/services/mock-backend/index.ts @@ -1,8 +1,9 @@ import { setupWorker } from 'msw'; + +import { branchesHandlers } from './branches.handler'; import { productHandlers } from './product.handler'; -import { storeHandlers } from './product-stores.handler'; -const worker = setupWorker(...productHandlers, ...storeHandlers); +const worker = setupWorker(...productHandlers, ...branchesHandlers); worker.start({ onUnhandledRequest: 'warn', }); 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 index 1b570c03..621a12c4 100644 --- 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 @@ -13,7 +13,7 @@ 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 { Branch } from '../../../../models'; import { ProductsBranchStore } from './products-branch.store'; @Component({ @@ -100,10 +100,10 @@ export class ProductsBranchDropdownComponent { this.store.filterEntities({ filter: { search: query } }); } - trackByFn(index: number, item: ProductsStore) { + trackByFn(index: number, item: Branch) { return item.id; } - compareById(value: ProductsStore, option: ProductsStore) { + compareById(value: Branch, option: Branch) { 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 index 2acd65b5..1da0ce7f 100644 --- 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 @@ -10,10 +10,10 @@ 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'; +import { Branch } from '../../../../models'; +import { BranchService } from '../../../../services/branch.service'; -const entity = type(); +const entity = type(); export const ProductsBranchStore = signalStore( withEntities({ entity, @@ -30,7 +30,7 @@ export const ProductsBranchStore = signalStore( withEntitiesLoadingCall({ fetchEntities: async ({ entitiesPagedRequest, entitiesFilter }) => { const res = await lastValueFrom( - inject(ProductsStoreService).getStores({ + inject(BranchService).getBranches({ search: entitiesFilter().search, skip: entitiesPagedRequest().startIndex, take: entitiesPagedRequest().size, diff --git a/libs/ngrx-traits/core/api-docs.md b/libs/ngrx-traits/core/api-docs.md index 55fc5c1b..e2d7ddab 100644 --- a/libs/ngrx-traits/core/api-docs.md +++ b/libs/ngrx-traits/core/api-docs.md @@ -389,7 +389,8 @@ it will return the cache value without calling again source

| options.expires |

time to expire the cache valued, if not present is infinite

| | options.maxCacheSize |

max number of keys to store , only works if last key is variable

| -**Example** +**Example** + ```js // cache for 3 min loadStores$ = createEffect(() => { @@ -399,7 +400,7 @@ loadStores$ = createEffect(() => { cache({ key: ['stores'], store: this.store, - source: this.storeService.getStores(), + source: this.storeService.getBranches(), expire: 1000 * 60 * 3 // optional param , cache forever if not present }).pipe( map((res) => ProductStoreActions.loadStoresSuccess({ entities: res })), @@ -409,7 +410,7 @@ loadStores$ = createEffect(() => { ); }); // cache top 10, for 3 mins - loadDepartments$ = createEffect(() => { +loadDepartments$ = createEffect(() => { return this.actions$.pipe( ofType(this.localActions.loadDepartments), concatLatestFrom(() => @@ -417,21 +418,34 @@ loadStores$ = createEffect(() => { ), exhaustMap(([_, filters]) => cache({ - key: ['stores','departments',{ storeId: filters!.storeId }, - store: this.store, - source: this.storeService.getStoreDepartments(filters!.storeId), - expires: 1000 * 60 * 3, - maxCacheSize: 10, - }).pipe( - map((res) => - this.localActions.loadDepartmentsSuccess({ - entities: res, - }) - ), - catchError(() => of(this.localActions.loadDepartmentsFail())) - ) - ) - ); + key: ['stores', 'departments', { storeId: filters!.storeId + }, + store +: + this.store, + source +: + this.storeService.getBranchDepartments(filters + ! +. + storeId +), + expires: 1000 * 60 * 3, + maxCacheSize +: + 10, +}). + pipe( + map((res) => + this.localActions.loadDepartmentsSuccess({ + entities: res, + }) + ), + catchError(() => of(this.localActions.loadDepartmentsFail())) + ) +) +) + ; }); ``` From 7177b49157d477176d9aa9da80b16e8180482143 Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Wed, 17 Apr 2024 22:10:54 +0100 Subject: [PATCH 2/9] test: added missing unit test to all traits --- .../filter-entities.trait.effect.ts | 39 +-- .../ngrx-traits/signals/src/lib/test.mocks.ts | 143 +++++++++++ .../ngrx-traits/signals/src/lib/test.model.ts | 63 +++++ .../with-call-status/with-call-status.spec.ts | 43 ++++ .../lib/with-call-status/with-call-status.ts | 2 +- .../src/lib/with-calls/with-calls.spec.ts | 37 +++ .../signals/src/lib/with-calls/with-calls.ts | 2 +- .../with-entities-filter.util.ts | 3 +- .../with-entities-local-filter.spec.ts | 190 ++++++++++++++ .../with-entities-remote-filter.spec.ts | 235 ++++++++++++++++++ .../with-entities-remote-filter.ts | 4 +- .../with-entities-loading-call.spec.ts | 191 ++++++++++++++ .../with-entities-multi-selection.ts | 48 ++-- 13 files changed, 949 insertions(+), 51 deletions(-) create mode 100644 libs/ngrx-traits/signals/src/lib/test.mocks.ts create mode 100644 libs/ngrx-traits/signals/src/lib/test.model.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts diff --git a/libs/ngrx-traits/common/src/lib/filter-entities/filter-entities.trait.effect.ts b/libs/ngrx-traits/common/src/lib/filter-entities/filter-entities.trait.effect.ts index ca382361..cef57b8a 100644 --- a/libs/ngrx-traits/common/src/lib/filter-entities/filter-entities.trait.effect.ts +++ b/libs/ngrx-traits/common/src/lib/filter-entities/filter-entities.trait.effect.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { TraitEffect, Type } from '@ngrx-traits/core'; +import { createEffect, ofType } from '@ngrx/effects'; import { asyncScheduler, EMPTY, of, pipe, timer } from 'rxjs'; import { concatMap, @@ -10,17 +11,17 @@ import { pairwise, startWith, } from 'rxjs/operators'; -import { createEffect, ofType } from '@ngrx/effects'; -import { - FilterEntitiesKeyedConfig, - FilterEntitiesSelectors, -} from './filter-entities.model'; + +import { EntitiesPaginationActions } from '../entities-pagination'; import { LoadEntitiesActions, LoadEntitiesSelectors, } from '../load-entities/load-entities.model'; +import { + FilterEntitiesKeyedConfig, + FilterEntitiesSelectors, +} from './filter-entities.model'; import { ƟFilterEntitiesActions } from './filter-entities.model.internal'; -import { EntitiesPaginationActions } from '../entities-pagination'; export function createFilterTraitEffects( allActions: ƟFilterEntitiesActions & @@ -28,7 +29,7 @@ export function createFilterTraitEffects( EntitiesPaginationActions, allSelectors: FilterEntitiesSelectors & LoadEntitiesSelectors, - allConfigs: FilterEntitiesKeyedConfig + allConfigs: FilterEntitiesKeyedConfig, ): Type[] { const traitConfig = allConfigs.filter; @Injectable() @@ -42,7 +43,7 @@ export function createFilterTraitEffects( this.actions$.pipe( ofType(allActions.filterEntities), debounce((value) => - value?.forceLoad ? EMPTY : timer(debounceTime, scheduler) + value?.forceLoad ? of({}) : timer(debounceTime, scheduler), ), concatMap((payload) => payload.patch @@ -51,15 +52,15 @@ export function createFilterTraitEffects( map((storedFilters) => ({ ...payload, filters: { ...storedFilters, ...payload?.filters }, - })) + })), ) - : of(payload) + : of(payload), ), distinctUntilChanged( (previous, current) => !current?.forceLoad && JSON.stringify(previous?.filters) === - JSON.stringify(current?.filters) + JSON.stringify(current?.filters), ), traitConfig?.isRemoteFilter ? pipe( @@ -71,7 +72,7 @@ export function createFilterTraitEffects( concatMap(([previous, current]) => traitConfig?.isRemoteFilter!( previous?.filters, - current?.filters + current?.filters, ) ? [ allActions.storeEntitiesFilter({ @@ -85,16 +86,16 @@ export function createFilterTraitEffects( filters: current?.filters, patch: current?.patch, }), - ] - ) + ], + ), ) : map((action) => allActions.storeEntitiesFilter({ filters: action?.filters, patch: action?.patch, - }) - ) - ) + }), + ), + ), ); loadEntities$ = @@ -108,8 +109,8 @@ export function createFilterTraitEffects( allActions.clearEntitiesPagesCache(), allActions.loadEntitiesFirstPage(), ] - : [allActions.loadEntities()] - ) + : [allActions.loadEntities()], + ), ); }); } diff --git a/libs/ngrx-traits/signals/src/lib/test.mocks.ts b/libs/ngrx-traits/signals/src/lib/test.mocks.ts new file mode 100644 index 00000000..c6b340e8 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/test.mocks.ts @@ -0,0 +1,143 @@ +import { Product } from '../../../../../apps/example-app/src/app/examples/models'; +import { getRandomInteger } from '../../../../../apps/example-app/src/app/examples/utils/form-utils'; + +const snes = [ + 'Super Mario World', + 'F-Zero', + 'Pilotwings', + 'SimCity', + 'Super Tennis', + 'Mario Paint', + 'Super Mario Kart', + 'Super Scope 6', + 'BattleClash', + 'The Legend of Zelda: A Link to the Past', + 'Super Play Action Football', + 'NCAA Basketball', + 'Super Soccer', + 'Star Fox', + 'Super Mario All-Stars', + "Yoshi's Safari", + 'Vegas Stakes', + "Metal Combat: Falcon's Revenge", + 'NHL Stanley Cup', + 'Mario & Wario', + "Yoshi's Cookie", + 'Super Metroid', + 'Stunt Race FX', + 'Donkey Kong Country', + 'Ken Griffey Jr. Presents Major League Baseball', + 'Super Pinball: Behind the Mask', + 'Super Punch-Out!!', + 'Tin Star', + 'Tetris 2', + 'Tetris & Dr. Mario', + 'Uniracers', + "Wario's Woods", + 'Super Mario All Stars', + 'Super Mario World', + 'Illusion of Gaia', + 'Fire Emblem: Monshou no Nazo', + 'Mega Man 6', + 'EarthBound', + "Kirby's Dream Course", + "Super Mario World 2: Yoshi's Island", + "Donkey Kong Country 2: Diddy's Kong Quest", + "Kirby's Avalanche", + 'Killer Instinct', + "Mario's Super Picross", + 'Panel de Pon', + 'Super Mario RPG: Legend of the Seven Stars', + 'Kirby Super Star', + "Donkey Kong Country 3: Dixie Kong's Double Trouble!", + "Ken Griffey Jr.'s Winning Run", + 'Tetris Attack', + 'Fire Emblem: Seisen no Keifu', + 'Marvelous: Another Treasure Island', + 'Maui Mallard in Cold Shadow', + 'Arkanoid: Doh it Again', + "Kirby's Dream Land 3", + 'Heisei Shin Onigashima', + 'Space Invaders: The Original Game', + "Wrecking Crew '98", + 'Kirby no Kirakira Kizzu', + 'Sutte Hakkun', + 'Zoo-tto Mahjong!', + 'Power Soukoban', + 'Fire Emblem: Thracia 776', + 'Famicom Bunko: Hajimari no Mori', + 'Power Lode Runner', +]; +const gamecube = [ + "Luigi's Mansion", + 'Wave Race: Blue Storm', + 'Super Smash Bros. Melee', + 'Pikmin', + 'Animal Crossing', + "Disney's Magical Mirror Starring Mickey Mouse", + "Eternal Darkness: Sanity's Requiem", + 'Mario Party 4', + 'Metroid Prime', + 'NBA Courtside 2002', + 'Star Fox Adventures', + 'Super Mario Sunshine', + 'Cubivore: Survival of the Fittest', + 'Doshin the Giant', + '1080° Avalanche', + 'F-Zero GX', + 'Kirby Air Ride', + "The Legend of Zelda Collector's Edition", + 'The Legend of Zelda: Ocarina of Time Master Quest', + 'The Legend of Zelda: The Wind Waker', + 'Mario Golf: Toadstool Tour', + 'Mario Kart: Double Dash‼', + 'Mario Party 5', + 'Pokémon Channel', + 'Wario World', + 'GiFTPiA', + 'Nintendo Puzzle Collection', + 'Custom Robo', + 'Donkey Konga', + 'Metal Gear Solid: The Twin Snakes', + 'The Legend of Zelda: Four Swords Adventure', + 'Mario Party 6', + 'Mario Power Tennis', + 'Metroid Prime 2: Echoes', + 'Paper Mario: The Thousand-Year Door', + 'Pikmin 2', + 'Pokémon Box: Ruby and Sapphire', + 'Pokémon Colosseum', + 'WarioWare, Inc.: Mega Party Game$', + 'Final Fantasy: Crystal Chronicles', + 'Kururin Squash!', + 'Battalion Wars', + 'Dance Dance Revolution: Mario Mix', + 'Donkey Konga 2', + 'Donkey Kong Jungle Beat', + 'Fire Emblem: Path of Radiance', + 'Geist', + 'Mario Party 7', + 'Mario Superstar Baseball', + 'Pokémon XD: Gale of Darkness', + 'Star Fox: Assault', + 'Super Mario Strikers', + 'Densetsu no Quiz Ou Ketteisen', + 'Donkey Konga 3', + 'Chibi-Robo!', + 'The Legend of Zelda: Twilight Princess', + 'Odama', +]; +export const mockProducts: Product[] = [ + ...snes.map((name, id) => ({ + name, + id: id + '', + description: 'Super Nintendo Game', + price: id * 2 + 10, + })), + ...gamecube.map((name, id) => ({ + name, + id: snes.length + id + '', + description: 'GameCube Game', + price: id * 3 + 10, + })), +]; diff --git a/libs/ngrx-traits/signals/src/lib/test.model.ts b/libs/ngrx-traits/signals/src/lib/test.model.ts new file mode 100644 index 00000000..f3760c33 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/test.model.ts @@ -0,0 +1,63 @@ +export interface Product { + id: string; + name: string; + description: string; + price: number; +} +export interface ProductOrder extends Product { + quantity?: number; +} +export interface ProductFilter { + search: string; +} + +export interface ProductDetail extends Product { + maker: string; + releaseDate: string; + image: string; +} + +export interface Branch { + id: number; + name: string; + address: string; +} + +export interface BranchDetail { + id: number; + name: string; + phone: string; + address: { + line1: string; + postCode: string; + town: string; + country: string; + }; + manager: string; + departments: Department[]; +} +export type BranchQuery = { + search?: string | undefined; + sortColumn?: keyof Branch | undefined; + sortAscending?: string | undefined; + skip?: string | undefined; + take?: string | undefined; +}; +export interface BranchResponse { + resultList: Branch[]; + total: number; +} + +export interface BranchFilter { + search?: string; +} + +export interface Department { + id: number; + name: string; +} + +export interface DepartmentFilter { + storeId: number; + search?: string; +} diff --git a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts new file mode 100644 index 00000000..bb515188 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts @@ -0,0 +1,43 @@ +import { withCallStatus } from '@ngrx-traits/signals'; +import { signalStore } from '@ngrx/signals'; + +describe('withCallStatus', () => { + const Store = signalStore(withCallStatus()); + + it('setLoading should make isLoading return true', () => { + const store = new Store(); + expect(store.isLoading()).toEqual(false); + store.setLoading(); + expect(store.isLoading()).toEqual(true); + }); + it('setLoaded should make isLoaded return true', () => { + const store = new Store(); + expect(store.isLoaded()).toEqual(false); + store.setLoaded(); + expect(store.isLoaded()).toEqual(true); + }); + it('setError should make error return the object set', () => { + const store = new Store(); + expect(store.error()).toEqual(undefined); + store.setError({ message: 'error' }); + expect(store.error()).toEqual({ message: 'error' }); + }); + it('check initialValue works', () => { + const Store = signalStore(withCallStatus({ initialValue: 'loading' })); + const store = new Store(); + expect(store.isLoading()).toEqual(true); + }); + it('check prop rename works', () => { + const Store = signalStore(withCallStatus({ prop: 'test' })); + const store = new Store(); + expect(store.isTestLoading()).toEqual(false); + store.setTestLoading(); + expect(store.isTestLoading()).toEqual(true); + expect(store.isTestLoaded()).toEqual(false); + store.setTestLoaded(); + expect(store.isTestLoaded()).toEqual(true); + expect(store.testError()).toEqual(undefined); + store.setTestError({ message: 'error' }); + expect(store.testError()).toEqual({ message: 'error' }); + }); +}); 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 519ecf83..95021016 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 @@ -136,7 +136,7 @@ export function withCallStatus({ [loadedKey]: computed(() => callState() === 'loaded'), [errorKey]: computed(() => { const v = callState(); - return typeof v === 'object' ? v.error : null; + return typeof v === 'object' ? v.error : undefined; }), }; }), diff --git a/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts new file mode 100644 index 00000000..cbca4504 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts @@ -0,0 +1,37 @@ +import { TestBed } from '@angular/core/testing'; +import { withCalls } from '@ngrx-traits/signals'; +import { signalStore, withState } from '@ngrx/signals'; +import { Subject, throwError } from 'rxjs'; + +describe('withCalls', () => { + const apiResponse = new Subject(); + const Store = signalStore( + withState({ foo: 'bar' }), + withCalls(() => ({ + testCall: ({ ok }: { ok: boolean }) => { + return ok ? apiResponse : throwError(() => new Error('fail')); + }, + })), + ); + it('Successful call should set status to loading and loaded ', async () => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + expect(store.isTestCallLoading()).toBeFalsy(); + store.testCall({ ok: true }); + expect(store.isTestCallLoading()).toBeTruthy(); + apiResponse.next('test'); + expect(store.isTestCallLoaded()).toBeTruthy(); + expect(store.testCallResult()).toBe('test'); + }); + }); + it('Fail on a call should set status return error ', async () => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + expect(store.isTestCallLoading()).toBeFalsy(); + store.testCall({ ok: false }); + console.log(store.testCallCallStatus()); + expect(store.testCallError()).toEqual(new Error('fail')); + expect(store.testCallResult()).toBe(undefined); + }); + }); +}); 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 0bc5d071..980709fe 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 @@ -182,7 +182,7 @@ export function withCalls< } as StateSignal); const setError = (error: unknown) => patchState(store, { - [callStatusKey]: error, + [callStatusKey]: { error }, } as StateSignal); acc[setLoadingKey] = () => patchState(store, { 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 56c83f1c..be539b88 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 @@ -3,7 +3,6 @@ import { concatMap, debounce, distinctUntilChanged, - EMPTY, of, pipe, timer, @@ -36,7 +35,7 @@ export function debounceFilterPipe(filter: Signal) { debounce?: number; patch?: boolean; forceLoad?: boolean; - }) => (value?.forceLoad ? EMPTY : timer(value.debounce || 300)), + }) => (value?.forceLoad ? of({}) : timer(value.debounce || 300)), ), concatMap((payload) => payload.patch diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts new file mode 100644 index 00000000..d0e2092f --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts @@ -0,0 +1,190 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + withEntitiesLocalFilter, + withEntitiesLocalPagination, + withEntitiesMultiSelection, + withEntitiesSingleSelection, +} from '@ngrx-traits/signals'; +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesLocalFilter', () => { + const entity = type(); + const collection = 'products'; + const Store = signalStore( + withEntities({ + entity, + }), + withEntitiesLocalFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + filterFn: (entity, filter) => + !filter?.search || + entity?.name.toLowerCase().includes(filter?.search.toLowerCase()), + }), + ); + + it('should filter entities and store filter', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar2' }, + }); + expect(store.entities().length).toEqual(mockProducts.length); + tick(400); + expect(store.entities().length).toEqual(2); + expect(store.entities()).toEqual([ + { + description: 'Super Nintendo Game', + id: '1', + name: 'F-Zero', + price: 12, + }, + { + description: 'GameCube Game', + id: '80', + name: 'F-Zero GX', + price: 55, + }, + ]); + expect(store.entitiesFilter()).toEqual({ search: 'zero', foo: 'bar2' }); + }); + })); + + it('should filter entities after provide debounce', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar2' }, + debounce: 1000, + }); + expect(store.entities().length).toEqual(mockProducts.length); + tick(1100); + expect(store.entities().length).toEqual(2); + }); + })); + + it('should filter entities immediately when forceLoad is true', () => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar2' }, + forceLoad: true, + }); + expect(store.entities().length).toEqual(2); + }); + }); + + it('should merge new filter with previous if patch true is set ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + expect(store.entities().length).toEqual(mockProducts.length); + tick(400); + expect(store.entities().length).toEqual(2); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar' }, + forceLoad: true, + }); + }); + })); + + it(' should resetPage to and selection when when filter is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withEntitiesLocalPagination({ entity }), + withEntitiesSingleSelection({ entity }), + withEntitiesMultiSelection({ entity }), + withEntitiesLocalFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + filterFn: (entity, filter) => + !filter?.search || + entity?.name.toLowerCase().includes(filter?.search.toLowerCase()), + }), + ); + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.selectEntity({ id: mockProducts[0].id }); + store.selectEntities({ ids: [mockProducts[2].id, mockProducts[3].id] }); + store.loadEntitiesPage({ pageIndex: 3 }); + expect(store.entitiesSelectedEntity()).toEqual(mockProducts[0]); + expect(store.entitiesSelected?.()).toEqual([ + mockProducts[2], + mockProducts[3], + ]); + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + // check selection and page reset + expect(store.entitiesSelectedEntity()).toEqual(undefined); + expect(store.entitiesSelected()).toEqual([]); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + // check filter + expect(store.entities().length).toEqual(2); + }); + })); + + it(' should rename props when collection param is set', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withEntitiesLocalPagination({ entity, collection }), + withEntitiesLocalFilter({ + entity, + collection, + defaultFilter: { search: '', foo: 'bar' }, + filterFn: (entity, filter) => + !filter?.search || + entity?.name.toLowerCase().includes(filter?.search.toLowerCase()), + }), + ); + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.loadProductsPage({ pageIndex: 3 }); + expect(store.productsCurrentPage().pageIndex).toEqual(3); + + store.filterProductsEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + expect(store.productsEntities().length).toEqual(2); + expect(store.productsEntities()).toEqual([ + { + description: 'Super Nintendo Game', + id: '1', + name: 'F-Zero', + price: 12, + }, + { + description: 'GameCube Game', + id: '80', + name: 'F-Zero GX', + price: 55, + }, + ]); + expect(store.productsFilter()).toEqual({ search: 'zero', foo: 'bar' }); + }); + })); +}); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts new file mode 100644 index 00000000..fe780973 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts @@ -0,0 +1,235 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesMultiSelection, + withEntitiesRemoteFilter, + withEntitiesRemotePagination, + withEntitiesSingleSelection, +} from '@ngrx-traits/signals'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { of } from 'rxjs'; + +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesRemoteFilter', () => { + const entity = type(); + const collection = 'products'; + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemoteFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesFilter }) => { + let result = [...mockProducts]; + if (entitiesFilter()?.search) { + result = mockProducts.filter((entity) => + entitiesFilter()?.search + ? entity.name + .toLowerCase() + .includes(entitiesFilter()?.search.toLowerCase()) + : false, + ); + } + return of(result); + }, + }), + ); + + it('should filter entities and store filter', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + TestBed.flushEffects(); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar2' }, + }); + expect(store.entities().length).toEqual(mockProducts.length); + tick(400); + expect(store.entities().length).toEqual(2); + expect(store.entities()).toEqual([ + { + description: 'Super Nintendo Game', + id: '1', + name: 'F-Zero', + price: 12, + }, + { + description: 'GameCube Game', + id: '80', + name: 'F-Zero GX', + price: 55, + }, + ]); + expect(store.entitiesFilter()).toEqual({ search: 'zero', foo: 'bar2' }); + }); + })); + + it('should filter entities after provide debounce', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + TestBed.flushEffects(); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar2' }, + debounce: 1000, + }); + expect(store.entities().length).toEqual(mockProducts.length); + tick(1100); + expect(store.entities().length).toEqual(2); + }); + })); + + it('should filter entities immediately when forceLoad is true', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + TestBed.flushEffects(); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar2' }, + forceLoad: true, + }); + tick(); + expect(store.entities().length).toEqual(2); + }); + })); + + it('should merge new filter with previous if patch true is set ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const store = new Store(); + TestBed.flushEffects(); + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + expect(store.entities().length).toEqual(mockProducts.length); + tick(400); + expect(store.entities().length).toEqual(2); + store.filterEntities({ + filter: { search: 'zero', foo: 'bar' }, + forceLoad: true, + }); + }); + })); + + it(' should resetPage to and selection when when filter is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemotePagination({ entity }), + withEntitiesSingleSelection({ entity }), + withEntitiesMultiSelection({ entity }), + withEntitiesRemoteFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesFilter }) => { + let result = [...mockProducts]; + if (entitiesFilter()?.search) + result = mockProducts.filter((entity) => + entitiesFilter()?.search + ? entity.name + .toLowerCase() + .includes(entitiesFilter()?.search.toLowerCase()) + : false, + ); + return Promise.resolve({ entities: result, total: result.length }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + tick(400); + store.selectEntity({ id: mockProducts[0].id }); + store.selectEntities({ ids: [mockProducts[2].id, mockProducts[3].id] }); + store.loadEntitiesPage({ pageIndex: 3 }); + expect(store.entitiesSelectedEntity()).toEqual(mockProducts[0]); + expect(store.entitiesSelected?.()).toEqual([ + mockProducts[2], + mockProducts[3], + ]); + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + // check selection and page reset + expect(store.entitiesSelectedEntity()).toEqual(undefined); + expect(store.entitiesSelected()).toEqual([]); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + // check filter + expect(store.entities().length).toEqual(2); + }); + })); + + it(' should rename props when collection param is set', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection, initialValue: 'loading' }), + withEntitiesRemotePagination({ entity, collection }), + withEntitiesRemoteFilter({ + entity, + collection, + defaultFilter: { search: '', foo: 'bar' }, + }), + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsFilter }) => { + let result = [...mockProducts]; + if (productsFilter()?.search) { + result = mockProducts.filter((entity) => + productsFilter()?.search + ? entity.name + .toLowerCase() + .includes(productsFilter()?.search.toLowerCase()) + : false, + ); + } + return of(result); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + store.loadProductsPage({ pageIndex: 3 }); + tick(400); + expect(store.productsCurrentPage().pageIndex).toEqual(3); + + store.filterProductsEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + expect(store.productsEntities().length).toEqual(2); + expect(store.productsEntities()).toEqual([ + { + description: 'Super Nintendo Game', + id: '1', + name: 'F-Zero', + price: 12, + }, + { + description: 'GameCube Game', + id: '80', + name: 'F-Zero GX', + price: 55, + }, + ]); + expect(store.productsFilter()).toEqual({ search: 'zero', foo: 'bar' }); + }); + })); +}); 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 b94b3376..526a5e7b 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 @@ -109,7 +109,7 @@ export function withEntitiesRemoteFilter< Filter extends Record, >(options: { defaultFilter: Filter; - entity?: Entity; + entity: Entity; }): SignalStoreFeature< { state: EntityState; @@ -200,7 +200,7 @@ export function withEntitiesRemoteFilter< Filter extends Record, >(options: { defaultFilter: Filter; - entity?: Entity; + entity: Entity; collection?: Collection; }): SignalStoreFeature< { diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts new file mode 100644 index 00000000..4833fb76 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts @@ -0,0 +1,191 @@ +import { runInInjectionContext } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesRemotePagination, +} from '@ngrx-traits/signals'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { of } from 'rxjs'; + +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesLoadingCall', () => { + const entity = type(); + const collection = 'products'; + + describe('without collection setLoading should call fetch entities', () => { + it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall({ + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + }); + })); + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesLoadingCall({ + fetchEntities: () => { + let result = [...mockProducts]; + return of({ entities: result }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts); + }); + })); + it('should setEntitiesLoadResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus(), + withEntitiesRemotePagination({ + entity, + pageSize: 10, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesPagedRequest }) => { + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + expect(store.entities()).toEqual(mockProducts.slice(0, 30)); + }); + })); + }); + + describe('with collection set[Collection]Loading should call fetch entities', () => { + it('should setAllEntities if fetchEntities returns an Entity[] ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesLoadingCall({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + return of(result); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts); + }); + })); + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesLoadingCall({ + collection, + fetchEntities: () => { + let result = [...mockProducts]; + return of({ entities: result }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts); + }); + })); + it('should set[Collection]LoadResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ collection }), + withEntitiesRemotePagination({ + entity, + collection, + pageSize: 10, + }), + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsPagedRequest }) => { + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: productsPagedRequest()?.startIndex, + take: productsPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + expect(store.productsEntities()).toEqual(mockProducts.slice(0, 30)); + }); + })); + }); +}); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts index 824385fd..29ba19e5 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts @@ -17,9 +17,6 @@ import { import type { StateSignal } from '@ngrx/signals/src/state-signal'; 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'; import { EntitiesMultiSelectionComputed, EntitiesMultiSelectionMethods, @@ -88,28 +85,8 @@ function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { */ export function withEntitiesMultiSelection< Entity extends { id: string | number }, - Collection extends string, >(config: { entity?: Entity; - collection?: Collection; -}): SignalStoreFeature< - // TODO: the problem seems be with the state pro, when set to empty - // it works but is it has a namedstate it doesnt - { - state: NamedEntityState; - signals: NamedEntitySignals; - methods: {}; - }, - { - state: NamedEntitiesMultiSelectionState; - signals: NamedEntitiesMultiSelectionComputed; - methods: NamedEntitiesMultiSelectionMethods; - } ->; -export function withEntitiesMultiSelection< - Entity extends { id: string | number }, ->(options: { - entity?: Entity; }): SignalStoreFeature< { state: EntityState; @@ -122,6 +99,7 @@ export function withEntitiesMultiSelection< methods: EntitiesMultiSelectionMethods; } >; + /** * Generates state, signals and methods for multi selection of entities * @param config @@ -168,6 +146,27 @@ export function withEntitiesMultiSelection< methods: NamedEntitiesMultiSelectionMethods; } >; + +export function withEntitiesMultiSelection< + Entity extends { id: string | number }, + Collection extends string, +>(config: { + entity?: Entity; + collection?: Collection; +}): SignalStoreFeature< + // TODO: the problem seems be with the state pro, when set to empty + // it works but is it has a namedstate it doesnt + { + state: NamedEntityState; + signals: NamedEntitySignals; + methods: {}; + }, + { + state: NamedEntitiesMultiSelectionState; + signals: NamedEntitiesMultiSelectionComputed; + methods: NamedEntitiesMultiSelectionMethods; + } +>; export function withEntitiesMultiSelection< Entity extends { id: string | number }, Collection extends string, @@ -187,9 +186,6 @@ export function withEntitiesMultiSelection< toggleSelectAllEntitiesKey, isAllEntitiesSelectedKey, } = getEntitiesMultiSelectionKeys(config); - const { filterKey } = getWithEntitiesFilterKeys(config); - const { sortKey } = getWithEntitiesSortKeys(config); - const { paginationKey } = getWithEntitiesLocalPaginationKeys(config); return signalStoreFeature( withState({ [selectedIdsMapKey]: {} }), withComputed((state: Record>) => { From 1c16b929ca4f6948bfe92cbe6d72c5dbe5178eb7 Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Wed, 17 Apr 2024 22:20:59 +0100 Subject: [PATCH 3/9] refactor: rename setEntitiesLoadResult to setEntitiesPagedResult and change parameters rename setEntitiesLoadResult to setEntitiesPagedResult and paramter to be an object like {entities, total} --- libs/ngrx-traits/signals/api-docs.md | 4 ++-- .../with-entities-loading-call.spec.ts | 6 ++++- .../with-entities-loading-call.ts | 17 +++++++------- .../with-entities-remote-pagination.model.ts | 23 ++++++++----------- .../with-entities-remote-pagination.ts | 10 ++++++-- .../with-entities-remote-pagination.util.ts | 6 ++--- ...entities-remote-scroll-pagination.model.ts | 13 +++++++---- .../with-entities-remote-scroll-pagination.ts | 10 ++++++-- ...-entities-remote-scroll-pagination.util.ts | 4 ++-- 9 files changed, 53 insertions(+), 40 deletions(-) diff --git a/libs/ngrx-traits/signals/api-docs.md b/libs/ngrx-traits/signals/api-docs.md index 4c499c49..b9803665 100644 --- a/libs/ngrx-traits/signals/api-docs.md +++ b/libs/ngrx-traits/signals/api-docs.md @@ -20,7 +20,7 @@ and is debounced by default. Requires withEntities and withCallStatus to be pres

Generates a onInit hook that fetches entities from a remote source when the [collection]Loading is true, by calling the fetchEntities function and if successful, it will call set[Collection]Loaded and also set the entities -to the store using the setAllEntities method or the setEntitiesLoadResult method +to the store using the setAllEntities method or the setEntitiesPagedResult method if it exists (comes from withEntitiesRemotePagination), if an error occurs it will set the error to the store using set[Collection]Error with the error. Requires withEntities and withCallStatus to be present in the store.

@@ -272,7 +272,7 @@ export const store = signalStore(

Generates a onInit hook that fetches entities from a remote source when the [collection]Loading is true, by calling the fetchEntities function and if successful, it will call set[Collection]Loaded and also set the entities -to the store using the setAllEntities method or the setEntitiesLoadResult method +to the store using the setAllEntities method or the setEntitiesPagedResult method if it exists (comes from withEntitiesRemotePagination), if an error occurs it will set the error to the store using set[Collection]Error with the error. Requires withEntities and withCallStatus to be present in the store.

diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts index 4833fb76..e5026eb8 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts @@ -39,6 +39,7 @@ describe('withEntitiesLoadingCall', () => { expect(store.entities()).toEqual(mockProducts); }); })); + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( @@ -61,7 +62,8 @@ describe('withEntitiesLoadingCall', () => { expect(store.entities()).toEqual(mockProducts); }); })); - it('should setEntitiesLoadResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + + it('should setEntitiesPagedResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( withEntities({ @@ -124,6 +126,7 @@ describe('withEntitiesLoadingCall', () => { expect(store.productsEntities()).toEqual(mockProducts); }); })); + it('should setAllEntities if fetchEntities returns an a {entities: Entity[]} ', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( @@ -148,6 +151,7 @@ describe('withEntitiesLoadingCall', () => { expect(store.productsEntities()).toEqual(mockProducts); }); })); + it('should set[Collection]LoadResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( 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 537d59b9..0f9d972d 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 @@ -54,7 +54,7 @@ import { getWithEntitiesRemotePaginationKeys } from '../with-entities-pagination * Generates a onInit hook that fetches entities from a remote source * when the [collection]Loading is true, by calling the fetchEntities function * and if successful, it will call set[Collection]Loaded and also set the entities - * to the store using the setAllEntities method or the setEntitiesLoadResult method + * to the store using the setAllEntities method or the setEntitiesPagedResult method * if it exists (comes from withEntitiesRemotePagination), * if an error occurs it will set the error to the store using set[Collection]Error with the error. * Requires withEntities and withCallStatus to be present in the store. @@ -145,7 +145,7 @@ export function withEntitiesLoadingCall< * Generates a onInit hook that fetches entities from a remote source * when the [collection]Loading is true, by calling the fetchEntities function * and if successful, it will call set[Collection]Loaded and also set the entities - * to the store using the setAllEntities method or the setEntitiesLoadResult method + * to the store using the setAllEntities method or the setEntitiesPagedResult method * if it exists (comes from withEntitiesRemotePagination), * if an error occurs it will set the error to the store using set[Collection]Error with the error. * Requires withEntities and withCallStatus to be present in the store. @@ -271,17 +271,16 @@ export function withEntitiesLoadingCall< const { loadingKey, setErrorKey, setLoadedKey } = getWithCallStatusKeys({ prop: collection, }); - const { setEntitiesLoadResultKey } = getWithEntitiesRemotePaginationKeys({ + const { setEntitiesPagedResultKey } = getWithEntitiesRemotePaginationKeys({ collection, }); return (store) => { const loading = store.signals[loadingKey] as Signal; const setLoaded = store.methods[setLoadedKey] as () => void; const setError = store.methods[setErrorKey] as (error: unknown) => void; - const setEntitiesLoadResult = store.methods[setEntitiesLoadResultKey] as ( - entities: Entity[], - total: number, - ) => void; + const setEntitiesPagedResult = store.methods[ + setEntitiesPagedResultKey + ] as (result: { entities: Entity[]; total: number }) => void; return signalStoreFeature( withHooks({ @@ -316,8 +315,8 @@ export function withEntitiesLoadingCall< ); } else { const { entities, total } = result; - if (setEntitiesLoadResult) - setEntitiesLoadResult(entities, total); + if (setEntitiesPagedResult) + setEntitiesPagedResult({ entities, total }); else patchState( state, 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 index 946c811d..dad56bd4 100644 --- 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 @@ -62,23 +62,18 @@ export type NamedEntitiesPaginationRemoteComputed< }; export type EntitiesPaginationRemoteMethods = EntitiesPaginationLocalMethods & { - setEntitiesLoadedResult: (entities: Entity[], total: number) => void; + setEntitiesPagedResult: (result: { + 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; + [K in Collection as `set${Capitalize}PagedResult`]: (result: { + 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 78a799a9..149b4d53 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 @@ -358,7 +358,7 @@ export function withEntitiesRemotePagination< entitiesCurrentPageKey, paginationKey, entitiesPagedRequestKey, - setEntitiesLoadResultKey, + setEntitiesPagedResultKey, } = getWithEntitiesRemotePaginationKeys(config); return signalStoreFeature( @@ -453,7 +453,13 @@ export function withEntitiesRemotePagination< ); }, ), - [setEntitiesLoadResultKey]: (entities: Entity[], total: number) => { + [setEntitiesPagedResultKey]: ({ + entities, + total, + }: { + entities: Entity[]; + total: number; + }) => { // TODO extract this function and test all egg cases, like preloading next pages and jumping page const isPreloadNextPagesReady = pagination().currentPage + 1 === pagination().requestPage; 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 fae505e3..5cee5c06 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 @@ -29,9 +29,9 @@ export function getWithEntitiesRemotePaginationKeys(config?: { loadEntitiesPageKey: collection ? `load${capitalizedProp}Page` : 'loadEntitiesPage', - setEntitiesLoadResultKey: collection - ? `set${capitalizedProp}LoadedResult` - : 'setEntitiesLoadedResult', + setEntitiesPagedResultKey: collection + ? `set${capitalizedProp}PagedResult` + : 'setEntitiesPagedResult', }; } export function isEntitiesInCache( 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 index 8cba508c..9c1e909e 100644 --- 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 @@ -58,7 +58,10 @@ export type NamedEntitiesPaginationInfiniteComputed< }; export type EntitiesPaginationInfiniteMethods = EntitiesPaginationLocalMethods & { - setEntitiesLoadedResult: (entities: Entity[], total: number) => void; + setEntitiesPagedResult: (result: { + entities: Entity[]; + total: number; + }) => void; loadEntitiesNextPage: () => void; loadEntitiesPreviousPage: () => void; loadEntitiesFirstPage: () => void; @@ -67,10 +70,10 @@ export type NamedEntitiesPaginationInfiniteMethods< Entity, Collection extends string, > = { - [K in Collection as `set${Capitalize}LoadedResult`]: ( - entities: Entity[], - total: number, - ) => void; + [K in Collection as `set${Capitalize}PagedResult`]: (result: { + entities: Entity[]; + total: number; + }) => void; } & { [K in Collection as `load${Capitalize}NextPage`]: () => 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 index 8fd205ee..3956147f 100644 --- 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 @@ -289,7 +289,7 @@ export function withEntitiesRemoteScrollPagination< loadEntitiesFirstPageKey, loadEntitiesPreviousPageKey, entitiesPageInfoKey, - setEntitiesLoadResultKey, + setEntitiesPagedResultKey, entitiesPagedRequestKey, paginationKey, } = getWithEntitiesInfinitePaginationKeys(config); @@ -377,7 +377,13 @@ export function withEntitiesRemoteScrollPagination< ); }, ), - [setEntitiesLoadResultKey]: (entities: Entity[], total: number) => { + [setEntitiesPagedResultKey]: ({ + entities, + total, + }: { + entities: Entity[]; + total: number; + }) => { patchState( state as StateSignal, config.collection diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts index d06e287c..b3d56af7 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts @@ -27,8 +27,8 @@ export function getWithEntitiesInfinitePaginationKeys(config?: { ? `load ${capitalizedProp}FirstPage` : 'loadEntitiesFirstPage', - setEntitiesLoadResultKey: collection + setEntitiesPagedResultKey: collection ? `set${capitalizedProp}LoadedResult` - : 'setEntitiesLoadedResult', + : 'setEntitiesPagedResult', }; } From e5d694722cfe01d80c29201f40a0b844b71c7cdd Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Wed, 17 Apr 2024 23:24:20 +0100 Subject: [PATCH 4/9] fix: unit test, and small bug fixes found in unit test Missing unit test, and fixed bug in entitiesSelected() of withEntitiesMultiSelection --- .../with-entities-remote-filter.ts | 8 +- .../with-entities-local-pagination.spec.ts | 0 .../with-entities-multi-selection.spec.ts | 120 ++++++++++++++++++ .../with-entities-multi-selection.ts | 12 +- .../with-entities-single-selection.spec.ts | 71 +++++++++++ .../with-entities-single-selection.ts | 6 +- 6 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts 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 526a5e7b..9ed10dd7 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 @@ -107,9 +107,9 @@ import { export function withEntitiesRemoteFilter< Entity extends { id: string | number }, Filter extends Record, ->(options: { +>(config: { defaultFilter: Filter; - entity: Entity; + entity?: Entity; }): SignalStoreFeature< { state: EntityState; @@ -198,9 +198,9 @@ export function withEntitiesRemoteFilter< Entity extends { id: string | number }, Collection extends string, Filter extends Record, ->(options: { +>(config: { defaultFilter: Filter; - entity: Entity; + entity?: Entity; collection?: Collection; }): SignalStoreFeature< { diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts new file mode 100644 index 00000000..7835627f --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts @@ -0,0 +1,120 @@ +import { withEntitiesMultiSelection } from '@ngrx-traits/signals'; +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesMultiSelection', () => { + const entity = type(); + + describe('without collection', () => { + const Store = signalStore( + withEntities({ entity }), + withEntitiesMultiSelection({ entity }), + ); + it('selectEntities should select the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.selectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); + expect(store.entitiesSelected()).toEqual([ + mockProducts[4], + mockProducts[8], + ]); + }); + + it('deselectEntities should deselect the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.selectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); + store.deselectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); + expect(store.entitiesSelected()).toEqual([]); + }); + + it('toggleSelectEntities should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.toggleSelectEntities({ id: mockProducts[4].id }); + expect(store.entitiesSelected()).toEqual([mockProducts[4]]); + store.toggleSelectEntities({ id: mockProducts[4].id }); + expect(store.entitiesSelected()).toEqual([]); + }); + + it('toggleSelectAllEntities should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.toggleSelectAllEntities(); + expect(store.entitiesSelected().length).toEqual(mockProducts.length); + store.toggleSelectAllEntities(); + expect(store.entitiesSelected()).toEqual([]); + }); + + it('isAllEntitiesSelected should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.toggleSelectAllEntities(); + expect(store.isAllEntitiesSelected()).toEqual('all'); + store.toggleSelectAllEntities(); + expect(store.isAllEntitiesSelected()).toEqual('none'); + store.toggleSelectEntities({ ids: mockProducts.map((p) => p.id) }); + expect(store.isAllEntitiesSelected()).toEqual('all'); + store.toggleSelectEntities({ id: mockProducts[4].id }); + expect(store.isAllEntitiesSelected()).toEqual('some'); + }); + }); + + describe('with collection', () => { + const collection = 'products'; + const Store = signalStore( + withEntities({ entity, collection }), + withEntitiesMultiSelection({ entity, collection }), + ); + it('select[Collection]Entities should select the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.selectProductsEntities({ + ids: [mockProducts[4].id, mockProducts[8].id], + }); + expect(store.productsSelectedEntities()).toEqual([ + mockProducts[4], + mockProducts[8], + ]); + }); + + it('deselect[Collection]Entities should deselect the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.selectProductsEntities({ + ids: [mockProducts[4].id, mockProducts[8].id], + }); + store.deselectProductsEntities({ + ids: [mockProducts[4].id, mockProducts[8].id], + }); + expect(store.productsSelectedEntities()).toEqual([]); + }); + + it('toggle[Collection]Entities should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.toggleSelectProductsEntities({ id: mockProducts[4].id }); + expect(store.productsSelectedEntities()).toEqual([mockProducts[4]]); + store.toggleSelectProductsEntities({ id: mockProducts[4].id }); + expect(store.productsSelectedEntities()).toEqual([]); + }); + + it('isAll[Collection]Selected should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.toggleSelectAllProductsEntities(); + expect(store.isAllProductsSelected()).toEqual('all'); + store.toggleSelectAllProductsEntities(); + expect(store.isAllProductsSelected()).toEqual('none'); + store.toggleSelectProductsEntities({ + ids: mockProducts.map((p) => p.id), + }); + expect(store.isAllProductsSelected()).toEqual('all'); + store.toggleSelectProductsEntities({ id: mockProducts[4].id }); + expect(store.isAllProductsSelected()).toEqual('some'); + }); + }); +}); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts index 29ba19e5..83d346c2 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts @@ -194,7 +194,17 @@ export function withEntitiesMultiSelection< const selectedIdsMap = state[selectedIdsMapKey] as Signal< Record >; - const selectedIdsArray = computed(() => Object.keys(selectedIdsMap())); + const selectedIdsArray = computed(() => + Object.entries(selectedIdsMap()).reduce( + (aux, [id, selected]) => { + if (selected) { + aux.push(id); + } + return aux; + }, + [] as (string | number)[], + ), + ); return { [selectedEntitiesKey]: computed(() => { return selectedIdsArray().map((id) => entityMap()[id]); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts new file mode 100644 index 00000000..4bb87599 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts @@ -0,0 +1,71 @@ +import { withEntitiesSingleSelection } from '@ngrx-traits/signals'; +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesSingleSelection', () => { + const entity = type(); + + describe('without collection', () => { + const Store = signalStore( + withEntities({ entity }), + withEntitiesSingleSelection({ entity }), + ); + it('selectEntity should select the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.selectEntity({ id: mockProducts[4].id }); + expect(store.entitiesSelectedEntity()).toEqual(mockProducts[4]); + }); + + it('deselectEntity should deselect the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.selectEntity({ id: mockProducts[4].id }); + store.deselectEntity({ id: mockProducts[4].id }); + expect(store.entitiesSelectedEntity()).toEqual(undefined); + }); + + it('toggleEntity should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.toggleEntity({ id: mockProducts[4].id }); + expect(store.entitiesSelectedEntity()).toEqual(mockProducts[4]); + store.toggleEntity({ id: mockProducts[4].id }); + expect(store.entitiesSelectedEntity()).toEqual(undefined); + }); + }); + + describe('with collection', () => { + const collection = 'products'; + const Store = signalStore( + withEntities({ entity, collection }), + withEntitiesSingleSelection({ entity, collection }), + ); + it('selectEntity should select the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.selectProductsEntity({ id: mockProducts[4].id }); + expect(store.productsSelectedEntity()).toEqual(mockProducts[4]); + }); + + it('deselectEntity should deselect the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.selectProductsEntity({ id: mockProducts[4].id }); + store.deselectProductsEntity({ id: mockProducts[4].id }); + expect(store.productsSelectedEntity()).toEqual(undefined); + }); + + it('toggleEntity should toggle selection of the entity', () => { + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + store.toggleProductsEntity({ id: mockProducts[4].id }); + expect(store.productsSelectedEntity()).toEqual(mockProducts[4]); + store.toggleProductsEntity({ id: mockProducts[4].id }); + expect(store.productsSelectedEntity()).toEqual(undefined); + }); + }); +}); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts index 4f53a8c8..389d635c 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts @@ -80,7 +80,7 @@ function getEntitiesSingleSelectionKeys(config?: { collection?: string }) { export function withEntitiesSingleSelection< Entity extends { id: string | number }, ->(config: { +>(config?: { entity?: Entity; }): SignalStoreFeature< { @@ -127,7 +127,7 @@ export function withEntitiesSingleSelection< export function withEntitiesSingleSelection< Entity extends { id: string | number }, Collection extends string, ->(config: { +>(config?: { entity?: Entity; collection?: Collection; }): SignalStoreFeature< @@ -147,7 +147,7 @@ export function withEntitiesSingleSelection< export function withEntitiesSingleSelection< Entity extends { id: string | number }, Collection extends string, ->(config: { +>(config?: { entity?: Entity; collection?: Collection; }): SignalStoreFeature { From 3dcbad573d5cc51d6a97a9208cdcff77616193d7 Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Thu, 18 Apr 2024 11:52:36 +0100 Subject: [PATCH 5/9] feat: expose idsSelected computed signal in multi selection --- .../with-entities-multi-selection.model.ts | 3 +++ .../with-entities-multi-selection.spec.ts | 10 ++++++++++ .../with-entities-multi-selection.ts | 10 ++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) 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 index 34838dc4..572a1fb7 100644 --- 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 @@ -8,6 +8,7 @@ export type NamedEntitiesMultiSelectionState = { }; export type EntitiesMultiSelectionComputed = { entitiesSelected: Signal; + entitiesSelectedIds: Signal<(string | number)[]>; isAllEntitiesSelected: Signal<'all' | 'none' | 'some'>; }; export type NamedEntitiesMultiSelectionComputed< @@ -15,6 +16,8 @@ export type NamedEntitiesMultiSelectionComputed< Collection extends string, > = { [K in Collection as `${K}SelectedEntities`]: Signal; +} & { + [K in Collection as `${K}SelectedIds`]: Signal; } & { [K in Collection as `isAll${Capitalize}Selected`]: Signal< 'all' | 'none' | 'some' diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts index 7835627f..56746468 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts @@ -17,6 +17,7 @@ describe('withEntitiesMultiSelection', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts)); store.selectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); + expect(store.entitiesSelectedIds()).toEqual(['4', '8']); expect(store.entitiesSelected()).toEqual([ mockProducts[4], mockProducts[8], @@ -28,6 +29,7 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts)); store.selectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); store.deselectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); + expect(store.entitiesSelectedIds()).toEqual([]); expect(store.entitiesSelected()).toEqual([]); }); @@ -36,8 +38,10 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts)); store.toggleSelectEntities({ id: mockProducts[4].id }); expect(store.entitiesSelected()).toEqual([mockProducts[4]]); + expect(store.entitiesSelectedIds()).toEqual(['4']); store.toggleSelectEntities({ id: mockProducts[4].id }); expect(store.entitiesSelected()).toEqual([]); + expect(store.entitiesSelectedIds()).toEqual([]); }); it('toggleSelectAllEntities should toggle selection of the entity', () => { @@ -45,8 +49,10 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts)); store.toggleSelectAllEntities(); expect(store.entitiesSelected().length).toEqual(mockProducts.length); + expect(store.entitiesSelectedIds().length).toEqual(mockProducts.length); store.toggleSelectAllEntities(); expect(store.entitiesSelected()).toEqual([]); + expect(store.entitiesSelectedIds()).toEqual([]); }); it('isAllEntitiesSelected should toggle selection of the entity', () => { @@ -75,6 +81,7 @@ describe('withEntitiesMultiSelection', () => { store.selectProductsEntities({ ids: [mockProducts[4].id, mockProducts[8].id], }); + expect(store.productsSelectedIds()).toEqual(['4', '8']); expect(store.productsSelectedEntities()).toEqual([ mockProducts[4], mockProducts[8], @@ -91,6 +98,7 @@ describe('withEntitiesMultiSelection', () => { ids: [mockProducts[4].id, mockProducts[8].id], }); expect(store.productsSelectedEntities()).toEqual([]); + expect(store.productsSelectedIds()).toEqual([]); }); it('toggle[Collection]Entities should toggle selection of the entity', () => { @@ -98,8 +106,10 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts, { collection })); store.toggleSelectProductsEntities({ id: mockProducts[4].id }); expect(store.productsSelectedEntities()).toEqual([mockProducts[4]]); + expect(store.productsSelectedIds()).toEqual(['4']); store.toggleSelectProductsEntities({ id: mockProducts[4].id }); expect(store.productsSelectedEntities()).toEqual([]); + expect(store.productsSelectedIds()).toEqual([]); }); it('isAll[Collection]Selected should toggle selection of the entity', () => { diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts index 83d346c2..446cfa2e 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts @@ -1,9 +1,10 @@ -import { computed, Signal } from '@angular/core'; +import { computed, effect, Signal } from '@angular/core'; import { patchState, signalStoreFeature, SignalStoreFeature, withComputed, + withHooks, withMethods, withState, } from '@ngrx/signals'; @@ -36,6 +37,9 @@ function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { selectedEntitiesKey: collection ? `${config.collection}SelectedEntities` : 'entitiesSelected', + selectedEntitiesIdsKey: collection + ? `${config.collection}SelectedIds` + : 'entitiesSelectedIds', selectEntitiesKey: collection ? `select${capitalizedProp}Entities` : 'selectEntities', @@ -179,6 +183,7 @@ export function withEntitiesMultiSelection< const { selectedIdsMapKey, selectedEntitiesKey, + selectedEntitiesIdsKey, deselectEntitiesKey, toggleSelectEntitiesKey, clearEntitiesSelectionKey, @@ -197,7 +202,7 @@ export function withEntitiesMultiSelection< const selectedIdsArray = computed(() => Object.entries(selectedIdsMap()).reduce( (aux, [id, selected]) => { - if (selected) { + if (selected && entityMap()[id]) { aux.push(id); } return aux; @@ -206,6 +211,7 @@ export function withEntitiesMultiSelection< ), ); return { + [selectedEntitiesIdsKey]: selectedIdsArray, [selectedEntitiesKey]: computed(() => { return selectedIdsArray().map((id) => entityMap()[id]); }), From 182d7d262227ef6f82e3464c2ed4285123b699dc Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Thu, 18 Apr 2024 12:16:18 +0100 Subject: [PATCH 6/9] refactor: renamed props on single and multi selection traits to align with withEntities --- .../product.store.ts | 4 +- ...list-paginated-page-container.component.ts | 4 +- .../with-entities-multi-selection.model.ts | 10 ++-- .../with-entities-multi-selection.spec.ts | 28 +++++----- .../with-entities-multi-selection.ts | 51 ++++--------------- .../with-entities-multi-selection.util.ts | 37 ++++++++++++++ .../with-entities-single-selection.model.ts | 8 +-- .../with-entities-single-selection.spec.ts | 16 +++--- .../with-entities-single-selection.ts | 31 ++--------- .../with-entities-single-selection.util.ts | 23 +++++++++ 10 files changed, 111 insertions(+), 101 deletions(-) create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.util.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.util.ts diff --git a/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts b/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts index 60d93632..bf49a852 100644 --- a/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts +++ b/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts @@ -70,7 +70,7 @@ export const ProductsRemoteStore = signalStore( ); }, }), - withCalls(({ productsSelectedEntity }) => ({ + withCalls(({ productsEntitySelected }) => ({ loadProductDetail: { call: ({ id }: { id: string }) => inject(ProductService).getProductDetail(id), @@ -78,7 +78,7 @@ export const ProductsRemoteStore = signalStore( }, checkout: () => inject(OrderService).checkout({ - productId: productsSelectedEntity()!.id, + productId: productsEntitySelected()!.id, quantity: 1, }), })), 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 80bdb69e..be4def68 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 @@ -31,7 +31,7 @@ import { ProductsLocalStore } from './product.store';
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 index 572a1fb7..7b3600f6 100644 --- 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 @@ -1,23 +1,23 @@ import { Signal } from '@angular/core'; export type EntitiesMultiSelectionState = { - entitiesSelectedIdsMap: Record; + idsSelectedMap: Record; }; export type NamedEntitiesMultiSelectionState = { - [K in Collection as `${K}SelectedIdsMap`]: Record; + [K in Collection as `${K}IdsSelectedMap`]: Record; }; export type EntitiesMultiSelectionComputed = { entitiesSelected: Signal; - entitiesSelectedIds: Signal<(string | number)[]>; + idsSelected: Signal<(string | number)[]>; isAllEntitiesSelected: Signal<'all' | 'none' | 'some'>; }; export type NamedEntitiesMultiSelectionComputed< Entity, Collection extends string, > = { - [K in Collection as `${K}SelectedEntities`]: Signal; + [K in Collection as `${K}EntitiesSelected`]: Signal; } & { - [K in Collection as `${K}SelectedIds`]: Signal; + [K in Collection as `${K}IdsSelected`]: Signal; } & { [K in Collection as `isAll${Capitalize}Selected`]: Signal< 'all' | 'none' | 'some' diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts index 56746468..890e902d 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts @@ -17,7 +17,7 @@ describe('withEntitiesMultiSelection', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts)); store.selectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); - expect(store.entitiesSelectedIds()).toEqual(['4', '8']); + expect(store.idsSelected()).toEqual(['4', '8']); expect(store.entitiesSelected()).toEqual([ mockProducts[4], mockProducts[8], @@ -29,7 +29,7 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts)); store.selectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); store.deselectEntities({ ids: [mockProducts[4].id, mockProducts[8].id] }); - expect(store.entitiesSelectedIds()).toEqual([]); + expect(store.idsSelected()).toEqual([]); expect(store.entitiesSelected()).toEqual([]); }); @@ -38,10 +38,10 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts)); store.toggleSelectEntities({ id: mockProducts[4].id }); expect(store.entitiesSelected()).toEqual([mockProducts[4]]); - expect(store.entitiesSelectedIds()).toEqual(['4']); + expect(store.idsSelected()).toEqual(['4']); store.toggleSelectEntities({ id: mockProducts[4].id }); expect(store.entitiesSelected()).toEqual([]); - expect(store.entitiesSelectedIds()).toEqual([]); + expect(store.idsSelected()).toEqual([]); }); it('toggleSelectAllEntities should toggle selection of the entity', () => { @@ -49,10 +49,10 @@ describe('withEntitiesMultiSelection', () => { patchState(store, setAllEntities(mockProducts)); store.toggleSelectAllEntities(); expect(store.entitiesSelected().length).toEqual(mockProducts.length); - expect(store.entitiesSelectedIds().length).toEqual(mockProducts.length); + expect(store.idsSelected().length).toEqual(mockProducts.length); store.toggleSelectAllEntities(); expect(store.entitiesSelected()).toEqual([]); - expect(store.entitiesSelectedIds()).toEqual([]); + expect(store.idsSelected()).toEqual([]); }); it('isAllEntitiesSelected should toggle selection of the entity', () => { @@ -81,8 +81,8 @@ describe('withEntitiesMultiSelection', () => { store.selectProductsEntities({ ids: [mockProducts[4].id, mockProducts[8].id], }); - expect(store.productsSelectedIds()).toEqual(['4', '8']); - expect(store.productsSelectedEntities()).toEqual([ + expect(store.productsIdsSelected()).toEqual(['4', '8']); + expect(store.productsEntitiesSelected()).toEqual([ mockProducts[4], mockProducts[8], ]); @@ -97,19 +97,19 @@ describe('withEntitiesMultiSelection', () => { store.deselectProductsEntities({ ids: [mockProducts[4].id, mockProducts[8].id], }); - expect(store.productsSelectedEntities()).toEqual([]); - expect(store.productsSelectedIds()).toEqual([]); + expect(store.productsEntitiesSelected()).toEqual([]); + expect(store.productsIdsSelected()).toEqual([]); }); it('toggle[Collection]Entities should toggle selection of the entity', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts, { collection })); store.toggleSelectProductsEntities({ id: mockProducts[4].id }); - expect(store.productsSelectedEntities()).toEqual([mockProducts[4]]); - expect(store.productsSelectedIds()).toEqual(['4']); + expect(store.productsEntitiesSelected()).toEqual([mockProducts[4]]); + expect(store.productsIdsSelected()).toEqual(['4']); store.toggleSelectProductsEntities({ id: mockProducts[4].id }); - expect(store.productsSelectedEntities()).toEqual([]); - expect(store.productsSelectedIds()).toEqual([]); + expect(store.productsEntitiesSelected()).toEqual([]); + expect(store.productsIdsSelected()).toEqual([]); }); it('isAll[Collection]Selected should toggle selection of the entity', () => { diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts index 446cfa2e..d0b65bee 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts @@ -26,43 +26,12 @@ import { NamedEntitiesMultiSelectionMethods, NamedEntitiesMultiSelectionState, } from './with-entities-multi-selection.model'; - -function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { - const collection = config?.collection; - const capitalizedProp = collection && capitalize(collection); - return { - selectedIdsMapKey: collection - ? `${config.collection}SelectedIdsMap` - : 'entitiesSelectedIdsMap', - selectedEntitiesKey: collection - ? `${config.collection}SelectedEntities` - : 'entitiesSelected', - selectedEntitiesIdsKey: collection - ? `${config.collection}SelectedIds` - : 'entitiesSelectedIds', - selectEntitiesKey: collection - ? `select${capitalizedProp}Entities` - : 'selectEntities', - deselectEntitiesKey: collection - ? `deselect${capitalizedProp}Entities` - : 'deselectEntities', - toggleSelectEntitiesKey: collection - ? `toggleSelect${capitalizedProp}Entities` - : 'toggleSelectEntities', - toggleSelectAllEntitiesKey: collection - ? `toggleSelectAll${capitalizedProp}Entities` - : 'toggleSelectAllEntities', - clearEntitiesSelectionKey: collection - ? `clear${capitalizedProp}Selection` - : 'clearEntitiesSelection', - isAllEntitiesSelectedKey: collection - ? `isAll${capitalizedProp}Selected` - : 'isAllEntitiesSelected', - }; -} +import { getEntitiesMultiSelectionKeys } from './with-entities-multi-selection.util'; /** - * Generates state, signals and methods for multi selection of entities + * Generates state, signals and methods for multi selection of entities. + * Warning: isAll[Collection]Selected and toggleSelectAll[Collection] wont work + * correctly in using remote pagination, because they cant select all the data * @param config * @param config.entity - the entity type * @param config.collection - the collection name @@ -77,9 +46,9 @@ function getEntitiesMultiSelectionKeys(config?: { collection?: string }) { * ); * * // generates the following signals - * store.productsSelectedIdsMap // Record; + * store.productsIdsSelectedMap // Record; * // generates the following computed signals - * store.productsSelectedEntities // Entity[]; + * store.productsEntitiesSelected // Entity[]; * store.isAllProductsSelected // 'all' | 'none' | 'some'; * // generates the following methods * store.selectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; @@ -105,7 +74,9 @@ export function withEntitiesMultiSelection< >; /** - * Generates state, signals and methods for multi selection of entities + * Generates state, signals and methods for multi selection of entities. + * Warning: isAll[Collection]Selected and toggleSelectAll[Collection] wont work + * correctly in using remote pagination, because they cant select all the data * @param config * @param config.entity - the entity type * @param config.collection - the collection name @@ -120,9 +91,9 @@ export function withEntitiesMultiSelection< * ); * * // generates the following signals - * store.productsSelectedIdsMap // Record; + * store.productsIdsSelectedMap // Record; * // generates the following computed signals - * store.productsSelectedEntities // Entity[]; + * store.productsEntitiesSelected // Entity[]; * store.isAllProductsSelected // 'all' | 'none' | 'some'; * // generates the following methods * store.selectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.util.ts new file mode 100644 index 00000000..fc70e3fa --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.util.ts @@ -0,0 +1,37 @@ +import { capitalize } from '../util'; + +export function getEntitiesMultiSelectionKeys(config?: { + collection?: string; +}) { + const collection = config?.collection; + const capitalizedProp = collection && capitalize(collection); + return { + selectedIdsMapKey: collection + ? `${config.collection}IdsSelectedMap` + : 'idsSelectedMap', + selectedEntitiesKey: collection + ? `${config.collection}EntitiesSelected` + : 'entitiesSelected', + selectedEntitiesIdsKey: collection + ? `${config.collection}IdsSelected` + : 'idsSelected', + selectEntitiesKey: collection + ? `select${capitalizedProp}Entities` + : 'selectEntities', + deselectEntitiesKey: collection + ? `deselect${capitalizedProp}Entities` + : 'deselectEntities', + toggleSelectEntitiesKey: collection + ? `toggleSelect${capitalizedProp}Entities` + : 'toggleSelectEntities', + toggleSelectAllEntitiesKey: collection + ? `toggleSelectAll${capitalizedProp}Entities` + : 'toggleSelectAllEntities', + clearEntitiesSelectionKey: collection + ? `clear${capitalizedProp}Selection` + : 'clearEntitiesSelection', + isAllEntitiesSelectedKey: collection + ? `isAll${capitalizedProp}Selected` + : 'isAllEntitiesSelected', + }; +} 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 index 49ca3bc0..51720b69 100644 --- 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 @@ -1,19 +1,19 @@ import { Signal } from '@angular/core'; export type EntitiesSingleSelectionState = { - entitiesSelectedId?: string | number; + idSelected?: string | number; }; export type NamedEntitiesSingleSelectionState = { - [K in Collection as `${K}SelectedId`]?: string | number; + [K in Collection as `${K}IdSelected`]?: string | number; }; export type EntitiesSingleSelectionComputed = { - entitiesSelectedEntity: Signal; + entitySelected: Signal; }; export type NamedEntitiesSingleSelectionComputed< Entity, Collection extends string, > = { - [K in Collection as `${K}SelectedEntity`]: Signal; + [K in Collection as `${K}EntitySelected`]: Signal; }; export type EntitiesSingleSelectionMethods = { selectEntity: (options: { id: string | number }) => void; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts index 4bb87599..c824da89 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts @@ -17,7 +17,7 @@ describe('withEntitiesSingleSelection', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts)); store.selectEntity({ id: mockProducts[4].id }); - expect(store.entitiesSelectedEntity()).toEqual(mockProducts[4]); + expect(store.entitySelected()).toEqual(mockProducts[4]); }); it('deselectEntity should deselect the entity', () => { @@ -25,16 +25,16 @@ describe('withEntitiesSingleSelection', () => { patchState(store, setAllEntities(mockProducts)); store.selectEntity({ id: mockProducts[4].id }); store.deselectEntity({ id: mockProducts[4].id }); - expect(store.entitiesSelectedEntity()).toEqual(undefined); + expect(store.entitySelected()).toEqual(undefined); }); it('toggleEntity should toggle selection of the entity', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts)); store.toggleEntity({ id: mockProducts[4].id }); - expect(store.entitiesSelectedEntity()).toEqual(mockProducts[4]); + expect(store.entitySelected()).toEqual(mockProducts[4]); store.toggleEntity({ id: mockProducts[4].id }); - expect(store.entitiesSelectedEntity()).toEqual(undefined); + expect(store.entitySelected()).toEqual(undefined); }); }); @@ -48,7 +48,7 @@ describe('withEntitiesSingleSelection', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts, { collection })); store.selectProductsEntity({ id: mockProducts[4].id }); - expect(store.productsSelectedEntity()).toEqual(mockProducts[4]); + expect(store.productsEntitySelected()).toEqual(mockProducts[4]); }); it('deselectEntity should deselect the entity', () => { @@ -56,16 +56,16 @@ describe('withEntitiesSingleSelection', () => { patchState(store, setAllEntities(mockProducts, { collection })); store.selectProductsEntity({ id: mockProducts[4].id }); store.deselectProductsEntity({ id: mockProducts[4].id }); - expect(store.productsSelectedEntity()).toEqual(undefined); + expect(store.productsEntitySelected()).toEqual(undefined); }); it('toggleEntity should toggle selection of the entity', () => { const store = new Store(); patchState(store, setAllEntities(mockProducts, { collection })); store.toggleProductsEntity({ id: mockProducts[4].id }); - expect(store.productsSelectedEntity()).toEqual(mockProducts[4]); + expect(store.productsEntitySelected()).toEqual(mockProducts[4]); store.toggleProductsEntity({ id: mockProducts[4].id }); - expect(store.productsSelectedEntity()).toEqual(undefined); + expect(store.productsEntitySelected()).toEqual(undefined); }); }); }); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts index 389d635c..1b7b6b0e 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts @@ -24,28 +24,7 @@ import { 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` - : 'entitiesSelectedId', - selectedEntityKey: collection - ? `${config.collection}SelectedEntity` - : 'entitiesSelectedEntity', - selectEntityKey: collection - ? `select${capitalizedProp}Entity` - : 'selectEntity', - deselectEntityKey: collection - ? `deselect${capitalizedProp}Entity` - : 'deselectEntity', - toggleEntityKey: collection - ? `toggle${capitalizedProp}Entity` - : 'toggleEntity', - }; -} +import { getEntitiesSingleSelectionKeys } from './with-entities-single-selection.util'; /** * Generates state, computed and methods for single selection of entities. Requires withEntities to be present before this function. @@ -69,9 +48,9 @@ function getEntitiesSingleSelectionKeys(config?: { collection?: string }) { * ); * * // generates the following signals - * store.productsSelectedId // string | number | undefined + * store.productsIdSelected // string | number | undefined * // generates the following computed signals - * store.productsSelectedEntity // Entity | undefined + * store.productsEntitySelected // Entity | undefined * // generates the following methods * store.selectProductEntity // (config: { id: string | number }) => void * store.deselectProductEntity // (config: { id: string | number }) => void @@ -116,9 +95,9 @@ export function withEntitiesSingleSelection< * ); * * // generates the following signals - * store.productsSelectedId // string | number | undefined + * store.productsIdSelected // string | number | undefined * // generates the following computed signals - * store.productsSelectedEntity // Entity | undefined + * store.productsEntitySelected // Entity | undefined * // generates the following methods * store.selectProductEntity // (config: { id: string | number }) => void * store.deselectProductEntity // (config: { id: string | number }) => void diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.util.ts new file mode 100644 index 00000000..336df524 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.util.ts @@ -0,0 +1,23 @@ +import { capitalize } from '../util'; + +export function getEntitiesSingleSelectionKeys(config?: { + collection?: string; +}) { + const collection = config?.collection; + const capitalizedProp = collection && capitalize(collection); + return { + selectedIdKey: collection ? `${config.collection}IdSelected` : 'idSelected', + selectedEntityKey: collection + ? `${config.collection}EntitySelected` + : 'entitySelected', + selectEntityKey: collection + ? `select${capitalizedProp}Entity` + : 'selectEntity', + deselectEntityKey: collection + ? `deselect${capitalizedProp}Entity` + : 'deselectEntity', + toggleEntityKey: collection + ? `toggle${capitalizedProp}Entity` + : 'toggleEntity', + }; +} From d9d739486cc7b9bd12da090f92f7686a2095b705 Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Fri, 19 Apr 2024 11:21:39 +0100 Subject: [PATCH 7/9] test: added missed unit test to pagination, filter, sort, sync to web storage, and enttites loading call traits --- .../products-branch-dropdown.component.ts | 1 - .../products-branch.store.ts | 8 +- .../ngrx-traits/signals/src/lib/test.mocks.ts | 3 +- .../with-call-status/with-call-status.spec.ts | 3 +- .../src/lib/with-calls/with-calls.spec.ts | 3 +- .../with-entities-local-filter.spec.ts | 16 +- .../with-entities-remote-filter.spec.ts | 16 +- .../with-entities-loading-call.spec.ts | 11 +- .../signal-infinite-datasource.ts | 27 +- .../with-entities-local-pagination.spec.ts | 175 ++++++++ .../with-entities-remote-pagination.spec.ts | 408 ++++++++++++++++++ ...entities-remote-scroll-pagination.model.ts | 75 +--- ...-entities-remote-scroll-pagination.spec.ts | 240 +++++++++++ .../with-entities-remote-scroll-pagination.ts | 180 +++----- ...-entities-remote-scroll-pagination.util.ts | 32 +- .../with-entities-multi-selection.spec.ts | 2 +- .../with-entities-multi-selection.ts | 5 +- .../with-entities-single-selection.spec.ts | 2 +- .../with-entities-single-selection.ts | 2 +- .../with-entities-local-sort.model.ts | 4 +- .../with-entities-local-sort.spec.ts | 101 +++++ .../with-entities-local-sort.ts | 20 +- .../with-entities-remote-sort.spec.ts | 141 ++++++ .../with-sync-to-web-storage.spec.ts | 120 ++++++ .../with-sync-to-web-storage.ts | 4 + libs/ngrx-traits/signals/src/test-setup.ts | 3 +- package.json | 2 +- 27 files changed, 1357 insertions(+), 247 deletions(-) create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.spec.ts create mode 100644 libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.spec.ts 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 index 621a12c4..61d4d2c2 100644 --- 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 @@ -1,6 +1,5 @@ import { CdkFixedSizeVirtualScroll, - CdkScrollableModule, CdkVirtualForOf, CdkVirtualScrollViewport, } from '@angular/cdk/scrolling'; 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 index 1da0ce7f..3fed5e5b 100644 --- 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 @@ -24,16 +24,16 @@ export const ProductsBranchStore = signalStore( defaultFilter: { search: '' }, }), withEntitiesRemoteScrollPagination({ - pageSize: 10, + bufferSize: 30, entity, }), withEntitiesLoadingCall({ - fetchEntities: async ({ entitiesPagedRequest, entitiesFilter }) => { + fetchEntities: async ({ entitiesRequest, entitiesFilter }) => { const res = await lastValueFrom( inject(BranchService).getBranches({ search: entitiesFilter().search, - skip: entitiesPagedRequest().startIndex, - take: entitiesPagedRequest().size, + skip: entitiesRequest().startIndex, + take: entitiesRequest().size, }), ); return { entities: res.resultList, total: res.total }; diff --git a/libs/ngrx-traits/signals/src/lib/test.mocks.ts b/libs/ngrx-traits/signals/src/lib/test.mocks.ts index c6b340e8..ccfcf80b 100644 --- a/libs/ngrx-traits/signals/src/lib/test.mocks.ts +++ b/libs/ngrx-traits/signals/src/lib/test.mocks.ts @@ -1,5 +1,4 @@ -import { Product } from '../../../../../apps/example-app/src/app/examples/models'; -import { getRandomInteger } from '../../../../../apps/example-app/src/app/examples/utils/form-utils'; +import { Product } from './test.model'; const snes = [ 'Super Mario World', diff --git a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts index bb515188..22a3b84c 100644 --- a/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-call-status/with-call-status.spec.ts @@ -1,6 +1,7 @@ -import { withCallStatus } from '@ngrx-traits/signals'; import { signalStore } from '@ngrx/signals'; +import { withCallStatus } from '../index'; + describe('withCallStatus', () => { const Store = signalStore(withCallStatus()); diff --git a/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts index cbca4504..2a95d3e4 100644 --- a/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-calls/with-calls.spec.ts @@ -1,8 +1,9 @@ import { TestBed } from '@angular/core/testing'; -import { withCalls } from '@ngrx-traits/signals'; import { signalStore, withState } from '@ngrx/signals'; import { Subject, throwError } from 'rxjs'; +import { withCalls } from '../index'; + describe('withCalls', () => { const apiResponse = new Subject(); const Store = signalStore( diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts index d0e2092f..4c312bcd 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-local-filter.spec.ts @@ -1,13 +1,13 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + import { withEntitiesLocalFilter, withEntitiesLocalPagination, withEntitiesMultiSelection, withEntitiesSingleSelection, -} from '@ngrx-traits/signals'; -import { patchState, signalStore, type } from '@ngrx/signals'; -import { setAllEntities, withEntities } from '@ngrx/signals/entities'; - +} from '../index'; import { mockProducts } from '../test.mocks'; import { Product } from '../test.model'; @@ -55,7 +55,7 @@ describe('withEntitiesLocalFilter', () => { }); })); - it('should filter entities after provide debounce', fakeAsync(() => { + it('should filter entities after debounce', fakeAsync(() => { TestBed.runInInjectionContext(() => { const store = new Store(); patchState(store, setAllEntities(mockProducts)); @@ -99,7 +99,7 @@ describe('withEntitiesLocalFilter', () => { }); })); - it(' should resetPage to and selection when when filter is executed', fakeAsync(() => { + it(' should resetPage to and selection when filter is executed', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( withEntities({ @@ -121,7 +121,7 @@ describe('withEntitiesLocalFilter', () => { store.selectEntity({ id: mockProducts[0].id }); store.selectEntities({ ids: [mockProducts[2].id, mockProducts[3].id] }); store.loadEntitiesPage({ pageIndex: 3 }); - expect(store.entitiesSelectedEntity()).toEqual(mockProducts[0]); + expect(store.entitySelected()).toEqual(mockProducts[0]); expect(store.entitiesSelected?.()).toEqual([ mockProducts[2], mockProducts[3], @@ -134,7 +134,7 @@ describe('withEntitiesLocalFilter', () => { }); tick(400); // check selection and page reset - expect(store.entitiesSelectedEntity()).toEqual(undefined); + expect(store.entitySelected()).toEqual(undefined); expect(store.entitiesSelected()).toEqual([]); expect(store.entitiesCurrentPage().pageIndex).toEqual(0); // check filter diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts index fe780973..4b1c34c6 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-filter/with-entities-remote-filter.spec.ts @@ -1,4 +1,8 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { of } from 'rxjs'; + import { withCallStatus, withEntitiesLoadingCall, @@ -6,11 +10,7 @@ import { withEntitiesRemoteFilter, withEntitiesRemotePagination, withEntitiesSingleSelection, -} from '@ngrx-traits/signals'; -import { signalStore, type } from '@ngrx/signals'; -import { withEntities } from '@ngrx/signals/entities'; -import { of } from 'rxjs'; - +} from '../index'; import { mockProducts } from '../test.mocks'; import { Product } from '../test.model'; @@ -116,7 +116,7 @@ describe('withEntitiesRemoteFilter', () => { }); })); - it(' should resetPage to and selection when when filter is executed', fakeAsync(() => { + it(' should resetPage to and selection when filter is executed', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( withEntities({ @@ -151,7 +151,7 @@ describe('withEntitiesRemoteFilter', () => { store.selectEntity({ id: mockProducts[0].id }); store.selectEntities({ ids: [mockProducts[2].id, mockProducts[3].id] }); store.loadEntitiesPage({ pageIndex: 3 }); - expect(store.entitiesSelectedEntity()).toEqual(mockProducts[0]); + expect(store.entitySelected()).toEqual(mockProducts[0]); expect(store.entitiesSelected?.()).toEqual([ mockProducts[2], mockProducts[3], @@ -164,7 +164,7 @@ describe('withEntitiesRemoteFilter', () => { }); tick(400); // check selection and page reset - expect(store.entitiesSelectedEntity()).toEqual(undefined); + expect(store.entitySelected()).toEqual(undefined); expect(store.entitiesSelected()).toEqual([]); expect(store.entitiesCurrentPage().pageIndex).toEqual(0); // check filter diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts index e5026eb8..a8e422b9 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts @@ -1,14 +1,13 @@ -import { runInInjectionContext } from '@angular/core'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { - withCallStatus, - withEntitiesLoadingCall, - withEntitiesRemotePagination, -} from '@ngrx-traits/signals'; import { signalStore, type } from '@ngrx/signals'; import { withEntities } from '@ngrx/signals/entities'; import { of } from 'rxjs'; +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesRemotePagination, +} from '../index'; import { mockProducts } from '../test.mocks'; import { Product } from '../test.model'; 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 index 3b9bf902..7dbc7d60 100644 --- 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 @@ -9,35 +9,36 @@ import { Observable, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; import { getWithEntitiesKeys } from '../util'; -import { InfinitePaginationState } from './with-entities-remote-scroll-pagination.model'; import { - EntitiesPaginationInfiniteMethods, - NamedEntitiesPaginationInfiniteMethods, + EntitiesScrollPaginationMethods, + NamedEntitiesScrollPaginationMethods, + ScrollPaginationState, } from './with-entities-remote-scroll-pagination.model'; import { getWithEntitiesInfinitePaginationKeys } from './with-entities-remote-scroll-pagination.util'; export function getInfiniteScrollDataSource( options: | { - store: EntitySignals & - EntitiesPaginationInfiniteMethods; + store: EntitySignals & EntitiesScrollPaginationMethods; } | { collection: Collection; store: NamedEntitySignals & - NamedEntitiesPaginationInfiniteMethods; + NamedEntitiesScrollPaginationMethods; }, ) { const collection = 'collection' in options ? options.collection : undefined; - const { loadEntitiesNextPageKey, paginationKey } = + const { loadMoreEntitiesKey, entitiesScrollCacheKey } = 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; + const entitiesScrollCache = store[ + entitiesScrollCacheKey + ] as Signal; + const loadMoreEntities = store[loadMoreEntitiesKey] as () => void; class MyDataSource extends DataSource { subscription?: Subscription; @@ -46,15 +47,17 @@ export function getInfiniteScrollDataSource( this.subscription = collectionViewer.viewChange .pipe( filter(({ end, start }) => { - const { pageSize, total, cache } = pagination(); + const { bufferSize, total } = entitiesScrollCache(); // 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; + return ( + start != 0 && end <= total! && end + bufferSize >= entities.length + ); }), ) .subscribe(() => { - loadEntitiesNextPage(); + loadMoreEntities(); }); return this.entitiesList; } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts index e69de29b..ee244d3a 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-local-pagination.spec.ts @@ -0,0 +1,175 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + +import { + withEntitiesLocalFilter, + withEntitiesLocalPagination, + withEntitiesLocalSort, +} from '../index'; +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesLocalPagination', () => { + const entity = type(); + + it('entitiesCurrentPage should split entities in the correct pages', () => { + const Store = signalStore( + withEntities({ entity }), + withEntitiesLocalPagination({ entity, pageSize: 10 }), + ); + + const store = new Store(); + patchState(store, setAllEntities(mockProducts.slice(0, 25))); + // check the first page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(0, 10), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(3); + expect(store.entitiesCurrentPage().total).toEqual(25); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(false); + expect(store.entitiesCurrentPage().hasNext).toEqual(true); + + store.loadEntitiesPage({ pageIndex: 1 }); + // check the second page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(10, 20), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(1); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(3); + expect(store.entitiesCurrentPage().total).toEqual(25); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(true); + expect(store.entitiesCurrentPage().hasNext).toEqual(true); + + store.loadEntitiesPage({ pageIndex: 2 }); + + // check the third page + expect(store.entitiesCurrentPage().entities.length).toEqual(5); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(20, 25), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(2); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(3); + expect(store.entitiesCurrentPage().total).toEqual(25); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(true); + expect(store.entitiesCurrentPage().hasNext).toEqual(false); + }); + + it('with collection entitiesCurrentPage should split entities in the correct pages', () => { + const collection = 'products'; + const Store = signalStore( + withEntities({ entity, collection }), + withEntitiesLocalPagination({ entity, collection, pageSize: 10 }), + ); + + const store = new Store(); + patchState( + store, + setAllEntities(mockProducts.slice(0, 25), { collection }), + ); + // check the first page + expect(store.productsCurrentPage().entities.length).toEqual(10); + expect(store.productsCurrentPage().entities).toEqual( + mockProducts.slice(0, 10), + ); + expect(store.productsCurrentPage().pageIndex).toEqual(0); + expect(store.productsCurrentPage().pageSize).toEqual(10); + expect(store.productsCurrentPage().pagesCount).toEqual(3); + expect(store.productsCurrentPage().total).toEqual(25); + expect(store.productsCurrentPage().hasPrevious).toEqual(false); + expect(store.productsCurrentPage().hasNext).toEqual(true); + + store.loadProductsPage({ pageIndex: 1 }); + // check the second page + expect(store.productsCurrentPage().entities.length).toEqual(10); + expect(store.productsCurrentPage().entities).toEqual( + mockProducts.slice(10, 20), + ); + expect(store.productsCurrentPage().pageIndex).toEqual(1); + expect(store.productsCurrentPage().pageSize).toEqual(10); + expect(store.productsCurrentPage().pagesCount).toEqual(3); + expect(store.productsCurrentPage().total).toEqual(25); + expect(store.productsCurrentPage().hasPrevious).toEqual(true); + expect(store.productsCurrentPage().hasNext).toEqual(true); + + store.loadProductsPage({ pageIndex: 2 }); + + // check the third page + expect(store.productsCurrentPage().entities.length).toEqual(5); + expect(store.productsCurrentPage().entities).toEqual( + mockProducts.slice(20, 25), + ); + expect(store.productsCurrentPage().pageIndex).toEqual(2); + expect(store.productsCurrentPage().pageSize).toEqual(10); + expect(store.productsCurrentPage().pagesCount).toEqual(3); + expect(store.productsCurrentPage().total).toEqual(25); + expect(store.productsCurrentPage().hasPrevious).toEqual(true); + expect(store.productsCurrentPage().hasNext).toEqual(false); + }); + + it('should resetPage when filter is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withEntitiesLocalPagination({ entity }), + withEntitiesLocalFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + filterFn: (entity, filter) => + !filter?.search || + entity?.name.toLowerCase().includes(filter?.search.toLowerCase()), + }), + ); + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.loadEntitiesPage({ pageIndex: 3 }); + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + // check filter + expect(store.entities().length).toEqual(2); + }); + })); + + // TODO fix this test when refactor to use withEventHandler + xit('should resetPage when sort is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withEntitiesLocalPagination({ entity }), + withEntitiesLocalSort({ + entity, + defaultSort: { field: 'id', direction: 'asc' }, + }), + ); + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + store.loadEntitiesPage({ pageIndex: 3 }); + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + + store.sortEntities({ + sort: { + field: 'name', + direction: 'desc', + }, + }); + tick(400); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + }); + })); +}); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.spec.ts new file mode 100644 index 00000000..21189487 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-pagination.spec.ts @@ -0,0 +1,408 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { of } from 'rxjs'; + +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesRemoteFilter, + withEntitiesRemotePagination, + withEntitiesRemoteSort, +} from '../index'; +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; +import { sortData } from '../with-entities-sort/with-entities-sort.utils'; + +describe('withEntitiesRemotePagination', () => { + const entity = type(); + + it('entitiesCurrentPage should split entities in the correct pages', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withEntitiesRemotePagination({ entity, pageSize: 10 }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesPagedRequest }) => { + let result = [...mockProducts.slice(0, 25)]; + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + // check the first page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(0, 10), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(3); + expect(store.entitiesCurrentPage().total).toEqual(25); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(false); + expect(store.entitiesCurrentPage().hasNext).toEqual(true); + + store.loadEntitiesPage({ pageIndex: 1 }); + // check the second page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(10, 20), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(1); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(3); + expect(store.entitiesCurrentPage().total).toEqual(25); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(true); + expect(store.entitiesCurrentPage().hasNext).toEqual(true); + + store.loadEntitiesPage({ pageIndex: 2 }); + + // check the third page + expect(store.entitiesCurrentPage().entities.length).toEqual(5); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(20, 25), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(2); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(3); + expect(store.entitiesCurrentPage().total).toEqual(25); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(true); + expect(store.entitiesCurrentPage().hasNext).toEqual(false); + }); + })); + + it('with collection entitiesCurrentPage should split entities in the correct pages', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const collection = 'products'; + const Store = signalStore( + withEntities({ entity, collection }), + withCallStatus({ collection }), + withEntitiesRemotePagination({ entity, collection, pageSize: 10 }), + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsPagedRequest }) => { + let result = [...mockProducts.slice(0, 25)]; + const total = result.length; + const options = { + skip: productsPagedRequest()?.startIndex, + take: productsPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + + // check the first page + expect(store.productsCurrentPage().entities.length).toEqual(10); + expect(store.productsCurrentPage().entities).toEqual( + mockProducts.slice(0, 10), + ); + expect(store.productsCurrentPage().pageIndex).toEqual(0); + expect(store.productsCurrentPage().pageSize).toEqual(10); + expect(store.productsCurrentPage().pagesCount).toEqual(3); + expect(store.productsCurrentPage().total).toEqual(25); + expect(store.productsCurrentPage().hasPrevious).toEqual(false); + expect(store.productsCurrentPage().hasNext).toEqual(true); + + store.loadProductsPage({ pageIndex: 1 }); + // check the second page + expect(store.productsCurrentPage().entities.length).toEqual(10); + expect(store.productsCurrentPage().entities).toEqual( + mockProducts.slice(10, 20), + ); + expect(store.productsCurrentPage().pageIndex).toEqual(1); + expect(store.productsCurrentPage().pageSize).toEqual(10); + expect(store.productsCurrentPage().pagesCount).toEqual(3); + expect(store.productsCurrentPage().total).toEqual(25); + expect(store.productsCurrentPage().hasPrevious).toEqual(true); + expect(store.productsCurrentPage().hasNext).toEqual(true); + + store.loadProductsPage({ pageIndex: 2 }); + + // check the third page + expect(store.productsCurrentPage().entities.length).toEqual(5); + expect(store.productsCurrentPage().entities).toEqual( + mockProducts.slice(20, 25), + ); + expect(store.productsCurrentPage().pageIndex).toEqual(2); + expect(store.productsCurrentPage().pageSize).toEqual(10); + expect(store.productsCurrentPage().pagesCount).toEqual(3); + expect(store.productsCurrentPage().total).toEqual(25); + expect(store.productsCurrentPage().hasPrevious).toEqual(true); + expect(store.productsCurrentPage().hasNext).toEqual(false); + }); + })); + + describe('loadEntitiesPage', () => { + it('test when a requested page is not cache gets loaded', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const fetchEntitiesSpy = jest.fn(); + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withEntitiesRemotePagination({ entity, pageSize: 10 }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesPagedRequest }) => { + fetchEntitiesSpy(entitiesPagedRequest()); + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + jest.spyOn(store, 'setLoading'); + tick(); + // basic check for the first page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + + // load a page not in cache + store.loadEntitiesPage({ pageIndex: 7 }); + tick(); + expect(fetchEntitiesSpy).toHaveBeenCalledWith({ + startIndex: 70, + size: 30, + page: 7, + }); + // check the page + + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(70, 80), + ); + expect(store.entitiesCurrentPage().pageIndex).toEqual(7); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(13); + expect(store.entitiesCurrentPage().total).toEqual(mockProducts.length); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(true); + expect(store.entitiesCurrentPage().hasNext).toEqual(true); + }); + })); + + it('test when last page of cache gets loaded more pages are requested', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const fetchEntitiesSpy = jest.fn(); + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withEntitiesRemotePagination({ + entity, + pageSize: 10, + pagesToCache: 3, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesPagedRequest }) => { + fetchEntitiesSpy(entitiesPagedRequest()); + let result = [...mockProducts]; + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + // basic check the first page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + // load next cached page + store.loadEntitiesPage({ pageIndex: 1 }); + tick(); + // basic check the second page + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().pageIndex).toEqual(1); + // load load last page in cache + store.loadEntitiesPage({ pageIndex: 2 }); + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().pageIndex).toEqual(2); + tick(); + + expect(fetchEntitiesSpy).toHaveBeenCalledTimes(2); + // check preload next pages call + expect(fetchEntitiesSpy).toHaveBeenCalledWith({ + startIndex: 30, + size: 30, + page: 3, + }); + + //load next page + store.loadEntitiesPage({ pageIndex: 3 }); + tick(); + // only two calls should have been made the initial and the preload next pages + expect(fetchEntitiesSpy).toHaveBeenCalledTimes(2); + // check the page + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + expect(store.entitiesCurrentPage().entities.length).toEqual(10); + expect(store.entitiesCurrentPage().entities).toEqual( + mockProducts.slice(30, 40), + ); + expect(store.entitiesCurrentPage().pageSize).toEqual(10); + expect(store.entitiesCurrentPage().pagesCount).toEqual(13); + expect(store.entitiesCurrentPage().total).toEqual(mockProducts.length); + expect(store.entitiesCurrentPage().hasPrevious).toEqual(true); + expect(store.entitiesCurrentPage().hasNext).toEqual(true); + }); + })); + }); + + it('should resetPage when filter is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemotePagination({ entity }), + withEntitiesRemoteFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesFilter, entitiesPagedRequest }) => { + let result = [...mockProducts]; + if (entitiesFilter()?.search) + result = mockProducts.filter((entity) => + entitiesFilter()?.search + ? entity.name + .toLowerCase() + .includes(entitiesFilter()?.search.toLowerCase()) + : false, + ); + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return Promise.resolve({ entities: result, total }); + return Promise.resolve({ entities: result, total: result.length }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + tick(400); + store.loadEntitiesPage({ pageIndex: 3 }); + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + // check page reset + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + expect(store.entities().length).toEqual(2); + }); + })); + + it('should resetPage when sort is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemotePagination({ entity }), + withEntitiesRemoteSort({ + entity, + defaultSort: { field: 'id', direction: 'asc' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesSort, entitiesPagedRequest }) => { + let result = [...mockProducts]; + if (entitiesSort()?.field) { + result = sortData(result, { + field: entitiesSort()?.field as any, + direction: entitiesSort().direction, + }); + } + const total = result.length; + const options = { + skip: entitiesPagedRequest()?.startIndex, + take: entitiesPagedRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return Promise.resolve({ entities: result, total }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + tick(400); + store.loadEntitiesPage({ pageIndex: 3 }); + expect(store.entitiesCurrentPage().pageIndex).toEqual(3); + + store.sortEntities({ + sort: { + field: 'name', + direction: 'desc', + }, + }); + tick(400); + // check page reset + expect(store.entitiesCurrentPage().pageIndex).toEqual(0); + expect(store.entities().length).toEqual(30); + }); + })); +}); 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 index 9c1e909e..5bf5d008 100644 --- 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 @@ -1,72 +1,39 @@ import { Signal } from '@angular/core'; -import { EntitiesPaginationLocalMethods } from './with-entities-local-pagination.model'; - -export type InfinitePaginationState = { - currentPage: number; - requestPage: number; - pageSize: number; +export type ScrollPaginationState = { + bufferSize: number; total: number | undefined; - pagesToCache: number; - cache: { - start: number; - end: number; - }; + hasMore: boolean; }; -export type EntitiesPaginationInfiniteState = { - entitiesPagination: InfinitePaginationState; +export type EntitiesScrollPaginationState = { + entitiesScrollCache: ScrollPaginationState; }; -export type NamedEntitiesPaginationInfiniteState = { - [K in Collection as `${K}Pagination`]: InfinitePaginationState; +export type NamedEntitiesScrollPaginationState = { + [K in Collection as `${K}ScrollCache`]: ScrollPaginationState; }; -export type EntitiesPaginationInfiniteComputed = { - entitiesPageInfo: Signal<{ - pageIndex: number; - total: number | undefined; - pageSize: number; - pagesCount: number | undefined; - hasPrevious: boolean; - hasNext: boolean; - isLoading: boolean; - }>; - entitiesPagedRequest: Signal<{ +export type EntitiesScrollPaginationComputed = { + entitiesRequest: Signal<{ startIndex: number; size: number; - page: number; }>; }; -export type NamedEntitiesPaginationInfiniteComputed< +export type NamedEntitiesScrollPaginationComputed< Entity, Collection extends string, > = { - [K in Collection as `${K}PagedRequest`]: Signal<{ + [K in Collection as `${K}Request`]: Signal<{ startIndex: number; size: number; - page: number; }>; -} & { - [K in Collection as `${K}PageInfo`]: Signal<{ +}; +export type EntitiesScrollPaginationMethods = { + setEntitiesPagedResult: (result: { entities: Entity[]; - pageIndex: number; - total: number | undefined; - pageSize: number; - pagesCount: number | undefined; - hasPrevious: boolean; - hasNext: boolean; - isLoading: boolean; - }>; + total: number; + }) => void; + loadMoreEntities: () => void; }; -export type EntitiesPaginationInfiniteMethods = - EntitiesPaginationLocalMethods & { - setEntitiesPagedResult: (result: { - entities: Entity[]; - total: number; - }) => void; - loadEntitiesNextPage: () => void; - loadEntitiesPreviousPage: () => void; - loadEntitiesFirstPage: () => void; - }; -export type NamedEntitiesPaginationInfiniteMethods< +export type NamedEntitiesScrollPaginationMethods< Entity, Collection extends string, > = { @@ -75,9 +42,5 @@ export type NamedEntitiesPaginationInfiniteMethods< 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; + [K in Collection as `loadMore${Capitalize}`]: () => void; }; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.spec.ts new file mode 100644 index 00000000..42c8afd8 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.spec.ts @@ -0,0 +1,240 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; +import { of } from 'rxjs'; + +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesRemoteFilter, + withEntitiesRemoteScrollPagination, + withEntitiesRemoteSort, +} from '../index'; +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; +import { sortData } from '../with-entities-sort/with-entities-sort.utils'; + +describe('withEntitiesRemoteScrollPagination', () => { + const entity = type(); + + it('entitiesCurrentPage should split entities in the correct pages', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withEntitiesRemoteScrollPagination({ entity, bufferSize: 10 }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesRequest }) => { + let result = [...mockProducts.slice(0, 25)]; + const total = result.length; + const options = { + skip: entitiesRequest()?.startIndex, + take: entitiesRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + + const store = new Store(); + TestBed.flushEffects(); + expect(store.entities()).toEqual([]); + store.setLoading(); + tick(); + // check the first load + expect(store.entities().length).toEqual(10); + expect(store.entities()).toEqual(mockProducts.slice(0, 10)); + expect(store.entitiesScrollCache().hasMore).toEqual(true); + expect(store.entitiesScrollCache().total).toEqual(25); + expect(store.entitiesScrollCache().bufferSize).toEqual(10); + + store.loadMoreEntities(); + tick(); + // check the second load + expect(store.entities().length).toEqual(20); + expect(store.entities()).toEqual(mockProducts.slice(0, 20)); + expect(store.entitiesScrollCache().hasMore).toEqual(true); + store.loadMoreEntities(); + tick(); + expect(store.entities().length).toEqual(25); + expect(store.entities()).toEqual(mockProducts.slice(0, 25)); + expect(store.entitiesScrollCache().hasMore).toEqual(false); + }); + })); + + it('with collection entitiesCurrentPage should split entities in the correct pages', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const collection = 'products'; + const Store = signalStore( + withEntities({ entity, collection }), + withCallStatus({ collection }), + withEntitiesRemoteScrollPagination({ + entity, + collection, + bufferSize: 10, + }), + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsRequest }) => { + let result = [...mockProducts.slice(0, 25)]; + const total = result.length; + const options = { + skip: productsRequest()?.startIndex, + take: productsRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + }, + }), + ); + + const store = new Store(); + TestBed.flushEffects(); + expect(store.productsEntities()).toEqual([]); + store.setProductsLoading(); + tick(); + // check the first load + expect(store.productsEntities().length).toEqual(10); + expect(store.productsEntities()).toEqual(mockProducts.slice(0, 10)); + expect(store.productsScrollCache().hasMore).toEqual(true); + expect(store.productsScrollCache().total).toEqual(25); + expect(store.productsScrollCache().bufferSize).toEqual(10); + + store.loadMoreProducts(); + tick(); + // check the second load + expect(store.productsEntities().length).toEqual(20); + expect(store.productsEntities()).toEqual(mockProducts.slice(0, 20)); + expect(store.productsScrollCache().hasMore).toEqual(true); + store.loadMoreProducts(); + tick(); + expect(store.productsEntities().length).toEqual(25); + expect(store.productsEntities()).toEqual(mockProducts.slice(0, 25)); + expect(store.productsScrollCache().hasMore).toEqual(false); + }); + })); + + it(' should reset cache when filter is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemoteScrollPagination({ entity, bufferSize: 30 }), + withEntitiesRemoteFilter({ + entity, + defaultFilter: { search: '', foo: 'bar' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesFilter, entitiesRequest }) => { + let result = [...mockProducts]; + const total = result.length; + if (entitiesFilter()?.search) + result = mockProducts.filter((entity) => + entitiesFilter()?.search + ? entity.name + .toLowerCase() + .includes(entitiesFilter()?.search.toLowerCase()) + : false, + ); + const options = { + skip: entitiesRequest()?.startIndex, + take: entitiesRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + return Promise.resolve({ entities: result, total: result.length }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + // first fill cache + tick(); + expect(store.entities().length).toEqual(30); + store.loadMoreEntities(); + tick(); + expect(store.entities().length).toEqual(60); + expect(store.entities()).toEqual(mockProducts.slice(0, 60)); + + store.filterEntities({ + filter: { search: 'zero' }, + patch: true, + }); + tick(400); + expect(store.entities().length).toEqual(2); + }); + })); + + it(' should reset cache when sort is executed', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemoteScrollPagination({ entity, bufferSize: 30 }), + withEntitiesRemoteSort({ + entity, + defaultSort: { field: 'id', direction: 'asc' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesSort, entitiesRequest }) => { + let result = [...mockProducts]; + const total = result.length; + + if (entitiesSort()?.field) { + result = sortData(result, { + field: entitiesSort()?.field as any, + direction: entitiesSort().direction, + }); + } + const options = { + skip: entitiesRequest()?.startIndex, + take: entitiesRequest()?.size, + }; + if (options?.skip || options?.take) { + const skip = +(options?.skip ?? 0); + const take = +(options?.take ?? 0); + result = result.slice(skip, skip + take); + } + return of({ entities: result, total }); + return Promise.resolve({ entities: result, total: result.length }); + }, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + // first fill cache + tick(); + expect(store.entities().length).toEqual(30); + store.loadMoreEntities(); + tick(); + expect(store.entities().length).toEqual(60); + expect(store.entities()).toEqual(mockProducts.slice(0, 60)); + + store.sortEntities({ + sort: { + field: 'name', + direction: 'desc', + }, + }); + tick(400); + expect(store.entities().length).toEqual(30); + }); + })); +}); 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 index 3956147f..37e53aca 100644 --- 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 @@ -1,4 +1,5 @@ import { computed, Signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; import { patchState, signalStoreFeature, @@ -17,7 +18,9 @@ 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 { exhaustMap, first, pipe, tap } from 'rxjs'; import { combineFunctions, getWithEntitiesKeys } from '../util'; import { @@ -27,15 +30,14 @@ import { NamedCallStatusMethods, } from '../with-call-status/with-call-status.model'; import { getWithCallStatusKeys } from '../with-call-status/with-call-status.util'; -import { loadEntitiesPageFactory } from './with-entities-remote-pagination.util'; import { - EntitiesPaginationInfiniteComputed, - EntitiesPaginationInfiniteMethods, - EntitiesPaginationInfiniteState, - InfinitePaginationState, - NamedEntitiesPaginationInfiniteComputed, - NamedEntitiesPaginationInfiniteMethods, - NamedEntitiesPaginationInfiniteState, + EntitiesScrollPaginationComputed, + EntitiesScrollPaginationMethods, + EntitiesScrollPaginationState, + NamedEntitiesScrollPaginationComputed, + NamedEntitiesScrollPaginationMethods, + NamedEntitiesScrollPaginationState, + ScrollPaginationState, } from './with-entities-remote-scroll-pagination.model'; import { getWithEntitiesInfinitePaginationKeys } from './with-entities-remote-scroll-pagination.util'; @@ -51,8 +53,7 @@ import { getWithEntitiesInfinitePaginationKeys } from './with-entities-remote-sc * 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.bufferSize - The number of entities loaded each time * @param config.entity - The entity type * @param config.collection - The name of the collection * @@ -68,7 +69,7 @@ import { getWithEntitiesInfinitePaginationKeys } from './with-entities-remote-sc * withEntitiesRemoteScrollPagination({ * entity, * collection, - * pageSize: 5, + * bufferSize: 5, * pagesToCache: 2, * }) * // after you can use withEntitiesLoadingCall to connect the filter to @@ -121,9 +122,9 @@ import { getWithEntitiesInfinitePaginationKeys } from './with-entities-remote-sc * 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 + * store.productsPagination // { currentPage: number, requestPage: number, bufferSize: 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, isLoading: boolean } + * store.productsPageInfo // { pageIndex: number, total: number, bufferSize: 5, pagesCount: number, hasPrevious: boolean, hasNext: boolean, isLoading: boolean } * store.productsPagedRequest // { startIndex: number, size: number, page: number } * // generates the following methods * store.loadProductsNextPage() // loads next page @@ -134,9 +135,7 @@ import { getWithEntitiesInfinitePaginationKeys } from './with-entities-remote-sc export function withEntitiesRemoteScrollPagination< Entity extends { id: string | number }, >(config: { - pageSize?: number; - currentPage?: number; - pagesToCache?: number; + bufferSize?: number; entity?: Entity; }): SignalStoreFeature< { @@ -145,9 +144,9 @@ export function withEntitiesRemoteScrollPagination< methods: CallStatusMethods; }, { - state: EntitiesPaginationInfiniteState; - signals: EntitiesPaginationInfiniteComputed; - methods: EntitiesPaginationInfiniteMethods; + state: EntitiesScrollPaginationState; + signals: EntitiesScrollPaginationComputed; + methods: EntitiesScrollPaginationMethods; } >; @@ -163,8 +162,7 @@ export function withEntitiesRemoteScrollPagination< * 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.bufferSize - The number of entities to show per page * @param config.entity - The entity type * @param config.collection - The name of the collection * @@ -180,7 +178,7 @@ export function withEntitiesRemoteScrollPagination< * withEntitiesRemoteScrollPagination({ * entity, * collection, - * pageSize: 5, + * bufferSize: 5, * pagesToCache: 2, * }) * // after you can use withEntitiesLoadingCall to connect the filter to @@ -233,9 +231,9 @@ export function withEntitiesRemoteScrollPagination< * 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 + * store.productsPagination // { currentPage: number, requestPage: number, bufferSize: 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, isLoading: boolean } + * store.productsPageInfo // { pageIndex: number, total: number, bufferSize: 5, pagesCount: number, hasPrevious: boolean, hasNext: boolean, isLoading: boolean } * store.productsPagedRequest // { startIndex: number, size: number, page: number } * // generates the following methods * store.loadProductsNextPage() // loads next page @@ -247,9 +245,7 @@ export function withEntitiesRemoteScrollPagination< Entity extends { id: string | number }, Collection extends string, >(config: { - pageSize?: number; - currentPage?: number; - pagesToCache?: number; + bufferSize?: number; entity?: Entity; collection?: Collection; }): SignalStoreFeature< @@ -260,9 +256,9 @@ export function withEntitiesRemoteScrollPagination< methods: NamedCallStatusMethods; }, { - state: NamedEntitiesPaginationInfiniteState; - signals: NamedEntitiesPaginationInfiniteComputed; - methods: NamedEntitiesPaginationInfiniteMethods; + state: NamedEntitiesScrollPaginationState; + signals: NamedEntitiesScrollPaginationComputed; + methods: NamedEntitiesScrollPaginationMethods; } >; @@ -270,11 +266,11 @@ export function withEntitiesRemoteScrollPagination< Entity extends { id: string | number }, Collection extends string, >({ - pageSize = 10, + bufferSize = 10, pagesToCache = 3, ...config }: { - pageSize?: number; + bufferSize?: number; pagesToCache?: number; entity?: Entity; collection?: Collection; @@ -282,78 +278,46 @@ export function withEntitiesRemoteScrollPagination< const { loadingKey, setLoadingKey } = getWithCallStatusKeys({ prop: config.collection, }); - const { clearEntitiesCacheKey } = getWithEntitiesKeys(config); + const { clearEntitiesCacheKey, entitiesKey } = getWithEntitiesKeys(config); const { - loadEntitiesNextPageKey, - loadEntitiesFirstPageKey, - loadEntitiesPreviousPageKey, - entitiesPageInfoKey, + loadMoreEntitiesKey, setEntitiesPagedResultKey, - entitiesPagedRequestKey, - paginationKey, + entitiesRequestKey, + entitiesScrollCacheKey, } = getWithEntitiesInfinitePaginationKeys(config); return signalStoreFeature( withState({ - [paginationKey]: { - pageSize, - currentPage: 0, - requestPage: 0, - pagesToCache, + [entitiesScrollCacheKey]: { total: undefined, - cache: { - start: 0, - end: 0, - }, + bufferSize, + hasMore: true, }, }), withComputed((state: Record>) => { - const loading = state[loadingKey] as Signal; - const pagination = state[ - paginationKey - ] as Signal; + const entities = state[entitiesKey] as Signal; + const entitiesScrollCache = state[ + entitiesScrollCacheKey + ] 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, - isLoading: - loading() && pagination().requestPage === pagination().currentPage, - }; - }); const entitiesPagedRequest = computed(() => ({ - startIndex: pagination().pageSize * pagination().requestPage, - size: pagination().pageSize * pagination().pagesToCache, - page: pagination().requestPage, + startIndex: entities().length, + size: entitiesScrollCache().bufferSize, })); return { - [entitiesPageInfoKey]: entitiesPageInfo, - [entitiesPagedRequestKey]: entitiesPagedRequest, + [entitiesRequestKey]: 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 + const entitiesScrollCache = state[ + entitiesScrollCacheKey + ] as Signal; + + const isLoading = state[loadingKey] as Signal; + const $loading = toObservable(isLoading); + const setLoading = state[setLoadingKey] as () => void; + // TODO refactor some of this code is repeated in remote entitiesScrollCache return { [clearEntitiesCacheKey]: combineFunctions( state[clearEntitiesCacheKey], @@ -366,12 +330,10 @@ export function withEntitiesRemoteScrollPagination< }) : setAllEntities([]), { - [paginationKey]: { - ...pagination(), + [entitiesScrollCacheKey]: { + ...entitiesScrollCache(), total: 0, - cache: { start: 0, end: 0 }, - currentPage: 0, - requestPage: 0, + hasMore: true, }, }, ); @@ -384,6 +346,7 @@ export function withEntitiesRemoteScrollPagination< entities: Entity[]; total: number; }) => { + const entitiesOld = state[entitiesKey] as Signal; patchState( state as StateSignal, config.collection @@ -392,29 +355,28 @@ export function withEntitiesRemoteScrollPagination< }) : addEntities(entities), { - [paginationKey]: { - ...pagination(), + [entitiesScrollCacheKey]: { + ...entitiesScrollCache(), total, - cache: { - ...pagination().cache, - end: pagination().cache.end + entities.length, - }, + hasMore: entitiesOld().length + entities.length < total, }, }, ); }, - [loadEntitiesNextPageKey]: () => { - loadEntitiesPage({ pageIndex: pagination().currentPage + 1 }); - }, - [loadEntitiesPreviousPageKey]: () => { - loadEntitiesPage({ - pageIndex: - pagination().currentPage > 0 ? pagination().currentPage - 1 : 0, - }); - }, - [loadEntitiesFirstPageKey]: () => { - loadEntitiesPage({ pageIndex: 0 }); - }, + [loadMoreEntitiesKey]: rxMethod( + pipe( + exhaustMap(() => + $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(() => { + if (entitiesScrollCache().hasMore) setLoading(); + }), + ), + ), + ), + ), }; }), ); diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts index b3d56af7..f0c3d3fa 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts @@ -6,29 +6,17 @@ export function getWithEntitiesInfinitePaginationKeys(config?: { 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', + entitiesScrollCacheKey: collection + ? `${config.collection}ScrollCache` + : 'entitiesScrollCache', + entitiesRequestKey: collection + ? `${config.collection}Request` + : 'entitiesRequest', + loadMoreEntitiesKey: collection + ? `loadMore${capitalizedProp}` + : 'loadMoreEntities', setEntitiesPagedResultKey: collection - ? `set${capitalizedProp}LoadedResult` + ? `set${capitalizedProp}PagedResult` : 'setEntitiesPagedResult', }; } diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts index 890e902d..541f2336 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.spec.ts @@ -1,7 +1,7 @@ -import { withEntitiesMultiSelection } from '@ngrx-traits/signals'; import { patchState, signalStore, type } from '@ngrx/signals'; import { setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { withEntitiesMultiSelection } from '../index'; import { mockProducts } from '../test.mocks'; import { Product } from '../test.model'; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-multi-selection.ts index d0b65bee..ce4dec46 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-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,7 +16,7 @@ import { } from '@ngrx/signals/entities/src/models'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import { capitalize, combineFunctions, getWithEntitiesKeys } from '../util'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; import { EntitiesMultiSelectionComputed, EntitiesMultiSelectionMethods, diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts index c824da89..b29715da 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.spec.ts @@ -1,7 +1,7 @@ -import { withEntitiesSingleSelection } from '@ngrx-traits/signals'; import { patchState, signalStore, type } from '@ngrx/signals'; import { setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { withEntitiesSingleSelection } from '../index'; import { mockProducts } from '../test.mocks'; import { Product } from '../test.model'; diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts index 1b7b6b0e..e48a9651 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-selection/with-entities-single-selection.ts @@ -15,7 +15,7 @@ import { } from '@ngrx/signals/entities/src/models'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; -import { capitalize, combineFunctions, getWithEntitiesKeys } from '../util'; +import { combineFunctions, getWithEntitiesKeys } from '../util'; import { EntitiesSingleSelectionComputed, EntitiesSingleSelectionMethods, 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 index 470cf12e..b4a75c18 100644 --- 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 @@ -7,10 +7,10 @@ export type NamedEntitiesSortState = { [K in Collection as `${K}Sort`]: Sort; }; export type EntitiesSortMethods = { - sortEntities: (options: { sort: Sort }) => void; + sortEntities: (options?: { sort: Sort }) => void; }; export type NamedEntitiesSortMethods = { - [K in Collection as `sort${Capitalize}Entities`]: (options: { + [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.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.spec.ts new file mode 100644 index 00000000..739bfba5 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-local-sort.spec.ts @@ -0,0 +1,101 @@ +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + +import { withEntitiesLocalSort } from '../index'; +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withEntitiesLocalSort', () => { + const entity = type(); + it('should sort entities and store sort', () => { + const Store = signalStore( + withEntities({ + entity, + }), + withEntitiesLocalSort({ + entity, + defaultSort: { field: 'name', direction: 'asc' }, + }), + ); + const store = new Store(); + patchState(store, setAllEntities(mockProducts)); + expect(store.entitiesSort()).toEqual({ field: 'name', direction: 'asc' }); + // check default sort + store.sortEntities(); + expect( + store + .entities() + .map((e) => e.name) + .slice(0, 5), + ).toEqual([ + '1080° Avalanche', + 'Animal Crossing', + 'Arkanoid: Doh it Again', + 'Battalion Wars', + 'BattleClash', + ]); + // sort by price + store.sortEntities({ + sort: { field: 'price', direction: 'desc' }, + }); + expect( + store + .entities() + .map((e) => e.price) + .slice(0, 5), + ).toEqual([178, 175, 172, 169, 166]); + expect(store.entities().length).toEqual(mockProducts.length); + expect(store.entitiesSort()).toEqual({ + field: 'price', + direction: 'desc', + }); + }); + it('with collection should sort entities and store sort', () => { + const collection = 'products'; + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withEntitiesLocalSort({ + entity, + collection, + defaultSort: { field: 'name', direction: 'asc' }, + }), + ); + const store = new Store(); + patchState(store, setAllEntities(mockProducts, { collection })); + expect(store.productsSort()).toEqual({ field: 'name', direction: 'asc' }); + // check default sort + store.sortProductsEntities(); + expect( + store + .productsEntities() + .map((e) => e.name) + .slice(0, 5), + ).toEqual([ + '1080° Avalanche', + 'Animal Crossing', + 'Arkanoid: Doh it Again', + 'Battalion Wars', + 'BattleClash', + ]); + // sort by price + store.sortProductsEntities({ + sort: { field: 'price', direction: 'desc' }, + }); + expect( + store + .productsEntities() + .map((e) => e.price) + .slice(0, 5), + ).toEqual([178, 175, 172, 169, 166]); + expect(store.productsEntities().length).toEqual(mockProducts.length); + expect(store.productsSort()).toEqual({ + field: 'price', + direction: 'desc', + }); + }); + + // TODO check sort event gets fired +}); 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 fcaa2015..30451541 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 @@ -1,8 +1,10 @@ -import { Signal } from '@angular/core'; +import { effect, Signal, untracked } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { patchState, signalStoreFeature, SignalStoreFeature, + withHooks, withMethods, withState, } from '@ngrx/signals'; @@ -16,6 +18,7 @@ import { NamedEntitySignals, } from '@ngrx/signals/entities/src/models'; import type { StateSignal } from '@ngrx/signals/src/state-signal'; +import { filter } from 'rxjs/operators'; import { getWithEntitiesKeys } from '../util'; import { @@ -55,7 +58,7 @@ export { SortDirection }; */ export function withEntitiesLocalSort< Entity extends { id: string | number }, ->(options: { +>(config: { defaultSort: Sort; entity?: Entity; }): SignalStoreFeature< @@ -97,7 +100,7 @@ export function withEntitiesLocalSort< export function withEntitiesLocalSort< Entity extends { id: string | number }, Collection extends string, ->(options: { +>(config: { defaultSort: Sort; entity?: Entity; collection?: Collection; @@ -132,21 +135,24 @@ export function withEntitiesLocalSort< withState({ [sortKey]: defaultSort }), withMethods((state: Record>) => { return { - [sortEntitiesKey]: ({ sort: newSort }: { sort: Sort }) => { + [sortEntitiesKey]: ({ + sort: newSort, + }: { sort?: Sort } = {}) => { + const sort = newSort ?? defaultSort; patchState( state as StateSignal, { - [sortKey]: newSort, + [sortKey]: sort, }, config.collection ? setAllEntities( - sortData(state[entitiesKey]() as Entity[], newSort), + sortData(state[entitiesKey]() as Entity[], sort), { collection: config.collection, }, ) : setAllEntities( - sortData(state[entitiesKey]() as Entity[], newSort), + sortData(state[entitiesKey]() as Entity[], sort), ), ); }, diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.spec.ts new file mode 100644 index 00000000..a7dec566 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-entities-sort/with-entities-remote-sort.spec.ts @@ -0,0 +1,141 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { signalStore, type } from '@ngrx/signals'; +import { withEntities } from '@ngrx/signals/entities'; + +import { + withCallStatus, + withEntitiesLoadingCall, + withEntitiesRemotePagination, + withEntitiesRemoteSort, +} from '../index'; +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; +import { sortData } from './with-entities-sort.utils'; + +describe('withEntitiesRemoteSort', () => { + const entity = type(); + it('should sort entities and store sort', fakeAsync(() => { + const Store = signalStore( + withEntities({ + entity, + }), + withCallStatus({ initialValue: 'loading' }), + withEntitiesRemoteSort({ + entity, + defaultSort: { field: 'name', direction: 'asc' }, + }), + withEntitiesLoadingCall({ + fetchEntities: ({ entitiesSort }) => { + let result = [...mockProducts]; + if (entitiesSort()?.field) { + result = sortData(result, { + field: entitiesSort()?.field as any, + direction: entitiesSort().direction, + }); + } + + return Promise.resolve({ entities: result, total: result.length }); + }, + }), + ); + TestBed.runInInjectionContext(() => { + const store = new Store(); + TestBed.flushEffects(); + tick(); + // check default sort + expect(store.entitiesSort()).toEqual({ field: 'name', direction: 'asc' }); + expect( + store + .entities() + .map((e) => e.name) + .slice(0, 5), + ).toEqual([ + '1080° Avalanche', + 'Animal Crossing', + 'Arkanoid: Doh it Again', + 'Battalion Wars', + 'BattleClash', + ]); + store.sortEntities({ + sort: { field: 'price', direction: 'desc' }, + }); + tick(); + expect( + store + .entities() + .map((e) => e.price) + .slice(0, 5), + ).toEqual([178, 175, 172, 169, 166]); + expect(store.entities().length).toEqual(mockProducts.length); + expect(store.entitiesSort()).toEqual({ + field: 'price', + direction: 'desc', + }); + }); + })); + + it('with collection should sort entities and store sort', fakeAsync(() => { + const collection = 'products'; + const Store = signalStore( + withEntities({ + entity, + collection, + }), + withCallStatus({ initialValue: 'loading', collection }), + withEntitiesRemoteSort({ + entity, + collection, + defaultSort: { field: 'name', direction: 'asc' }, + }), + withEntitiesLoadingCall({ + collection, + fetchEntities: ({ productsSort }) => { + let result = [...mockProducts]; + if (productsSort()?.field) { + result = sortData(result, { + field: productsSort()?.field as any, + direction: productsSort().direction, + }); + } + + return Promise.resolve({ entities: result, total: result.length }); + }, + }), + ); + TestBed.runInInjectionContext(() => { + const store = new Store(); + TestBed.flushEffects(); + tick(); + // check default sort + expect(store.productsSort()).toEqual({ field: 'name', direction: 'asc' }); + expect( + store + .productsEntities() + .map((e) => e.name) + .slice(0, 5), + ).toEqual([ + '1080° Avalanche', + 'Animal Crossing', + 'Arkanoid: Doh it Again', + 'Battalion Wars', + 'BattleClash', + ]); + store.sortProductsEntities({ + sort: { field: 'price', direction: 'desc' }, + }); + tick(); + expect( + store + .productsEntities() + .map((e) => e.price) + .slice(0, 5), + ).toEqual([178, 175, 172, 169, 166]); + expect(store.productsEntities().length).toEqual(mockProducts.length); + expect(store.productsSort()).toEqual({ + field: 'price', + direction: 'desc', + }); + }); + })); + // TODO check sort event gets fired +}); diff --git a/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.spec.ts b/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.spec.ts new file mode 100644 index 00000000..544d7193 --- /dev/null +++ b/libs/ngrx-traits/signals/src/lib/with-sync-to-web-storage/with-sync-to-web-storage.spec.ts @@ -0,0 +1,120 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { withCallStatus, withSyncToWebStorage } from '@ngrx-traits/signals'; +import { patchState, signalStore, type } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; + +import { mockProducts } from '../test.mocks'; +import { Product } from '../test.model'; + +describe('withSyncToWebStorage', () => { + const entity = type(); + it('should save and load to local storage', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withSyncToWebStorage({ + key: 'test', + type: 'local', + restoreOnInit: false, + saveStateChangesAfterMs: 0, + }), + ); + const store = new Store(); + store.clearFromStore(); + TestBed.flushEffects(); + store.setLoaded(); + patchState(store, setAllEntities(mockProducts)); + store.saveToStorage(); + + store.setLoading(); + patchState(store, setAllEntities(mockProducts.slice(0, 30))); + + store.loadFromStorage(); + expect(store.entities()).toEqual(mockProducts); + expect(store.isLoaded()).toBe(true); + }); + }); + + it('should save and load to local session', () => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withSyncToWebStorage({ + key: 'test', + type: 'session', + restoreOnInit: false, + saveStateChangesAfterMs: 0, + }), + ); + const store = new Store(); + store.clearFromStore(); + TestBed.flushEffects(); + store.setLoaded(); + patchState(store, setAllEntities(mockProducts)); + store.saveToStorage(); + + store.setLoading(); + patchState(store, setAllEntities(mockProducts.slice(0, 30))); + + store.loadFromStorage(); + expect(store.entities()).toEqual(mockProducts); + expect(store.isLoaded()).toBe(true); + }); + }); + + it('should save after milliseconds set in saveStateChangesAfterMs if is greater than 0 ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withSyncToWebStorage({ + key: 'test', + type: 'local', + restoreOnInit: false, + saveStateChangesAfterMs: 1000, + }), + ); + const store = new Store(); + store.clearFromStore(); + TestBed.flushEffects(); + store.setLoaded(); + patchState(store, setAllEntities(mockProducts)); + let state = getFromStorage('test'); + expect(state).toBe(undefined); + tick(1500); + state = getFromStorage('test'); + expect(state.ids.length).toEqual(mockProducts.length); + expect(state.callStatus).toEqual('loaded'); + }); + })); + + it('should restore state from store on init if restoreOnInit: true ', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + window.localStorage.setItem( + 'test', + `{"entityMap":{"0":{"name":"Super Mario World","id":"0","description":"Super Nintendo Game","price":10},"1":{"name":"F-Zero","id":"1","description":"Super Nintendo Game","price":12}},"ids":["0","1"],"callStatus":"loaded"}`, + ); + const Store = signalStore( + withEntities({ entity }), + withCallStatus(), + withSyncToWebStorage({ + key: 'test', + type: 'local', + restoreOnInit: true, + saveStateChangesAfterMs: 0, + }), + ); + const store = new Store(); + TestBed.flushEffects(); + tick(); + expect(store.entities().length).toEqual(2); + expect(store.isLoaded()).toBe(true); + }); + })); +}); +function getFromStorage(key: string) { + const data = window.localStorage.getItem(key); + return data ? JSON.parse(data) : undefined; +} 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 aef386f7..a9621bc1 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 @@ -69,6 +69,10 @@ export const withSyncToWebStorage = ({ patchState(store, JSON.parse(stateJson)); return true; }, + clearFromStore() { + if (type === 'local') window.localStorage.removeItem(key); + else window.sessionStorage.removeItem(key); + }, }; }), withHooks(({ loadFromStorage, saveToStorage, ...store }) => ({ diff --git a/libs/ngrx-traits/signals/src/test-setup.ts b/libs/ngrx-traits/signals/src/test-setup.ts index ab1eeeb3..d2c50cd7 100644 --- a/libs/ngrx-traits/signals/src/test-setup.ts +++ b/libs/ngrx-traits/signals/src/test-setup.ts @@ -1,3 +1,5 @@ +import 'jest-preset-angular/setup-jest'; + // @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment globalThis.ngJest = { testEnvironmentOptions: { @@ -5,4 +7,3 @@ globalThis.ngJest = { errorOnUnknownProperties: true, }, }; -import 'jest-preset-angular/setup-jest'; diff --git a/package.json b/package.json index 3cc5a46a..767f57a3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "postinstall": "node ./decorate-angular-cli.js", "nx": "nx", "start": "nx serve", - "test": "nx test ngrx-traits-core", + "test": "nx affected:test", "build": "nx run-many --all --target=build", "test-all": "nx run-many --all --target=test", "lint": "nx lint ngrx-traits", From 19cea3a11a10ded5ab120e2fbbde46dc63a325cb Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Fri, 19 Apr 2024 17:01:13 +0100 Subject: [PATCH 8/9] refactor: rename setEntitiesPagedResult to setEntitiesResult to make it more generic --- .../product.store.ts | 1 - libs/ngrx-traits/core/api-docs.md | 50 +++++++------------ libs/ngrx-traits/signals/api-docs.md | 29 ++++++----- .../with-entities-loading-call.spec.ts | 2 +- .../with-entities-loading-call.ts | 17 ++++--- .../with-entities-remote-pagination.model.ts | 7 +-- .../with-entities-remote-pagination.ts | 4 +- .../with-entities-remote-pagination.util.ts | 6 +-- ...entities-remote-scroll-pagination.model.ts | 7 +-- .../with-entities-remote-scroll-pagination.ts | 4 +- ...-entities-remote-scroll-pagination.util.ts | 6 +-- 11 files changed, 58 insertions(+), 75 deletions(-) diff --git a/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts b/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts index bf49a852..8e44a85c 100644 --- a/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts +++ b/apps/example-app/src/app/examples/signals/product-list-paginated-page/product.store.ts @@ -83,7 +83,6 @@ export const ProductsRemoteStore = signalStore( }), })), ); - export const ProductsLocalStore = signalStore( { providedIn: 'root' }, withEntities({ entity, collection }), diff --git a/libs/ngrx-traits/core/api-docs.md b/libs/ngrx-traits/core/api-docs.md index e2d7ddab..55fc5c1b 100644 --- a/libs/ngrx-traits/core/api-docs.md +++ b/libs/ngrx-traits/core/api-docs.md @@ -389,8 +389,7 @@ it will return the cache value without calling again source

| options.expires |

time to expire the cache valued, if not present is infinite

| | options.maxCacheSize |

max number of keys to store , only works if last key is variable

| -**Example** - +**Example** ```js // cache for 3 min loadStores$ = createEffect(() => { @@ -400,7 +399,7 @@ loadStores$ = createEffect(() => { cache({ key: ['stores'], store: this.store, - source: this.storeService.getBranches(), + source: this.storeService.getStores(), expire: 1000 * 60 * 3 // optional param , cache forever if not present }).pipe( map((res) => ProductStoreActions.loadStoresSuccess({ entities: res })), @@ -410,7 +409,7 @@ loadStores$ = createEffect(() => { ); }); // cache top 10, for 3 mins -loadDepartments$ = createEffect(() => { + loadDepartments$ = createEffect(() => { return this.actions$.pipe( ofType(this.localActions.loadDepartments), concatLatestFrom(() => @@ -418,34 +417,21 @@ loadDepartments$ = createEffect(() => { ), exhaustMap(([_, filters]) => cache({ - key: ['stores', 'departments', { storeId: filters!.storeId - }, - store -: - this.store, - source -: - this.storeService.getBranchDepartments(filters - ! -. - storeId -), - expires: 1000 * 60 * 3, - maxCacheSize -: - 10, -}). - pipe( - map((res) => - this.localActions.loadDepartmentsSuccess({ - entities: res, - }) - ), - catchError(() => of(this.localActions.loadDepartmentsFail())) - ) -) -) - ; + key: ['stores','departments',{ storeId: filters!.storeId }, + store: this.store, + source: this.storeService.getStoreDepartments(filters!.storeId), + expires: 1000 * 60 * 3, + maxCacheSize: 10, + }).pipe( + map((res) => + this.localActions.loadDepartmentsSuccess({ + entities: res, + }) + ), + catchError(() => of(this.localActions.loadDepartmentsFail())) + ) + ) + ); }); ``` diff --git a/libs/ngrx-traits/signals/api-docs.md b/libs/ngrx-traits/signals/api-docs.md index b9803665..884f9bb8 100644 --- a/libs/ngrx-traits/signals/api-docs.md +++ b/libs/ngrx-traits/signals/api-docs.md @@ -20,7 +20,7 @@ and is debounced by default. Requires withEntities and withCallStatus to be pres

Generates a onInit hook that fetches entities from a remote source when the [collection]Loading is true, by calling the fetchEntities function and if successful, it will call set[Collection]Loaded and also set the entities -to the store using the setAllEntities method or the setEntitiesPagedResult method +to the store using the setAllEntities method or the setEntitiesResult 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.

@@ -48,7 +48,9 @@ or use withEntitiesLoadingCall to call the api with the [collection]PagedRequest 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

+

Generates state, signals and methods for multi selection of entities. +Warning: isAll[Collection]Selected and toggleSelectAll[Collection] wont work +correctly in using remote pagination, because they cant select all the data

withEntitiesSingleSelection(config)

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

withEntitiesLocalSort(config)
@@ -272,7 +274,7 @@ export const store = signalStore(

Generates a onInit hook that fetches entities from a remote source when the [collection]Loading is true, by calling the fetchEntities function and if successful, it will call set[Collection]Loaded and also set the entities -to the store using the setAllEntities method or the setEntitiesPagedResult method +to the store using the setAllEntities method or the setEntitiesResult 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.

@@ -482,8 +484,7 @@ Requires withEntities and withCallStatus to be present in the store.

| Param | Description | | --- | --- | | config | | -| config.pageSize |

The number of entities to show per page

| -| config.pagesToCache |

The number of pages to cache

| +| config.bufferSize |

The number of entities to show per page

| | config.entity |

The entity type

| | config.collection |

The name of the collection

| @@ -500,7 +501,7 @@ export const store = signalStore( withEntitiesRemoteScrollPagination({ entity, collection, - pageSize: 5, + bufferSize: 5, pagesToCache: 2, }) // after you can use withEntitiesLoadingCall to connect the filter to @@ -553,9 +554,9 @@ export const store = signalStore( 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 + store.productsPagination // { currentPage: number, requestPage: number, bufferSize: 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, isLoading: boolean } + store.productsPageInfo // { pageIndex: number, total: number, bufferSize: 5, pagesCount: number, hasPrevious: boolean, hasNext: boolean, isLoading: boolean } store.productsPagedRequest // { startIndex: number, size: number, page: number } // generates the following methods store.loadProductsNextPage() // loads next page @@ -566,7 +567,9 @@ export const store = signalStore( ## withEntitiesMultiSelection(config) -

Generates state, signals and methods for multi selection of entities

+

Generates state, signals and methods for multi selection of entities. +Warning: isAll[Collection]Selected and toggleSelectAll[Collection] wont work +correctly in using remote pagination, because they cant select all the data

**Kind**: global function @@ -587,9 +590,9 @@ export const store = signalStore( ); // generates the following signals -store.productsSelectedIdsMap // Record; +store.productsIdsSelectedMap // Record; // generates the following computed signals -store.productsSelectedEntities // Entity[]; +store.productsEntitiesSelected // Entity[]; store.isAllProductsSelected // 'all' | 'none' | 'some'; // generates the following methods store.selectProducts // (config: { id: string | number } | { ids: (string | number)[] }) => void; @@ -627,9 +630,9 @@ export const store = signalStore( ); // generates the following signals - store.productsSelectedId // string | number | undefined + store.productsIdSelected // string | number | undefined // generates the following computed signals - store.productsSelectedEntity // Entity | undefined + store.productsEntitySelected // Entity | undefined // generates the following methods store.selectProductEntity // (config: { id: string | number }) => void store.deselectProductEntity // (config: { id: string | number }) => void diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts index a8e422b9..682f1a0e 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-loading-call/with-entities-loading-call.spec.ts @@ -62,7 +62,7 @@ describe('withEntitiesLoadingCall', () => { }); })); - it('should setEntitiesPagedResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { + it('should setEntitiesResult if fetchEntities returns an a {entities: Entity[], total: number} ', fakeAsync(() => { TestBed.runInInjectionContext(() => { const Store = signalStore( withEntities({ 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 0f9d972d..e5671e33 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 @@ -54,7 +54,7 @@ import { getWithEntitiesRemotePaginationKeys } from '../with-entities-pagination * Generates a onInit hook that fetches entities from a remote source * when the [collection]Loading is true, by calling the fetchEntities function * and if successful, it will call set[Collection]Loaded and also set the entities - * to the store using the setAllEntities method or the setEntitiesPagedResult method + * to the store using the setAllEntities method or the setEntitiesResult 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. @@ -145,7 +145,7 @@ export function withEntitiesLoadingCall< * Generates a onInit hook that fetches entities from a remote source * when the [collection]Loading is true, by calling the fetchEntities function * and if successful, it will call set[Collection]Loaded and also set the entities - * to the store using the setAllEntities method or the setEntitiesPagedResult method + * to the store using the setAllEntities method or the setEntitiesResult 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. @@ -271,16 +271,17 @@ export function withEntitiesLoadingCall< const { loadingKey, setErrorKey, setLoadedKey } = getWithCallStatusKeys({ prop: collection, }); - const { setEntitiesPagedResultKey } = getWithEntitiesRemotePaginationKeys({ + const { setEntitiesResultKey } = getWithEntitiesRemotePaginationKeys({ collection, }); return (store) => { const loading = store.signals[loadingKey] as Signal; const setLoaded = store.methods[setLoadedKey] as () => void; const setError = store.methods[setErrorKey] as (error: unknown) => void; - const setEntitiesPagedResult = store.methods[ - setEntitiesPagedResultKey - ] as (result: { entities: Entity[]; total: number }) => void; + const setEntitiesResult = store.methods[setEntitiesResultKey] as (result: { + entities: Entity[]; + total: number; + }) => void; return signalStoreFeature( withHooks({ @@ -315,8 +316,8 @@ export function withEntitiesLoadingCall< ); } else { const { entities, total } = result; - if (setEntitiesPagedResult) - setEntitiesPagedResult({ entities, total }); + if (setEntitiesResult) + setEntitiesResult({ entities, total }); else patchState( state, 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 index dad56bd4..d41a453a 100644 --- 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 @@ -62,17 +62,14 @@ export type NamedEntitiesPaginationRemoteComputed< }; export type EntitiesPaginationRemoteMethods = EntitiesPaginationLocalMethods & { - setEntitiesPagedResult: (result: { - entities: Entity[]; - total: number; - }) => void; + setEntitiesResult: (result: { entities: Entity[]; total: number }) => void; }; export type NamedEntitiesPaginationRemoteMethods< Entity, Collection extends string, > = NamedEntitiesPaginationLocalMethods & { - [K in Collection as `set${Capitalize}PagedResult`]: (result: { + [K in Collection as `set${Capitalize}Result`]: (result: { 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 149b4d53..a84a6c26 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 @@ -358,7 +358,7 @@ export function withEntitiesRemotePagination< entitiesCurrentPageKey, paginationKey, entitiesPagedRequestKey, - setEntitiesPagedResultKey, + setEntitiesResultKey, } = getWithEntitiesRemotePaginationKeys(config); return signalStoreFeature( @@ -453,7 +453,7 @@ export function withEntitiesRemotePagination< ); }, ), - [setEntitiesPagedResultKey]: ({ + [setEntitiesResultKey]: ({ entities, total, }: { 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 5cee5c06..5c9c26e1 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 @@ -29,9 +29,9 @@ export function getWithEntitiesRemotePaginationKeys(config?: { loadEntitiesPageKey: collection ? `load${capitalizedProp}Page` : 'loadEntitiesPage', - setEntitiesPagedResultKey: collection - ? `set${capitalizedProp}PagedResult` - : 'setEntitiesPagedResult', + setEntitiesResultKey: collection + ? `set${capitalizedProp}Result` + : 'setEntitiesResult', }; } export function isEntitiesInCache( 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 index 5bf5d008..7c546354 100644 --- 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 @@ -27,17 +27,14 @@ export type NamedEntitiesScrollPaginationComputed< }>; }; export type EntitiesScrollPaginationMethods = { - setEntitiesPagedResult: (result: { - entities: Entity[]; - total: number; - }) => void; + setEntitiesResult: (result: { entities: Entity[]; total: number }) => void; loadMoreEntities: () => void; }; export type NamedEntitiesScrollPaginationMethods< Entity, Collection extends string, > = { - [K in Collection as `set${Capitalize}PagedResult`]: (result: { + [K in Collection as `set${Capitalize}Result`]: (result: { entities: Entity[]; total: number; }) => 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 index 37e53aca..f23508f6 100644 --- 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 @@ -282,7 +282,7 @@ export function withEntitiesRemoteScrollPagination< const { loadMoreEntitiesKey, - setEntitiesPagedResultKey, + setEntitiesResultKey, entitiesRequestKey, entitiesScrollCacheKey, } = getWithEntitiesInfinitePaginationKeys(config); @@ -339,7 +339,7 @@ export function withEntitiesRemoteScrollPagination< ); }, ), - [setEntitiesPagedResultKey]: ({ + [setEntitiesResultKey]: ({ entities, total, }: { diff --git a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts index f0c3d3fa..0928e8c4 100644 --- a/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts +++ b/libs/ngrx-traits/signals/src/lib/with-entities-pagination/with-entities-remote-scroll-pagination.util.ts @@ -15,8 +15,8 @@ export function getWithEntitiesInfinitePaginationKeys(config?: { loadMoreEntitiesKey: collection ? `loadMore${capitalizedProp}` : 'loadMoreEntities', - setEntitiesPagedResultKey: collection - ? `set${capitalizedProp}PagedResult` - : 'setEntitiesPagedResult', + setEntitiesResultKey: collection + ? `set${capitalizedProp}Result` + : 'setEntitiesResult', }; } From 5e5342c34aa96535d51e6bedf80c8eea535aa23c Mon Sep 17 00:00:00 2001 From: Gabriel Guerrero Date: Fri, 19 Apr 2024 17:06:58 +0100 Subject: [PATCH 9/9] fix: fix build --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce428fa3..1c4c55b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: cache: 'npm' - run: npm ci --legacy-peer-deps - run: npm run build - - run: npm test + - run: npm run test-all publish: