Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/mobile/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
})
Expand Down
5 changes: 5 additions & 0 deletions libs/mobile/auth/data-access/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
14 changes: 14 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.actions.ts
Original file line number Diff line number Diff line change
@@ -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 }>()
);
39 changes: 39 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.effects.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Action>;
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);
});
});
});
21 changes: 21 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.effects.ts
Original file line number Diff line number Diff line change
@@ -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 }));
})
)
);
}
98 changes: 98 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.facade.spec.ts
Original file line number Diff line number Diff line change
@@ -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<TestSchema>;
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);
});
});
});
27 changes: 27 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.facade.ts
Original file line number Diff line number Diff line change
@@ -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());
}
}
7 changes: 7 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Interface for the 'Auth' data
*/
export interface AuthEntity {
id: string | number; // Primary ID
name: string;
}
37 changes: 37 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.reducer.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
42 changes: 42 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.reducer.ts
Original file line number Diff line number Diff line change
@@ -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<AuthEntity> {
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<AuthEntity> =
createEntityAdapter<AuthEntity>();

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);
}
66 changes: 66 additions & 0 deletions libs/mobile/auth/data-access/src/lib/+state/auth.selectors.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading