diff --git a/apps/mobile/src/app/app.module.ts b/apps/mobile/src/app/app.module.ts index 894e876..dffe752 100644 --- a/apps/mobile/src/app/app.module.ts +++ b/apps/mobile/src/app/app.module.ts @@ -12,6 +12,9 @@ import { import { getFirestore, provideFirestore } from '@angular/fire/firestore'; import { ServiceWorkerModule } from '@angular/service-worker'; import { Capacitor } from '@capacitor/core'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { MobileShellModule } from '@task-ninja/mobile/shell/feature'; import { environment } from '../environments/environment'; import { AppComponent } from './app.component'; @@ -39,6 +42,18 @@ import { AppComponent } from './app.component'; // or after 30 seconds (whichever comes first). registrationStrategy: 'registerWhenStable:30000', }), + StoreModule.forRoot( + {}, + { + metaReducers: [], + runtimeChecks: { + strictActionImmutability: true, + strictStateImmutability: true, + }, + } + ), + EffectsModule.forRoot([]), + StoreDevtoolsModule.instrument({ logOnly: !isDevMode() }), ], bootstrap: [AppComponent], }) diff --git a/libs/mobile/auth/data-access/src/index.ts b/libs/mobile/auth/data-access/src/index.ts index ddb1d3e..c24d61b 100644 --- a/libs/mobile/auth/data-access/src/index.ts +++ b/libs/mobile/auth/data-access/src/index.ts @@ -1,2 +1,7 @@ +export * from './lib/+state/auth.facade'; +export * from './lib/+state/auth.models'; +export * from './lib/+state/auth.selectors'; +export * from './lib/+state/auth.reducer'; +export * from './lib/+state/auth.actions'; export * from './lib/auth-data-access.module'; export * from './lib/services/auth.service'; diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.actions.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.actions.ts new file mode 100644 index 0000000..551ba8a --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.actions.ts @@ -0,0 +1,14 @@ +import { createAction, props } from '@ngrx/store'; +import { AuthEntity } from './auth.models'; + +export const initAuth = createAction('[Auth Page] Init'); + +export const loadAuthSuccess = createAction( + '[Auth/API] Load Auth Success', + props<{ auth: AuthEntity[] }>() +); + +export const loadAuthFailure = createAction( + '[Auth/API] Load Auth Failure', + props<{ error: any }>() +); diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.effects.spec.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.effects.spec.ts new file mode 100644 index 0000000..35b7469 --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.effects.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + +import * as AuthActions from './auth.actions'; +import { AuthEffects } from './auth.effects'; + +describe('AuthEffects', () => { + let actions: Observable; + let effects: AuthEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + AuthEffects, + provideMockActions(() => actions), + provideMockStore(), + ], + }); + + effects = TestBed.inject(AuthEffects); + }); + + describe('init$', () => { + it('should work', () => { + actions = hot('-a-|', { a: AuthActions.initAuth() }); + + const expected = hot('-a-|', { + a: AuthActions.loadAuthSuccess({ auth: [] }), + }); + + expect(effects.init$).toBeObservable(expected); + }); + }); +}); diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.effects.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.effects.ts new file mode 100644 index 0000000..950804b --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.effects.ts @@ -0,0 +1,21 @@ +import { Injectable, inject } from '@angular/core'; +import { createEffect, Actions, ofType } from '@ngrx/effects'; +import { switchMap, catchError, of } from 'rxjs'; +import * as AuthActions from './auth.actions'; +import * as AuthFeature from './auth.reducer'; + +@Injectable() +export class AuthEffects { + private actions$ = inject(Actions); + + init$ = createEffect(() => + this.actions$.pipe( + ofType(AuthActions.initAuth), + switchMap(() => of(AuthActions.loadAuthSuccess({ auth: [] }))), + catchError((error) => { + console.error('Error', error); + return of(AuthActions.loadAuthFailure({ error })); + }) + ) + ); +} diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.facade.spec.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.facade.spec.ts new file mode 100644 index 0000000..b2ecd7f --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.facade.spec.ts @@ -0,0 +1,98 @@ +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule, Store } from '@ngrx/store'; +import { readFirst } from '@nx/angular/testing'; + +import * as AuthActions from './auth.actions'; +import { AuthEffects } from './auth.effects'; +import { AuthFacade } from './auth.facade'; +import { AuthEntity } from './auth.models'; +import { + AUTH_FEATURE_KEY, + AuthState, + initialAuthState, + authReducer, +} from './auth.reducer'; +import * as AuthSelectors from './auth.selectors'; + +interface TestSchema { + auth: AuthState; +} + +describe('AuthFacade', () => { + let facade: AuthFacade; + let store: Store; + const createAuthEntity = (id: string, name = ''): AuthEntity => ({ + id, + name: name || `name-${id}`, + }); + + describe('used in NgModule', () => { + beforeEach(() => { + @NgModule({ + imports: [ + StoreModule.forFeature(AUTH_FEATURE_KEY, authReducer), + EffectsModule.forFeature([AuthEffects]), + ], + providers: [AuthFacade], + }) + class CustomFeatureModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + CustomFeatureModule, + ], + }) + class RootModule {} + TestBed.configureTestingModule({ imports: [RootModule] }); + + store = TestBed.inject(Store); + facade = TestBed.inject(AuthFacade); + }); + + /** + * The initially generated facade::loadAll() returns empty array + */ + it('loadAll() should return empty list with loaded == true', async () => { + let list = await readFirst(facade.allAuth$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.init(); + + list = await readFirst(facade.allAuth$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(true); + }); + + /** + * Use `loadAuthSuccess` to manually update list + */ + it('allAuth$ should return the loaded list; and loaded flag == true', async () => { + let list = await readFirst(facade.allAuth$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + store.dispatch( + AuthActions.loadAuthSuccess({ + auth: [createAuthEntity('AAA'), createAuthEntity('BBB')], + }) + ); + + list = await readFirst(facade.allAuth$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(2); + expect(isLoaded).toBe(true); + }); + }); +}); diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.facade.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.facade.ts new file mode 100644 index 0000000..2088cee --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.facade.ts @@ -0,0 +1,27 @@ +import { Injectable, inject } from '@angular/core'; +import { select, Store, Action } from '@ngrx/store'; + +import * as AuthActions from './auth.actions'; +import * as AuthFeature from './auth.reducer'; +import * as AuthSelectors from './auth.selectors'; + +@Injectable() +export class AuthFacade { + private readonly store = inject(Store); + + /** + * Combine pieces of state using createSelector, + * and expose them as observables through the facade. + */ + loaded$ = this.store.pipe(select(AuthSelectors.selectAuthLoaded)); + allAuth$ = this.store.pipe(select(AuthSelectors.selectAllAuth)); + selectedAuth$ = this.store.pipe(select(AuthSelectors.selectEntity)); + + /** + * Use the initialization action to perform one + * or more tasks in your Effects. + */ + init() { + this.store.dispatch(AuthActions.initAuth()); + } +} diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.models.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.models.ts new file mode 100644 index 0000000..767a7df --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.models.ts @@ -0,0 +1,7 @@ +/** + * Interface for the 'Auth' data + */ +export interface AuthEntity { + id: string | number; // Primary ID + name: string; +} diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.reducer.spec.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.reducer.spec.ts new file mode 100644 index 0000000..423379e --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.reducer.spec.ts @@ -0,0 +1,37 @@ +import { Action } from '@ngrx/store'; + +import * as AuthActions from './auth.actions'; +import { AuthEntity } from './auth.models'; +import { AuthState, initialAuthState, authReducer } from './auth.reducer'; + +describe('Auth Reducer', () => { + const createAuthEntity = (id: string, name = ''): AuthEntity => ({ + id, + name: name || `name-${id}`, + }); + + describe('valid Auth actions', () => { + it('loadAuthSuccess should return the list of known Auth', () => { + const auth = [ + createAuthEntity('PRODUCT-AAA'), + createAuthEntity('PRODUCT-zzz'), + ]; + const action = AuthActions.loadAuthSuccess({ auth }); + + const result: AuthState = authReducer(initialAuthState, action); + + expect(result.loaded).toBe(true); + expect(result.ids.length).toBe(2); + }); + }); + + describe('unknown action', () => { + it('should return the previous state', () => { + const action = {} as Action; + + const result = authReducer(initialAuthState, action); + + expect(result).toBe(initialAuthState); + }); + }); +}); diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.reducer.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.reducer.ts new file mode 100644 index 0000000..f2d2e95 --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.reducer.ts @@ -0,0 +1,42 @@ +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on, Action } from '@ngrx/store'; + +import * as AuthActions from './auth.actions'; +import { AuthEntity } from './auth.models'; + +export const AUTH_FEATURE_KEY = 'auth'; + +export interface AuthState extends EntityState { + selectedId?: string | number; // which Auth record has been selected + loaded: boolean; // has the Auth list been loaded + error?: string | null; // last known error (if any) +} + +export interface AuthPartialState { + readonly [AUTH_FEATURE_KEY]: AuthState; +} + +export const authAdapter: EntityAdapter = + createEntityAdapter(); + +export const initialAuthState: AuthState = authAdapter.getInitialState({ + // set initial required properties + loaded: false, +}); + +const reducer = createReducer( + initialAuthState, + on(AuthActions.initAuth, (state) => ({ + ...state, + loaded: false, + error: null, + })), + on(AuthActions.loadAuthSuccess, (state, { auth }) => + authAdapter.setAll(auth, { ...state, loaded: true }) + ), + on(AuthActions.loadAuthFailure, (state, { error }) => ({ ...state, error })) +); + +export function authReducer(state: AuthState | undefined, action: Action) { + return reducer(state, action); +} diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.selectors.spec.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.selectors.spec.ts new file mode 100644 index 0000000..1799b17 --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.selectors.spec.ts @@ -0,0 +1,66 @@ +import { AuthEntity } from './auth.models'; +import { + authAdapter, + AuthPartialState, + initialAuthState, +} from './auth.reducer'; +import * as AuthSelectors from './auth.selectors'; + +describe('Auth Selectors', () => { + const ERROR_MSG = 'No Error Available'; + const getAuthId = (it: AuthEntity) => it.id; + const createAuthEntity = (id: string, name = '') => + ({ + id, + name: name || `name-${id}`, + } as AuthEntity); + + let state: AuthPartialState; + + beforeEach(() => { + state = { + auth: authAdapter.setAll( + [ + createAuthEntity('PRODUCT-AAA'), + createAuthEntity('PRODUCT-BBB'), + createAuthEntity('PRODUCT-CCC'), + ], + { + ...initialAuthState, + selectedId: 'PRODUCT-BBB', + error: ERROR_MSG, + loaded: true, + } + ), + }; + }); + + describe('Auth Selectors', () => { + it('selectAllAuth() should return the list of Auth', () => { + const results = AuthSelectors.selectAllAuth(state); + const selId = getAuthId(results[1]); + + expect(results.length).toBe(3); + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectEntity() should return the selected Entity', () => { + const result = AuthSelectors.selectEntity(state) as AuthEntity; + const selId = getAuthId(result); + + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectAuthLoaded() should return the current "loaded" status', () => { + const result = AuthSelectors.selectAuthLoaded(state); + + expect(result).toBe(true); + }); + + it('selectAuthError() should return the current "error" state', () => { + const result = AuthSelectors.selectAuthError(state); + + expect(result).toBe(ERROR_MSG); + }); + }); +}); diff --git a/libs/mobile/auth/data-access/src/lib/+state/auth.selectors.ts b/libs/mobile/auth/data-access/src/lib/+state/auth.selectors.ts new file mode 100644 index 0000000..77ed7f7 --- /dev/null +++ b/libs/mobile/auth/data-access/src/lib/+state/auth.selectors.ts @@ -0,0 +1,39 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { AUTH_FEATURE_KEY, AuthState, authAdapter } from './auth.reducer'; + +// Lookup the 'Auth' feature state managed by NgRx +export const selectAuthState = + createFeatureSelector(AUTH_FEATURE_KEY); + +const { selectAll, selectEntities } = authAdapter.getSelectors(); + +export const selectAuthLoaded = createSelector( + selectAuthState, + (state: AuthState) => state.loaded +); + +export const selectAuthError = createSelector( + selectAuthState, + (state: AuthState) => state.error +); + +export const selectAllAuth = createSelector( + selectAuthState, + (state: AuthState) => selectAll(state) +); + +export const selectAuthEntities = createSelector( + selectAuthState, + (state: AuthState) => selectEntities(state) +); + +export const selectSelectedId = createSelector( + selectAuthState, + (state: AuthState) => state.selectedId +); + +export const selectEntity = createSelector( + selectAuthEntities, + selectSelectedId, + (entities, selectedId) => (selectedId ? entities[selectedId] : undefined) +); diff --git a/libs/mobile/auth/data-access/src/lib/auth-data-access.module.ts b/libs/mobile/auth/data-access/src/lib/auth-data-access.module.ts index 625ef2a..9b13385 100644 --- a/libs/mobile/auth/data-access/src/lib/auth-data-access.module.ts +++ b/libs/mobile/auth/data-access/src/lib/auth-data-access.module.ts @@ -1,7 +1,17 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import * as fromAuth from './+state/auth.reducer'; +import { AuthEffects } from './+state/auth.effects'; +import { AuthFacade } from './+state/auth.facade'; @NgModule({ - imports: [CommonModule], + imports: [ + CommonModule, + StoreModule.forFeature(fromAuth.AUTH_FEATURE_KEY, fromAuth.authReducer), + EffectsModule.forFeature([AuthEffects]), + ], + providers: [AuthFacade], }) export class AuthDataAccessModule {} diff --git a/libs/mobile/shell/feature/src/lib/mobile-shell.module.ts b/libs/mobile/shell/feature/src/lib/mobile-shell.module.ts index b40b455..65593ac 100644 --- a/libs/mobile/shell/feature/src/lib/mobile-shell.module.ts +++ b/libs/mobile/shell/feature/src/lib/mobile-shell.module.ts @@ -2,10 +2,17 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouteReuseStrategy } from '@angular/router'; import { IonicRouteStrategy } from '@ionic/angular'; +import { AuthDataAccessModule } from '@task-ninja/mobile/auth/data-access'; +import { TasksDataAccessModule } from '@task-ninja/mobile/tasks/data-access'; import { MobileShellRoutingModule } from './mobile-shell-routing.module'; @NgModule({ - imports: [BrowserModule, MobileShellRoutingModule], + imports: [ + BrowserModule, + MobileShellRoutingModule, + TasksDataAccessModule, + AuthDataAccessModule, + ], providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }], }) export class MobileShellModule {} diff --git a/libs/mobile/tasks/data-access/src/index.ts b/libs/mobile/tasks/data-access/src/index.ts index cb5f17f..3e5f79d 100644 --- a/libs/mobile/tasks/data-access/src/index.ts +++ b/libs/mobile/tasks/data-access/src/index.ts @@ -1,2 +1,7 @@ +export * from './lib/+state/tasks.facade'; +export * from './lib/+state/tasks.models'; +export * from './lib/+state/tasks.selectors'; +export * from './lib/+state/tasks.reducer'; +export * from './lib/+state/tasks.actions'; export * from './lib/services/task.service'; export * from './lib/tasks-data-access.module'; diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.actions.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.actions.ts new file mode 100644 index 0000000..61a7d77 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.actions.ts @@ -0,0 +1,34 @@ +import { createAction, props } from '@ngrx/store'; +import { Task } from '../services/task.service'; +import { TasksEntity } from './tasks.models'; +export const loadTasks = createAction('[Tasks/API] Load Tasks'); + +export const loadTasksSuccess = createAction( + '[Tasks/API] Load Tasks Success', + props<{ tasks: TasksEntity[] }>() +); + +export const loadTasksFailure = createAction( + '[Tasks/API] Load Tasks Failure', + props<{ error: any }>() +); + +export const addTask = createAction( + '[Tasks/API] Add Task', + props<{ task: Task }>() +); + +export const addTaskSuccess = createAction( + '[Tasks/API] Add Task Success', + props<{ task: any }>() +); + +export const addTaskFailure = createAction( + '[Tasks/API] Add Task Failure', + props<{ error: any }>() +); + +export const deleteTask = createAction( + '[Tasks/API] Delete Task', + props<{ id: string }>() +); diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.effects.spec.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.effects.spec.ts new file mode 100644 index 0000000..af42c79 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.effects.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { hot } from 'jasmine-marbles'; +import { Observable, of } from 'rxjs'; + +import { Category, Task, TaskService } from '../services/task.service'; +import * as TasksActions from './tasks.actions'; +import { TasksEffects } from './tasks.effects'; + +const categoryMock: Category = { + id: 69, + name: 'Cleaning', + icon: 'sparkle', +}; + +const taskArrayMock: Task[] = [ + { + title: 'Clean windows', + description: 'Clean upstairs windows', + owner: 'John Doe', + type: categoryMock, + }, + { + title: 'Fix radiators', + description: 'Fix bedroom radiator', + owner: 'Karen Doe', + type: categoryMock, + }, +]; + +const taskServiceMock = { + getTasks: jest.fn().mockReturnValue(of(taskArrayMock)), +}; + +describe('TasksEffects', () => { + let actions: Observable; + let effects: TasksEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + TasksEffects, + provideMockActions(() => actions), + provideMockStore(), + { + provide: TaskService, + useValue: taskServiceMock, + }, + ], + }); + + effects = TestBed.inject(TasksEffects); + }); + + describe('init$', () => { + it('should work', () => { + actions = hot('-a-|', { a: TasksActions.loadTasks() }); + + const expected = hot('-a-|', { + a: TasksActions.loadTasksSuccess({ tasks: taskArrayMock }), + }); + + expect(effects.loadTasks$).toBeObservable(expected); + }); + }); +}); diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.effects.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.effects.ts new file mode 100644 index 0000000..6e06bd3 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.effects.ts @@ -0,0 +1,37 @@ +import { Injectable, inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, of, switchMap } from 'rxjs'; +import { TaskService } from '../services/task.service'; +import * as TasksActions from './tasks.actions'; + +@Injectable() +export class TasksEffects { + private actions$ = inject(Actions); + private taskService = inject(TaskService); + + loadTasks$ = createEffect(() => + this.actions$.pipe( + ofType(TasksActions.loadTasks), + switchMap(() => + this.taskService + .getTasks() + .pipe(map((tasks) => TasksActions.loadTasksSuccess({ tasks: tasks }))) + ), + catchError((error) => { + console.error('Error', error); + return of(TasksActions.loadTasksFailure({ error })); + }) + ) + ); + + // addTask$ = createEffect(() => + // this.actions$.pipe( + // ofType(TasksActions.addTask), + // switchMap((payload) => + // from(this.taskService.addTask(payload.task)).pipe( + // map((task) => TasksActions.addTaskSuccess({ task })) + // ) + // ) + // ) + // ); +} diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.facade.spec.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.facade.spec.ts new file mode 100644 index 0000000..cf9e219 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.facade.spec.ts @@ -0,0 +1,138 @@ +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EffectsModule } from '@ngrx/effects'; +import { Store, StoreModule } from '@ngrx/store'; +import { readFirst } from '@nx/angular/testing'; + +import { Auth } from '@angular/fire/auth'; +import { Firestore } from '@angular/fire/firestore'; +import { Category, Task } from '../services/task.service'; +import * as TasksActions from './tasks.actions'; +import { TasksEffects } from './tasks.effects'; +import { TasksFacade } from './tasks.facade'; +import { TasksEntity } from './tasks.models'; +import { TASKS_FEATURE_KEY, TasksState, tasksReducer } from './tasks.reducer'; + +interface TestSchema { + tasks: TasksState; +} + +const categoryMock: Category = { + id: 69, + name: 'Cleaning', + icon: 'sparkle', +}; + +const taskArrayMock: Task[] = [ + { + id: '69', + owner: 'John Doe', + type: categoryMock, + title: 'Clean windows', + description: 'Clean upstairs windows', + }, + { + id: 'Super69', + owner: 'Karen Doe', + type: categoryMock, + title: 'Fix radiators', + description: 'Fix bedroom radiator', + }, +]; + +//TODO: Implement better mock +const authMock = { + auth: jest.fn(), +}; + +//TODO: Implement better mock +const firestoreMock = { + firestore: jest.fn(), +}; + +describe('TasksFacade', () => { + let facade: TasksFacade; + let store: Store; + const createTasksEntity = (taskEntity: Task): TasksEntity => taskEntity; + + describe('used in NgModule', () => { + beforeEach(() => { + @NgModule({ + imports: [ + StoreModule.forFeature(TASKS_FEATURE_KEY, tasksReducer), + EffectsModule.forFeature([TasksEffects]), + ], + providers: [ + TasksFacade, + { + provide: Firestore, + useValue: firestoreMock, + }, + { + provide: Auth, + useValue: authMock, + }, + ], + }) + class CustomFeatureModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + CustomFeatureModule, + ], + }) + class RootModule {} + TestBed.configureTestingModule({ imports: [RootModule] }); + + store = TestBed.inject(Store); + facade = TestBed.inject(TasksFacade); + }); + + /** + * The initially generated facade::loadAll() returns empty array + */ + xit('loadAll() should return empty list with loaded == true', async () => { + let list = await readFirst(facade.allTasks$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.init(); + + list = await readFirst(facade.allTasks$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(true); + }); + + /** + * Use `loadTasksSuccess` to manually update list + */ + it('allTasks$ should return the loaded list; and loaded flag == true', async () => { + let list = await readFirst(facade.allTasks$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + store.dispatch( + TasksActions.loadTasksSuccess({ + tasks: [ + createTasksEntity(taskArrayMock[0]), + createTasksEntity(taskArrayMock[1]), + ], + }) + ); + + list = await readFirst(facade.allTasks$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(2); + expect(isLoaded).toBe(true); + }); + }); +}); diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.facade.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.facade.ts new file mode 100644 index 0000000..4326709 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.facade.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; +import { Store, select } from '@ngrx/store'; + +import { Task } from '../services/task.service'; +import * as TasksActions from './tasks.actions'; +import * as TasksSelectors from './tasks.selectors'; + +@Injectable() +export class TasksFacade { + private readonly store = inject(Store); + + /** + * Combine pieces of state using createSelector, + * and expose them as observables through the facade. + */ + loaded$ = this.store.pipe(select(TasksSelectors.selectTasksLoaded)); + allTasks$ = this.store.pipe(select(TasksSelectors.selectAllTasks)); + selectedTasks$ = this.store.pipe(select(TasksSelectors.selectEntity)); + + /** + * Use the initialization action to perform one + * or more tasks in your Effects. + */ + init() { + this.store.dispatch(TasksActions.loadTasks()); + } + + addTask(task: Task) { + this.store.dispatch(TasksActions.addTask({ task })); + } +} diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.models.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.models.ts new file mode 100644 index 0000000..d553838 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.models.ts @@ -0,0 +1,8 @@ +import { Task } from '../services/task.service'; + +/** + * Interface for the 'Tasks' data + */ +export interface TasksEntity extends Task { + name?: string; +} diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.reducer.spec.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.reducer.spec.ts new file mode 100644 index 0000000..5e4be09 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.reducer.spec.ts @@ -0,0 +1,58 @@ +import { Action } from '@ngrx/store'; + +import { Category, Task } from '../services/task.service'; +import * as TasksActions from './tasks.actions'; +import { TasksEntity } from './tasks.models'; +import { TasksState, initialTasksState, tasksReducer } from './tasks.reducer'; + +const categoryMock: Category = { + id: 69, + name: 'Cleaning', + icon: 'sparkle', +}; + +const taskArrayMock: Task[] = [ + { + id: '69', + owner: 'John Doe', + type: categoryMock, + title: 'Clean windows', + description: 'Clean upstairs windows', + }, + { + id: 'Super69', + owner: 'Karen Doe', + type: categoryMock, + title: 'Fix radiators', + description: 'Fix bedroom radiator', + }, +]; + +describe('Tasks Reducer', () => { + const createTasksEntity = (taskEntity: Task): TasksEntity => taskEntity; + + describe('valid Tasks actions', () => { + it('loadTasksSuccess should return the list of known Tasks', () => { + const tasks = [ + createTasksEntity(taskArrayMock[0]), + createTasksEntity(taskArrayMock[1]), + ]; + const action = TasksActions.loadTasksSuccess({ tasks }); + + const result: TasksState = tasksReducer(initialTasksState, action); + + expect(result.loaded).toBe(true); + expect(result.ids.length).toBe(2); + }); + }); + + describe('unknown action', () => { + it('should return the previous state', () => { + const action = {} as Action; + + const result = tasksReducer(initialTasksState, action); + + expect(result).toBe(initialTasksState); + }); + }); +}); diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.reducer.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.reducer.ts new file mode 100644 index 0000000..228d79b --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.reducer.ts @@ -0,0 +1,58 @@ +import { EntityAdapter, EntityState, createEntityAdapter } from '@ngrx/entity'; +import { Action, createReducer, on } from '@ngrx/store'; + +import * as TasksActions from './tasks.actions'; +import { TasksEntity } from './tasks.models'; + +export const TASKS_FEATURE_KEY = 'tasks'; + +export interface TasksState extends EntityState { + selectedId?: string | number; // which Tasks record has been selected + loaded: boolean; // has the Tasks list been loaded + error?: string | null; // last known error (if any) +} + +export interface TasksPartialState { + readonly [TASKS_FEATURE_KEY]: TasksState; +} + +export const tasksAdapter: EntityAdapter = + createEntityAdapter(); + +export const initialTasksState: TasksState = tasksAdapter.getInitialState({ + // set initial required properties + loaded: false, +}); + +const reducer = createReducer( + initialTasksState, + on(TasksActions.loadTasks, (state) => ({ + ...state, + loaded: false, + error: null, + })), + on(TasksActions.loadTasksSuccess, (state, { tasks }) => + tasksAdapter.setAll(tasks, { ...state, loaded: true }) + ), + on(TasksActions.loadTasksFailure, (state, { error }) => ({ + ...state, + error, + })), + + on(TasksActions.addTask, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(TasksActions.addTaskSuccess, (state, { task }) => + tasksAdapter.addOne(task, { ...state, loading: false }) + ), + on(TasksActions.addTaskFailure, (state, { error }) => ({ + ...state, + error, + })) +); + +export function tasksReducer(state: TasksState | undefined, action: Action) { + return reducer(state, action); +} diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.selectors.spec.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.selectors.spec.ts new file mode 100644 index 0000000..b7f2bc0 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.selectors.spec.ts @@ -0,0 +1,66 @@ +import { TasksEntity } from './tasks.models'; +import { + tasksAdapter, + TasksPartialState, + initialTasksState, +} from './tasks.reducer'; +import * as TasksSelectors from './tasks.selectors'; + +describe('Tasks Selectors', () => { + const ERROR_MSG = 'No Error Available'; + const getTasksId = (it: TasksEntity) => it.id; + const createTasksEntity = (id: string, name = '') => + ({ + id, + name: name || `name-${id}`, + } as TasksEntity); + + let state: TasksPartialState; + + beforeEach(() => { + state = { + tasks: tasksAdapter.setAll( + [ + createTasksEntity('PRODUCT-AAA'), + createTasksEntity('PRODUCT-BBB'), + createTasksEntity('PRODUCT-CCC'), + ], + { + ...initialTasksState, + selectedId: 'PRODUCT-BBB', + error: ERROR_MSG, + loaded: true, + } + ), + }; + }); + + describe('Tasks Selectors', () => { + it('selectAllTasks() should return the list of Tasks', () => { + const results = TasksSelectors.selectAllTasks(state); + const selId = getTasksId(results[1]); + + expect(results.length).toBe(3); + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectEntity() should return the selected Entity', () => { + const result = TasksSelectors.selectEntity(state) as TasksEntity; + const selId = getTasksId(result); + + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectTasksLoaded() should return the current "loaded" status', () => { + const result = TasksSelectors.selectTasksLoaded(state); + + expect(result).toBe(true); + }); + + it('selectTasksError() should return the current "error" state', () => { + const result = TasksSelectors.selectTasksError(state); + + expect(result).toBe(ERROR_MSG); + }); + }); +}); diff --git a/libs/mobile/tasks/data-access/src/lib/+state/tasks.selectors.ts b/libs/mobile/tasks/data-access/src/lib/+state/tasks.selectors.ts new file mode 100644 index 0000000..2b13641 --- /dev/null +++ b/libs/mobile/tasks/data-access/src/lib/+state/tasks.selectors.ts @@ -0,0 +1,39 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { TASKS_FEATURE_KEY, TasksState, tasksAdapter } from './tasks.reducer'; + +// Lookup the 'Tasks' feature state managed by NgRx +export const selectTasksState = + createFeatureSelector(TASKS_FEATURE_KEY); + +const { selectAll, selectEntities } = tasksAdapter.getSelectors(); + +export const selectTasksLoaded = createSelector( + selectTasksState, + (state: TasksState) => state.loaded +); + +export const selectTasksError = createSelector( + selectTasksState, + (state: TasksState) => state.error +); + +export const selectAllTasks = createSelector( + selectTasksState, + (state: TasksState) => selectAll(state) +); + +export const selectTasksEntities = createSelector( + selectTasksState, + (state: TasksState) => selectEntities(state) +); + +export const selectSelectedId = createSelector( + selectTasksState, + (state: TasksState) => state.selectedId +); + +export const selectEntity = createSelector( + selectTasksEntities, + selectSelectedId, + (entities, selectedId) => (selectedId ? entities[selectedId] : undefined) +); diff --git a/libs/mobile/tasks/data-access/src/lib/tasks-data-access.module.ts b/libs/mobile/tasks/data-access/src/lib/tasks-data-access.module.ts index d9d425e..b1f3971 100644 --- a/libs/mobile/tasks/data-access/src/lib/tasks-data-access.module.ts +++ b/libs/mobile/tasks/data-access/src/lib/tasks-data-access.module.ts @@ -1,7 +1,17 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import * as fromTasks from './+state/tasks.reducer'; +import { TasksEffects } from './+state/tasks.effects'; +import { TasksFacade } from './+state/tasks.facade'; @NgModule({ - imports: [CommonModule], + imports: [ + CommonModule, + StoreModule.forFeature(fromTasks.TASKS_FEATURE_KEY, fromTasks.tasksReducer), + EffectsModule.forFeature([TasksEffects]), + ], + providers: [TasksFacade], }) export class TasksDataAccessModule {} diff --git a/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.html b/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.html index a75c71d..dd978d1 100644 --- a/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.html +++ b/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.html @@ -25,7 +25,7 @@
- + - + diff --git a/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.spec.ts b/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.spec.ts index 140f9b6..7a3f33b 100644 --- a/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.spec.ts +++ b/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.spec.ts @@ -3,7 +3,7 @@ import { IonRouterOutlet, IonicModule } from '@ionic/angular'; import { Category, Task, - TaskService, + TasksFacade, } from '@task-ninja/mobile/tasks/data-access'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; @@ -35,8 +35,10 @@ describe('TasksPageComponent', () => { }, ]; - const taskServiceMock = { - getTasks: jest.fn().mockReturnValue(of(taskArrayMock)), + const taskFacadeMock = { + allTasks$: of(taskArrayMock), + loaded$: of(true), + init: jest.fn(), }; const routerOutletMock = { @@ -48,14 +50,14 @@ describe('TasksPageComponent', () => { declarations: [TasksPageComponent], imports: [IonicModule.forRoot()], providers: [ - { - provide: TaskService, - useValue: taskServiceMock, - }, { provide: IonRouterOutlet, useValue: routerOutletMock, }, + { + provide: TasksFacade, + useValue: taskFacadeMock, + }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); diff --git a/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.ts b/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.ts index bf9fdf9..6907edd 100644 --- a/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.ts +++ b/libs/mobile/tasks/feature/tasks/src/lib/tasks.page.ts @@ -1,8 +1,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { IonRouterOutlet, ModalController } from '@ionic/angular'; import { FiltersComponent } from '@task-ninja/mobile/shared/feature/filters'; -import { Task, TaskService } from '@task-ninja/mobile/tasks/data-access'; -import { Subject, takeUntil } from 'rxjs'; +import { Task, TasksFacade } from '@task-ninja/mobile/tasks/data-access'; +import { Observable, Subject } from 'rxjs'; @Component({ selector: 'task-ninja-tasks', @@ -10,25 +10,21 @@ import { Subject, takeUntil } from 'rxjs'; styleUrls: ['tasks.page.scss'], }) export class TasksPageComponent implements OnInit, OnDestroy { - contentLoaded = false; + contentLoaded$: Observable; + tasks$: Observable; tasks: Task[] = []; private isDestroyed$: Subject = new Subject(); constructor( - private taskService: TaskService, private modalController: ModalController, - private routerOutlet: IonRouterOutlet + private routerOutlet: IonRouterOutlet, + private tasksFacade: TasksFacade ) {} ngOnInit(): void { - this.taskService - .getTasks() - .pipe(takeUntil(this.isDestroyed$)) - .subscribe((tasks) => { - console.log(tasks); - this.contentLoaded = true; - this.tasks = tasks; - }); + this.tasksFacade.init(); + this.tasks$ = this.tasksFacade.allTasks$; + this.contentLoaded$ = this.tasksFacade.loaded$; } ngOnDestroy(): void { diff --git a/package-lock.json b/package-lock.json index 699c011..ca002e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,11 @@ "@capacitor/keyboard": "5.0.2", "@capacitor/status-bar": "5.0.2", "@ionic/angular": "^7.1.2", + "@ngrx/component-store": "~16.0.0", + "@ngrx/effects": "~16.0.0", + "@ngrx/entity": "~16.0.0", + "@ngrx/router-store": "~16.0.0", + "@ngrx/store": "~16.0.0", "firebase": "^9.23.0", "ionicons": "^7.1.2", "rxfire": "^6.0.3", @@ -49,6 +54,8 @@ "@commitlint/config-conventional": "^17.6.6", "@commitlint/config-nx-scopes": "^17.6.4", "@commitlint/cz-commitlint": "^17.5.0", + "@ngrx/schematics": "~16.0.0", + "@ngrx/store-devtools": "~16.0.0", "@nx/angular": "16.3.2", "@nx/cypress": "16.3.2", "@nx/eslint-plugin": "16.3.2", @@ -69,6 +76,7 @@ "eslint-config-prettier": "8.1.0", "eslint-plugin-cypress": "^2.10.3", "husky": "^8.0.0", + "jasmine-marbles": "~0.9.1", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-preset-angular": "~13.1.0", @@ -7023,6 +7031,90 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "node_modules/@ngrx/component-store": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/component-store/-/component-store-16.0.1.tgz", + "integrity": "sha512-c52V2bHnKV1MRjA+270oEpRa9hTUD7xP8t1eElYzV0ISisILO8xfWB9/C6XOJtjkK6+j6tHQmtiIGK9gZyIh3g==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^16.0.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/effects": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-16.0.1.tgz", + "integrity": "sha512-hpmON8p7kT44jIiruLBy3raFkYhNzQ45was0puKPkGhv41VrAoo44UcEn4Aysdx5yHaJc/CMCtI/+emFIpqgGA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^16.0.0", + "@ngrx/store": "16.0.1", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/entity": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/entity/-/entity-16.0.1.tgz", + "integrity": "sha512-LUqcFnH9+Civwq5F3bQ5CIrev6NYG89q918kDnM2ETmhIm7penjLmSj5uR8C4Byl14MpXArKaQuHQeMWovewFQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^16.0.0", + "@ngrx/store": "16.0.1", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/router-store": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/router-store/-/router-store-16.0.1.tgz", + "integrity": "sha512-2lqKK3d6g15VwBH09aIgTEKSGZ2CYcflpYyaHVja0GAg/iGPpc69P9fF76Z3GomsuMD2g8dCYYoFn6fbrqJp+Q==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": "^16.0.0", + "@angular/core": "^16.0.0", + "@angular/router": "^16.0.0", + "@ngrx/store": "16.0.1", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/schematics": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-NLRSQF6kjgVFi5/JVTFU6PuuMrQ4lL6SIK81ZPCFEIm3wZH0e4FcQhsKe0D/gqoENTunFquRR4PWr9tdbsa5pg==", + "dev": true + }, + "node_modules/@ngrx/store": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-16.0.1.tgz", + "integrity": "sha512-KkYzF3j29qKOzHcmiArRJgT+ABLqbddj1DuxerNq3A8zWnTDdC4YgNpDOKru8hQWb3pQ77ZbglLati5K9F8HnQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": "^16.0.0", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, + "node_modules/@ngrx/store-devtools": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/store-devtools/-/store-devtools-16.0.1.tgz", + "integrity": "sha512-fOk/etV2ldgPgy3BD7v0yK4ocsxNUx7iktjMOVSvfPY/eg6KsfUy0ObQ2/kJasan32wN9rADCVRD6PzLg1Zo8g==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@ngrx/store": "16.0.1", + "rxjs": "^6.5.3 || ^7.5.0" + } + }, "node_modules/@ngtools/webpack": { "version": "16.0.6", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.0.6.tgz", @@ -9476,7 +9568,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -10013,7 +10105,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8" } @@ -10147,7 +10239,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "devOptional": true, + "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -10501,7 +10593,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -12449,6 +12541,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -12458,6 +12551,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -13557,7 +13651,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "devOptional": true, + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -14190,7 +14284,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -15127,7 +15221,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "devOptional": true, + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -15220,7 +15314,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -15246,7 +15340,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -15297,7 +15391,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.12.0" } @@ -15704,6 +15798,18 @@ "node": "*" } }, + "node_modules/jasmine-marbles": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/jasmine-marbles/-/jasmine-marbles-0.9.2.tgz", + "integrity": "sha512-T7RjG4fRsdiGGzbQZ6Kj39qYt6O1/KIcR4FkUNsD3DUGkd/AzpwzN+xtk0DXlLWEz5BaVdK1SzMgQDVw879c4Q==", + "dev": true, + "dependencies": { + "lodash": "^4.17.20" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + } + }, "node_modules/jest": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.5.0.tgz", @@ -18258,7 +18364,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -19127,7 +19233,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true, + "dev": true, "engines": { "node": ">=8.6" }, @@ -20363,7 +20469,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "devOptional": true, + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -22157,7 +22263,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -24014,8 +24120,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "babel-plugin-polyfill-corejs2": { "version": "0.3.3", @@ -25481,8 +25586,7 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "requires": {} + "dev": true }, "@babel/plugin-proposal-unicode-property-regex": { "version": "7.18.6", @@ -26398,8 +26502,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.0.5.tgz", "integrity": "sha512-vH5Qoy+p2Egsu1GtPtOsihHcEI2fCGCIHwlUGPaXXGysudzpzWtJZ5JZNlycJyfRdjECrjkutgbNaHLog+YlXQ==", - "dev": true, - "requires": {} + "dev": true }, "@capacitor/cli": { "version": "5.0.5", @@ -26499,27 +26602,23 @@ "@capacitor/haptics": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-5.0.2.tgz", - "integrity": "sha512-HGkvL3a8yW26YVR7pfgMpkJrspD0+9sUrBB77SWR/GqQV+m32FTx9ECGlw+sHhwLnv0XtFx4PocIL5SlU1m3Vw==", - "requires": {} + "integrity": "sha512-HGkvL3a8yW26YVR7pfgMpkJrspD0+9sUrBB77SWR/GqQV+m32FTx9ECGlw+sHhwLnv0XtFx4PocIL5SlU1m3Vw==" }, "@capacitor/ios": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-5.0.5.tgz", "integrity": "sha512-U72TPbKN1HlUqEGCOPsCBp6j93Qu1TazWUuA8Q1yfcGDfSOE0zMDNl3eU7XO5OyzpV7z9lf8NJdehimezVl7sA==", - "dev": true, - "requires": {} + "dev": true }, "@capacitor/keyboard": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-5.0.2.tgz", - "integrity": "sha512-5qauopAd9Dlltzi87i/WHMBsocCf0BJqVxRZ3H+kifQm4BLHGokrzuanddEJd6MPOnglSl3H3C9oElJB3wkVVQ==", - "requires": {} + "integrity": "sha512-5qauopAd9Dlltzi87i/WHMBsocCf0BJqVxRZ3H+kifQm4BLHGokrzuanddEJd6MPOnglSl3H3C9oElJB3wkVVQ==" }, "@capacitor/status-bar": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-5.0.2.tgz", - "integrity": "sha512-5emXUQzx9WnQHnxzapghrqm8P80z7IPLM83PTsfN+NOkm3uw3ZOxB4T7qy9NBV2Zp+GCEpr1UrNBowxV7Ami3Q==", - "requires": {} + "integrity": "sha512-5emXUQzx9WnQHnxzapghrqm8P80z7IPLM83PTsfN+NOkm3uw3ZOxB4T7qy9NBV2Zp+GCEpr1UrNBowxV7Ami3Q==" }, "@colors/colors": { "version": "1.5.0", @@ -26557,8 +26656,7 @@ "version": "17.6.4", "resolved": "https://registry.npmjs.org/@commitlint/config-nx-scopes/-/config-nx-scopes-17.6.4.tgz", "integrity": "sha512-pUH4doxB/rPWGV4e9kwFkVEGlcnZsjSXUyk+DSguFBypTAWV/2yZipB2N02vGLhWFnfZEMKTR5rReGodaJIkrg==", - "dev": true, - "requires": {} + "dev": true }, "@commitlint/config-validator": { "version": "17.4.4", @@ -27299,8 +27397,7 @@ "@firebase/auth-types": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.12.0.tgz", - "integrity": "sha512-pPwaZt+SPOshK8xNoiQlK5XIrS97kFYc3Rc7xmy373QsOJ9MmqXxLaYssP5Kcds4wd2qK//amx/c+A8O2fVeZA==", - "requires": {} + "integrity": "sha512-pPwaZt+SPOshK8xNoiQlK5XIrS97kFYc3Rc7xmy373QsOJ9MmqXxLaYssP5Kcds4wd2qK//amx/c+A8O2fVeZA==" }, "@firebase/component": { "version": "0.6.4", @@ -27405,8 +27502,7 @@ "@firebase/firestore-types": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-2.5.1.tgz", - "integrity": "sha512-xG0CA6EMfYo8YeUxC8FeDzf6W3FX1cLlcAGBYV6Cku12sZRI81oWcu61RSKM66K6kUENP+78Qm8mvroBcm1whw==", - "requires": {} + "integrity": "sha512-xG0CA6EMfYo8YeUxC8FeDzf6W3FX1cLlcAGBYV6Cku12sZRI81oWcu61RSKM66K6kUENP+78Qm8mvroBcm1whw==" }, "@firebase/functions": { "version": "0.10.0", @@ -27501,8 +27597,7 @@ "@firebase/installations-types": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.0.tgz", - "integrity": "sha512-9DP+RGfzoI2jH7gY4SlzqvZ+hr7gYzPODrbzVD82Y12kScZ6ZpRg/i3j6rleto8vTFC8n6Len4560FnV1w2IRg==", - "requires": {} + "integrity": "sha512-9DP+RGfzoI2jH7gY4SlzqvZ+hr7gYzPODrbzVD82Y12kScZ6ZpRg/i3j6rleto8vTFC8n6Len4560FnV1w2IRg==" }, "@firebase/logger": { "version": "0.4.0", @@ -27663,8 +27758,7 @@ "@firebase/storage-types": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.0.tgz", - "integrity": "sha512-isRHcGrTs9kITJC0AVehHfpraWFui39MPaU7Eo8QfWlqW7YPymBmRgjDrlOgFdURh6Cdeg07zmkLP5tzTKRSpg==", - "requires": {} + "integrity": "sha512-isRHcGrTs9kITJC0AVehHfpraWFui39MPaU7Eo8QfWlqW7YPymBmRgjDrlOgFdURh6Cdeg07zmkLP5tzTKRSpg==" }, "@firebase/util": { "version": "1.9.3", @@ -28325,12 +28419,66 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "@ngrx/component-store": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/component-store/-/component-store-16.0.1.tgz", + "integrity": "sha512-c52V2bHnKV1MRjA+270oEpRa9hTUD7xP8t1eElYzV0ISisILO8xfWB9/C6XOJtjkK6+j6tHQmtiIGK9gZyIh3g==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/effects": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-16.0.1.tgz", + "integrity": "sha512-hpmON8p7kT44jIiruLBy3raFkYhNzQ45was0puKPkGhv41VrAoo44UcEn4Aysdx5yHaJc/CMCtI/+emFIpqgGA==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/entity": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/entity/-/entity-16.0.1.tgz", + "integrity": "sha512-LUqcFnH9+Civwq5F3bQ5CIrev6NYG89q918kDnM2ETmhIm7penjLmSj5uR8C4Byl14MpXArKaQuHQeMWovewFQ==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/router-store": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/router-store/-/router-store-16.0.1.tgz", + "integrity": "sha512-2lqKK3d6g15VwBH09aIgTEKSGZ2CYcflpYyaHVja0GAg/iGPpc69P9fF76Z3GomsuMD2g8dCYYoFn6fbrqJp+Q==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/schematics": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/schematics/-/schematics-16.0.1.tgz", + "integrity": "sha512-NLRSQF6kjgVFi5/JVTFU6PuuMrQ4lL6SIK81ZPCFEIm3wZH0e4FcQhsKe0D/gqoENTunFquRR4PWr9tdbsa5pg==", + "dev": true + }, + "@ngrx/store": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-16.0.1.tgz", + "integrity": "sha512-KkYzF3j29qKOzHcmiArRJgT+ABLqbddj1DuxerNq3A8zWnTDdC4YgNpDOKru8hQWb3pQ77ZbglLati5K9F8HnQ==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@ngrx/store-devtools": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@ngrx/store-devtools/-/store-devtools-16.0.1.tgz", + "integrity": "sha512-fOk/etV2ldgPgy3BD7v0yK4ocsxNUx7iktjMOVSvfPY/eg6KsfUy0ObQ2/kJasan32wN9rADCVRD6PzLg1Zo8g==", + "dev": true, + "requires": { + "tslib": "^2.0.0" + } + }, "@ngtools/webpack": { "version": "16.0.6", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-16.0.6.tgz", "integrity": "sha512-F6vDIAPxKqN3m0ABdHiBaf1OPb2dyDR0ABPmImxFoZtRApGjAiwuAHvsrk8aZRwBAC2XobSkemm2C0HpV6F6lw==", - "dev": true, - "requires": {} + "dev": true }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -29834,8 +29982,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.1.tgz", "integrity": "sha512-pcub+YbFtFhaGRTo1832FQHQSHvMrlb43974e2eS8EKleR3p1cDdkJFPci1UhwkEf1J9Bz+wKBSzqpKp7nNj2A==", - "dev": true, - "requires": {} + "dev": true }, "@webassemblyjs/ast": { "version": "1.11.6", @@ -30083,15 +30230,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "requires": {} + "dev": true }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} + "dev": true }, "acorn-walk": { "version": "8.2.0", @@ -30223,7 +30368,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, + "dev": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -30625,7 +30770,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "devOptional": true + "dev": true }, "bl": { "version": "4.1.0", @@ -30742,7 +30887,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "devOptional": true, + "dev": true, "requires": { "fill-range": "^7.0.1" } @@ -30981,7 +31126,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "devOptional": true, + "dev": true, "requires": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -31586,8 +31731,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-4.3.0.tgz", "integrity": "sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==", - "dev": true, - "requires": {} + "dev": true }, "create-require": { "version": "1.1.1", @@ -31651,8 +31795,7 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.0.tgz", "integrity": "sha512-jDfsatwWMWN0MODAFuHszfjphEXfNw9JUAhmY4pLu3TyTU+ohUpsbVtbU+1MZn4a47D9kqh03i4eyOm+74+zew==", - "dev": true, - "requires": {} + "dev": true }, "css-loader": { "version": "6.8.1", @@ -31818,8 +31961,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", - "dev": true, - "requires": {} + "dev": true }, "csso": { "version": "4.2.0", @@ -32468,6 +32610,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "requires": { "iconv-lite": "^0.6.2" @@ -32477,6 +32620,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "requires": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -32821,8 +32965,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz", "integrity": "sha512-oKMhGv3ihGbCIimCAjqkdzx2Q+jthoqnXSP+d86M9tptwugycmTFdVR4IpLgq2c4SHifbwO90z2fQ8/Aio73yw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-cypress": { "version": "2.13.3", @@ -33258,8 +33401,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "json-schema-traverse": { "version": "0.4.1", @@ -33324,7 +33466,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "devOptional": true, + "dev": true, "requires": { "to-regex-range": "^5.0.1" } @@ -33545,8 +33687,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "cosmiconfig": { "version": "7.1.0", @@ -33808,7 +33949,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, + "dev": true, "requires": { "is-glob": "^4.0.1" } @@ -34260,8 +34401,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "requires": {} + "dev": true }, "idb": { "version": "7.1.1", @@ -34518,7 +34658,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "devOptional": true, + "dev": true, "requires": { "binary-extensions": "^2.0.0" } @@ -34575,7 +34715,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true + "dev": true }, "is-fullwidth-code-point": { "version": "3.0.0", @@ -34592,7 +34732,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, + "dev": true, "requires": { "is-extglob": "^2.1.1" } @@ -34628,7 +34768,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true + "dev": true }, "is-number-object": { "version": "1.0.7", @@ -34919,6 +35059,15 @@ } } }, + "jasmine-marbles": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/jasmine-marbles/-/jasmine-marbles-0.9.2.tgz", + "integrity": "sha512-T7RjG4fRsdiGGzbQZ6Kj39qYt6O1/KIcR4FkUNsD3DUGkd/AzpwzN+xtk0DXlLWEz5BaVdK1SzMgQDVw879c4Q==", + "dev": true, + "requires": { + "lodash": "^4.17.20" + } + }, "jest": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.5.0.tgz", @@ -35163,8 +35312,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} + "dev": true }, "jest-preset-angular": { "version": "13.1.1", @@ -36820,7 +36968,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "devOptional": true + "dev": true }, "normalize-range": { "version": "0.1.2", @@ -37489,7 +37637,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "devOptional": true + "dev": true }, "pidtree": { "version": "0.6.0", @@ -37627,29 +37775,25 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", - "dev": true, - "requires": {} + "dev": true }, "postcss-discard-duplicates": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", - "dev": true, - "requires": {} + "dev": true }, "postcss-discard-empty": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", - "dev": true, - "requires": {} + "dev": true }, "postcss-discard-overridden": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", - "dev": true, - "requires": {} + "dev": true }, "postcss-import": { "version": "14.1.0", @@ -37751,8 +37895,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", - "dev": true, - "requires": {} + "dev": true }, "postcss-modules-local-by-default": { "version": "4.0.3", @@ -37787,8 +37930,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", - "dev": true, - "requires": {} + "dev": true }, "postcss-normalize-display-values": { "version": "5.1.0", @@ -38332,7 +38474,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "devOptional": true, + "dev": true, "requires": { "picomatch": "^2.2.1" } @@ -39336,8 +39478,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.3.tgz", "integrity": "sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==", - "dev": true, - "requires": {} + "dev": true }, "stylehacks": { "version": "5.1.1", @@ -39559,8 +39700,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "jest-worker": { "version": "27.5.1", @@ -39680,7 +39820,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, + "dev": true, "requires": { "is-number": "^7.0.0" } @@ -40190,8 +40330,7 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} + "dev": true }, "json-schema-traverse": { "version": "0.4.1", @@ -40498,8 +40637,7 @@ "version": "8.13.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", - "dev": true, - "requires": {} + "dev": true }, "xml-name-validator": { "version": "4.0.0", diff --git a/package.json b/package.json index c3c78a9..6681d5c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@commitlint/config-conventional": "^17.6.6", "@commitlint/config-nx-scopes": "^17.6.4", "@commitlint/cz-commitlint": "^17.5.0", + "@ngrx/schematics": "~16.0.0", + "@ngrx/store-devtools": "~16.0.0", "@nx/angular": "16.3.2", "@nx/cypress": "16.3.2", "@nx/eslint-plugin": "16.3.2", @@ -45,6 +47,7 @@ "eslint-config-prettier": "8.1.0", "eslint-plugin-cypress": "^2.10.3", "husky": "^8.0.0", + "jasmine-marbles": "~0.9.1", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-preset-angular": "~13.1.0", @@ -72,6 +75,11 @@ "@capacitor/keyboard": "5.0.2", "@capacitor/status-bar": "5.0.2", "@ionic/angular": "^7.1.2", + "@ngrx/component-store": "~16.0.0", + "@ngrx/effects": "~16.0.0", + "@ngrx/entity": "~16.0.0", + "@ngrx/router-store": "~16.0.0", + "@ngrx/store": "~16.0.0", "firebase": "^9.23.0", "ionicons": "^7.1.2", "rxfire": "^6.0.3",