feat(rxjs): refactor challenge 11

This commit is contained in:
thomas
2025-01-29 20:25:35 +01:00
parent 2050f7a932
commit 3cea14982e
111 changed files with 31 additions and 2787 deletions

View File

@@ -1,36 +0,0 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -1,13 +0,0 @@
# Effect vs Selector
> author: thomas-laforge
### Run Application
```bash
npx nx serve ngrx-effect-vs-selector
```
### Documentation and Instruction
Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/ngrx/2-effect-selector/).

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
export default {
displayName: 'ngrx-effect-vs-selector',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
coverageDirectory: '../../../coverage/apps/ngrx/2-effect-vs-selector',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -1,84 +0,0 @@
{
"name": "ngrx-effect-vs-selector",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/ngrx/2-effect-vs-selector/src",
"prefix": "app",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:browser",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/ngrx/2-effect-vs-selector",
"index": "apps/ngrx/2-effect-vs-selector/src/index.html",
"main": "apps/ngrx/2-effect-vs-selector/src/main.ts",
"polyfills": "apps/ngrx/2-effect-vs-selector/src/polyfills.ts",
"tsConfig": "apps/ngrx/2-effect-vs-selector/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/ngrx/2-effect-vs-selector/src/favicon.ico",
"apps/ngrx/2-effect-vs-selector/src/assets"
],
"styles": ["apps/ngrx/2-effect-vs-selector/src/styles.scss"],
"scripts": [],
"allowedCommonJsDependencies": ["seedrandom"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "ngrx-effect-vs-selector:build:production"
},
"development": {
"buildTarget": "ngrx-effect-vs-selector:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "ngrx-effect-vs-selector:build"
}
},
"test": {
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverage": true
}
}
}
}
}

View File

@@ -1,71 +0,0 @@
import { AsyncPipe, NgFor } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
inject,
} from '@angular/core';
import { Store } from '@ngrx/store';
import { loadActivities } from './store/activity/activity.actions';
import { ActivityType } from './store/activity/activity.model';
import { selectActivities } from './store/activity/activity.selectors';
import { loadStatuses } from './store/status/status.actions';
import { selectAllTeachersByActivityType } from './store/status/status.selectors';
import { loadUsers } from './store/user/user.actions';
@Component({
selector: 'app-root',
imports: [NgFor, AsyncPipe],
template: `
<h1>Activity Board</h1>
<section>
<div class="card" *ngFor="let activity of activities$ | async">
<h2>Activity Name: {{ activity.name }}</h2>
<p>Main teacher: {{ activity.teacher.name }}</p>
<span>All teachers available for : {{ activity.type }} are</span>
<ul>
<li
*ngFor="
let teacher of getAllTeachersForActivityType$(activity.type)
| async
">
{{ teacher.name }}
</li>
</ul>
</div>
</section>
`,
styles: [
`
section {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 2px;
}
.card {
display: flex;
flex-direction: column;
border: solid;
border-width: 1px;
border-color: black;
padding: 2px;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
private store = inject(Store);
activities$ = this.store.select(selectActivities);
ngOnInit(): void {
this.store.dispatch(loadActivities());
this.store.dispatch(loadUsers());
this.store.dispatch(loadStatuses());
}
getAllTeachersForActivityType$ = (type: ActivityType) =>
this.store.select(selectAllTeachersByActivityType(type));
}

View File

@@ -1,27 +0,0 @@
import { ApplicationConfig } from '@angular/core';
import { provideEffects } from '@ngrx/effects';
import { provideStore } from '@ngrx/store';
import { ActivityEffects } from './store/activity/activity.effects';
import {
activityFeatureKey,
activityReducer,
} from './store/activity/activity.reducer';
import { StatusEffects } from './store/status/status.effects';
import { UserEffects } from './store/user/user.effects';
import { statusFeatureKey, statusReducer } from './store/status/status.reducer';
import { userFeatureKey, userReducer } from './store/user/user.reducer';
const reducers = {
[statusFeatureKey]: statusReducer,
[activityFeatureKey]: activityReducer,
[userFeatureKey]: userReducer,
};
export const appConfig: ApplicationConfig = {
providers: [
provideStore(reducers),
provideEffects([ActivityEffects, UserEffects, StatusEffects]),
],
};

View File

@@ -1,14 +0,0 @@
import { createAction, props } from '@ngrx/store';
import { Activity } from './activity.model';
export const loadActivities = createAction('[Activity Effect] Load Activities');
export const loadActivitiesSuccess = createAction(
'[Activity Effect] Load Activities Success',
props<{ activities: Activity[] }>(),
);
export const loadActivitiesFailure = createAction(
'[Activity Effect] Load Activities Failure',
props<{ error: unknown }>(),
);

View File

@@ -1,30 +0,0 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, concatMap, map } from 'rxjs/operators';
import * as ActivityActions from './activity.actions';
import { ActivityService } from './activity.service';
@Injectable()
export class ActivityEffects {
loadActivities$ = createEffect(() => {
return this.actions$.pipe(
ofType(ActivityActions.loadActivities),
concatMap(() =>
this.ActivityService.fetchActivities().pipe(
map((activities) =>
ActivityActions.loadActivitiesSuccess({ activities }),
),
catchError((error) =>
of(ActivityActions.loadActivitiesFailure({ error })),
),
),
),
);
});
constructor(
private actions$: Actions,
private ActivityService: ActivityService,
) {}
}

View File

@@ -1,55 +0,0 @@
import {
incrementalNumber,
rand,
randFirstName,
randText,
} from '@ngneat/falso';
export const activityType = [
'Sport',
'Sciences',
'History',
'Maths',
'Physics',
] as const;
export type ActivityType = (typeof activityType)[number];
export interface Person {
id: number;
name: string;
}
export interface Activity {
id: number;
name: string;
type: ActivityType;
teacher: Person;
}
const factoryPerson = incrementalNumber();
export const randPerson = () => ({
id: factoryPerson(),
name: randFirstName(),
});
const factoryActivity = incrementalNumber();
export const randActivity = (): Activity => ({
id: factoryActivity(),
name: randText(),
type: rand(activityType),
teacher: randPerson(),
});
export const activities: Activity[] = [
randActivity(),
randActivity(),
randActivity(),
randActivity(),
randActivity(),
randActivity(),
randActivity(),
randActivity(),
randActivity(),
];

View File

@@ -1,25 +0,0 @@
import { createReducer, on } from '@ngrx/store';
import * as ActivityActions from './activity.actions';
import { Activity } from './activity.model';
export const activityFeatureKey = 'Activity';
export interface ActivityState {
activities: Activity[];
}
export const initialState: ActivityState = {
activities: [],
};
export const activityReducer = createReducer(
initialState,
on(ActivityActions.loadActivitiesSuccess, (state, { activities }) => ({
...state,
activities,
})),
on(ActivityActions.loadActivitiesFailure, (state) => ({
state,
activities: [],
})),
);

View File

@@ -1,10 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ActivityState, activityFeatureKey } from './activity.reducer';
export const selectActivityState =
createFeatureSelector<ActivityState>(activityFeatureKey);
export const selectActivities = createSelector(
selectActivityState,
(state) => state.activities,
);

View File

@@ -1,10 +0,0 @@
import { Injectable } from '@angular/core';
import { map, timer } from 'rxjs';
import { activities } from './activity.model';
@Injectable({
providedIn: 'root',
})
export class ActivityService {
fetchActivities = () => timer(500).pipe(map(() => activities));
}

View File

@@ -1,9 +0,0 @@
import { createAction, props } from '@ngrx/store';
import { Status } from './status.model';
export const loadStatuses = createAction('[Status] Load Statuses');
export const loadStatusesSuccess = createAction(
'[Status] Load Statuses Success',
props<{ statuses: Status[] }>(),
);

View File

@@ -1,52 +0,0 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { combineLatest, concatMap, map } from 'rxjs';
import { selectActivities } from '../activity/activity.selectors';
import { selectUser } from '../user/user.selectors';
import * as StatusActions from './status.actions';
import { Status } from './status.model';
@Injectable()
export class StatusEffects {
loadStatuses$ = createEffect(() => {
return this.actions$.pipe(
ofType(StatusActions.loadStatuses),
concatMap(() =>
combineLatest([
this.store.select(selectUser),
this.store.select(selectActivities),
]).pipe(
map(([user, activities]): Status[] => {
if (user?.isAdmin) {
return activities.reduce(
(status: Status[], activity): Status[] => {
const index = status.findIndex(
(s) => s.name === activity.type,
);
if (index === -1) {
return [
...status,
{ name: activity.type, teachers: [activity.teacher] },
];
} else {
status[index].teachers.push(activity.teacher);
return status;
}
},
[],
);
}
return [];
}),
map((statuses) => StatusActions.loadStatusesSuccess({ statuses })),
),
),
);
});
constructor(
private actions$: Actions,
private store: Store,
) {}
}

View File

@@ -1,6 +0,0 @@
import { ActivityType, Person } from '../activity/activity.model';
export interface Status {
name: ActivityType;
teachers: Person[];
}

View File

@@ -1,29 +0,0 @@
import { createReducer, on } from '@ngrx/store';
import { ActivityType, Person } from '../activity/activity.model';
import * as StatusActions from './status.actions';
import { Status } from './status.model';
export const statusFeatureKey = 'status';
export interface StatusState {
statuses: Status[];
teachersMap: Map<ActivityType, Person[]>;
}
export const initialState: StatusState = {
statuses: [],
teachersMap: new Map(),
};
export const statusReducer = createReducer(
initialState,
on(StatusActions.loadStatusesSuccess, (state, { statuses }): StatusState => {
const map = new Map();
statuses.forEach((s) => map.set(s.name, s.teachers));
return {
...state,
statuses,
teachersMap: map,
};
}),
);

View File

@@ -1,17 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ActivityType } from '../activity/activity.model';
import { StatusState, statusFeatureKey } from './status.reducer';
export const selectStatusState =
createFeatureSelector<StatusState>(statusFeatureKey);
export const selectStatuses = createSelector(
selectStatusState,
(state) => state.statuses,
);
export const selectAllTeachersByActivityType = (name: ActivityType) =>
createSelector(
selectStatusState,
(state) => state.teachersMap.get(name) ?? [],
);

View File

@@ -1,14 +0,0 @@
import { createAction, props } from '@ngrx/store';
import { User } from './user.model';
export const loadUsers = createAction('[User] Load Users');
export const loadUsersSuccess = createAction(
'[User] Load Users Success',
props<{ user: User }>(),
);
export const loadUsersFailure = createAction(
'[User] Load Users Failure',
props<{ error: unknown }>(),
);

View File

@@ -1,26 +0,0 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, concatMap, map } from 'rxjs/operators';
import * as UserActions from './user.actions';
import { UserService } from './user.service';
@Injectable()
export class UserEffects {
loadUsers$ = createEffect(() => {
return this.actions$.pipe(
ofType(UserActions.loadUsers),
concatMap(() =>
this.userService.fetchUser().pipe(
map((user) => UserActions.loadUsersSuccess({ user })),
catchError((error) => of(UserActions.loadUsersFailure({ error }))),
),
),
);
});
constructor(
private actions$: Actions,
private userService: UserService,
) {}
}

View File

@@ -1,15 +0,0 @@
import { randFirstName, randLastName, randText } from '@ngneat/falso';
export interface User {
firstname: string;
lastname: string;
isAdmin: boolean;
apiKey: string;
}
export const user: User = {
firstname: randFirstName(),
lastname: randLastName(),
isAdmin: true,
apiKey: randText(),
};

View File

@@ -1,19 +0,0 @@
import { createReducer, on } from '@ngrx/store';
import * as UserActions from './user.actions';
import { User } from './user.model';
export const userFeatureKey = 'user';
export interface UserState {
user?: User;
}
export const initialState: UserState = {
user: undefined,
};
export const userReducer = createReducer(
initialState,
on(UserActions.loadUsersSuccess, (state, { user }) => ({ ...state, user })),
on(UserActions.loadUsersFailure, (state) => ({ ...state, user: undefined })),
);

View File

@@ -1,9 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UserState, userFeatureKey } from './user.reducer';
export const selectUserState = createFeatureSelector<UserState>(userFeatureKey);
export const selectUser = createSelector(
selectUserState,
(state) => state.user,
);

View File

@@ -1,10 +0,0 @@
import { Injectable } from '@angular/core';
import { map, timer } from 'rxjs';
import { user } from './user.model';
@Injectable({
providedIn: 'root',
})
export class UserService {
fetchUser = () => timer(500).pipe(map(() => user));
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ngrx-effect-vs-selector</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,6 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig);

View File

@@ -1,52 +0,0 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes recent versions of Safari, Chrome (including
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [],
"target": "ES2022",
"useDefineForClassFields": false
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts"],
"compilerOptions": {
"types": ["jest", "node"]
}
}

View File

@@ -1,31 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.editor.json"
}
],
"compilerOptions": {
"target": "es2020",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
}

View File

@@ -1,36 +0,0 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -1,13 +0,0 @@
# Power of Effect
> author: thomas-laforge
### Run Application
```bash
npx nx serve ngrx-power-of-effect
```
### Documentation and Instruction
Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/ngrx/7-power-effect/).

View File

@@ -1,76 +0,0 @@
{
"name": "ngrx-power-of-effect",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/ngrx/7-power-of-effect/src",
"prefix": "app",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:browser",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/ngrx/7-power-of-effect",
"index": "apps/ngrx/7-power-of-effect/src/index.html",
"main": "apps/ngrx/7-power-of-effect/src/main.ts",
"polyfills": "apps/ngrx/7-power-of-effect/src/polyfills.ts",
"tsConfig": "apps/ngrx/7-power-of-effect/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/ngrx/7-power-of-effect/src/favicon.ico",
"apps/ngrx/7-power-of-effect/src/assets"
],
"styles": [
"apps/ngrx/7-power-of-effect/src/styles.scss",
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css"
],
"scripts": [],
"allowedCommonJsDependencies": ["seedrandom"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "ngrx-power-of-effect:build:production"
},
"development": {
"buildTarget": "ngrx-power-of-effect:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "ngrx-power-of-effect:build"
}
}
}
}

View File

@@ -1,9 +0,0 @@
import { createActionGroup, emptyProps } from '@ngrx/store';
// This is the global actions.
export const appActions = createActionGroup({
source: 'App Component',
events: {
'Init App': emptyProps(),
},
});

View File

@@ -1,39 +0,0 @@
import { Component, inject, OnInit } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import { appActions } from './app.actions';
@Component({
imports: [RouterOutlet, RouterLink],
selector: 'app-root',
template: `
<nav>
<button routerLink="/teacher">Teacher</button>
<button routerLink="/student">Student</button>
<button routerLink="/school">School</button>
</nav>
<router-outlet></router-outlet>
`,
styles: [
`
:host {
display: flex;
flex-direction: column;
gap: 20px;
nav {
display: flex;
gap: 20px;
}
}
`,
],
})
export class AppComponent implements OnInit {
private store = inject(Store);
ngOnInit(): void {
this.store.dispatch(appActions.initApp());
}
}

View File

@@ -1,50 +0,0 @@
import { FakeBackendService } from '@angular-challenges/power-of-effect/backend';
import {
ApplicationConfig,
inject,
provideAppInitializer,
} from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter } from '@angular/router';
import { provideEffects } from '@ngrx/effects';
import { provideStore } from '@ngrx/store';
import { NotificationService } from './data-access/notification.service';
import { ROUTES } from './routes';
import { StudentEffects } from './student/store/student.effects';
import {
studentReducer,
studentsFeatureKey,
} from './student/store/student.reducer';
import { TeacherEffects } from './teacher/store/teacher.effects';
import {
teacherReducer,
teachersFeatureKey,
} from './teacher/store/teacher.reducer';
const REDUCERS = {
[teachersFeatureKey]: teacherReducer,
[studentsFeatureKey]: studentReducer,
};
export const appConfig: ApplicationConfig = {
providers: [
provideStore(REDUCERS),
provideEffects([TeacherEffects, StudentEffects]),
provideRouter(ROUTES),
provideAppInitializer(() => {
const initializerFn = (() => {
const service = inject(FakeBackendService);
return () => service.start();
})();
return initializerFn();
}),
provideAppInitializer(() => {
const initializerFn = (() => {
const service = inject(NotificationService);
return () => service.init();
})();
return initializerFn();
}),
provideAnimations(),
],
};

View File

@@ -1,14 +0,0 @@
import { FakeBackendService } from '@angular-challenges/power-of-effect/backend';
import { inject, Injectable } from '@angular/core';
import { take } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class HttpService {
private fakeBackend = inject(FakeBackendService);
getAllTeachers = () => this.fakeBackend.getAllTeachers().pipe(take(1));
getAllStudents = () => this.fakeBackend.getAllStudents().pipe(take(1));
getAllSchools = () => this.fakeBackend.getAllSchools().pipe(take(1));
}

View File

@@ -1,39 +0,0 @@
import { PushService } from '@angular-challenges/power-of-effect/backend';
import {
isSchool,
isStudent,
isTeacher,
Push,
} from '@angular-challenges/power-of-effect/model';
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { filter } from 'rxjs';
import { studentActions } from '../student/store/student.actions';
import { teacherActions } from '../teacher/store/teacher.actions';
@Injectable({ providedIn: 'root' })
export class NotificationService {
private pushService = inject(PushService);
private store = inject(Store);
init() {
this.pushService.notification$
.pipe(filter(Boolean))
.subscribe((notification: Push) => {
if (isTeacher(notification)) {
this.store.dispatch(
teacherActions.addOneTeacher({ teacher: notification }),
);
}
if (isStudent(notification)) {
this.store.dispatch(
studentActions.addOneStudent({ student: notification }),
);
}
if (isSchool(notification)) {
// SchoolStore is a ComponentStore. We can't dispatch a school action here.
// We are stuck. We must have done something wrong and need to refactor...
}
});
}
}

View File

@@ -1,20 +0,0 @@
import { Route } from '@angular/router';
import { TeacherComponent } from './teacher/teacher.component';
export const ROUTES: Route[] = [
{ path: '', pathMatch: 'full', redirectTo: 'teacher' },
{
path: 'teacher',
component: TeacherComponent,
},
{
path: 'student',
loadComponent: () =>
import('./student/student.component').then((m) => m.StudentComponent),
},
{
path: 'school',
loadComponent: () =>
import('./school/school.component').then((m) => m.SchoolComponent),
},
];

View File

@@ -1,32 +0,0 @@
/* eslint-disable @angular-eslint/component-selector */
import { AsyncPipe, NgFor } from '@angular/common';
import { Component, inject } from '@angular/core';
import { provideComponentStore } from '@ngrx/component-store';
import { SchoolStore } from './school.store';
@Component({
imports: [NgFor, AsyncPipe],
providers: [provideComponentStore(SchoolStore)],
selector: 'school',
template: `
<h3>SCHOOL</h3>
<div *ngFor="let school of school$ | async">
{{ school.name }} - {{ school.version }}
</div>
`,
styles: [
`
:host {
display: block;
width: fit-content;
height: fit-content;
border: 1px solid red;
padding: 4px;
}
`,
],
})
export class SchoolComponent {
private store = inject(SchoolStore);
school$ = this.store.schools$;
}

View File

@@ -1,45 +0,0 @@
import { School } from '@angular-challenges/power-of-effect/model';
import { Injectable } from '@angular/core';
import { ComponentStore, OnStoreInit } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { pipe, switchMap } from 'rxjs';
import { HttpService } from '../data-access/http.service';
@Injectable()
export class SchoolStore
extends ComponentStore<{ schools: School[] }>
implements OnStoreInit
{
readonly schools$ = this.select((state) => state.schools);
constructor(private httpService: HttpService) {
super({ schools: [] });
}
addSchool = this.updater((state, school: School) => ({
...state,
schools: [...state.schools, school],
}));
updateSchool = this.updater((state, school: School) => ({
...state,
schools: state.schools.map((t) => (t.id === school.id ? school : t)),
}));
private readonly loadSchools = this.effect<void>(
pipe(
switchMap(() =>
this.httpService.getAllSchools().pipe(
tapResponse(
(schools) => this.patchState({ schools }),
(_) => _, // not handling the error
),
),
),
),
);
ngrxOnStoreInit() {
this.loadSchools();
}
}

View File

@@ -1,10 +0,0 @@
import { Student } from '@angular-challenges/power-of-effect/model';
import { createActionGroup, props } from '@ngrx/store';
export const studentActions = createActionGroup({
source: 'Student API',
events: {
'Add One Student': props<{ student: Student }>(),
'Add All Students': props<{ students: Student[] }>(),
},
});

View File

@@ -1,23 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, switchMap } from 'rxjs';
import { appActions } from '../../app.actions';
import { HttpService } from '../../data-access/http.service';
import { studentActions } from './student.actions';
@Injectable()
export class StudentEffects {
private actions$ = inject(Actions);
private httpService = inject(HttpService);
loadStudents$ = createEffect(() =>
this.actions$.pipe(
ofType(appActions.initApp),
switchMap(() =>
this.httpService
.getAllStudents()
.pipe(map((students) => studentActions.addAllStudents({ students }))),
),
),
);
}

View File

@@ -1,24 +0,0 @@
import { Student } from '@angular-challenges/power-of-effect/model';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { studentActions } from './student.actions';
export const studentsFeatureKey = 'students';
export type StudentState = EntityState<Student>;
export const studentAdapter: EntityAdapter<Student> =
createEntityAdapter<Student>();
export const studentReducer = createReducer(
studentAdapter.getInitialState(),
on(studentActions.addOneStudent, (state, { student }) =>
studentAdapter.upsertOne(student, state),
),
on(studentActions.addAllStudents, (state, { students }) =>
studentAdapter.setAll(students, state),
),
);
export const { selectIds, selectEntities, selectAll, selectTotal } =
studentAdapter.getSelectors();

View File

@@ -1,17 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import {
StudentState,
studentAdapter,
studentsFeatureKey,
} from './student.reducer';
const selectStudentState =
createFeatureSelector<StudentState>(studentsFeatureKey);
export const { selectAll } = studentAdapter.getSelectors();
const selectStudents = createSelector(selectStudentState, selectAll);
export const StudentSelectors = {
selectStudents,
};

View File

@@ -1,31 +0,0 @@
/* eslint-disable @angular-eslint/component-selector */
import { AsyncPipe, NgFor } from '@angular/common';
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { StudentSelectors } from './store/student.selectors';
@Component({
imports: [NgFor, AsyncPipe],
selector: 'student',
template: `
<h3>STUDENTS</h3>
<div *ngFor="let student of students$ | async">
{{ student.firstname }} {{ student.lastname }} - {{ student.version }}
</div>
`,
styles: [
`
:host {
display: block;
width: fit-content;
height: fit-content;
border: 1px solid red;
padding: 4px;
}
`,
],
})
export class StudentComponent {
private store = inject(Store);
students$ = this.store.select(StudentSelectors.selectStudents);
}

View File

@@ -1,10 +0,0 @@
import { Teacher } from '@angular-challenges/power-of-effect/model';
import { createActionGroup, props } from '@ngrx/store';
export const teacherActions = createActionGroup({
source: 'Teacher API',
events: {
'Add One Teacher': props<{ teacher: Teacher }>(),
'Add All Teachers': props<{ teachers: Teacher[] }>(),
},
});

View File

@@ -1,23 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, switchMap } from 'rxjs';
import { appActions } from '../../app.actions';
import { HttpService } from '../../data-access/http.service';
import { teacherActions } from './teacher.actions';
@Injectable()
export class TeacherEffects {
private actions$ = inject(Actions);
private httpService = inject(HttpService);
loadTeachers$ = createEffect(() =>
this.actions$.pipe(
ofType(appActions.initApp),
switchMap(() =>
this.httpService
.getAllTeachers()
.pipe(map((teachers) => teacherActions.addAllTeachers({ teachers }))),
),
),
);
}

View File

@@ -1,24 +0,0 @@
import { Teacher } from '@angular-challenges/power-of-effect/model';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { teacherActions } from './teacher.actions';
export const teachersFeatureKey = 'teachers';
export type TeacherState = EntityState<Teacher>;
export const teacherAdapter: EntityAdapter<Teacher> =
createEntityAdapter<Teacher>();
export const teacherReducer = createReducer(
teacherAdapter.getInitialState(),
on(teacherActions.addOneTeacher, (state, { teacher }) =>
teacherAdapter.upsertOne(teacher, state),
),
on(teacherActions.addAllTeachers, (state, { teachers }) =>
teacherAdapter.setAll(teachers, state),
),
);
export const { selectIds, selectEntities, selectAll, selectTotal } =
teacherAdapter.getSelectors();

View File

@@ -1,17 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import {
TeacherState,
teacherAdapter,
teachersFeatureKey,
} from './teacher.reducer';
const selectTeacherState =
createFeatureSelector<TeacherState>(teachersFeatureKey);
export const { selectAll } = teacherAdapter.getSelectors();
const selectTeachers = createSelector(selectTeacherState, selectAll);
export const TeacherSelectors = {
selectTeachers,
};

View File

@@ -1,32 +0,0 @@
/* eslint-disable @angular-eslint/component-selector */
import { AsyncPipe, NgFor } from '@angular/common';
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { TeacherSelectors } from './store/teacher.selectors';
@Component({
imports: [NgFor, AsyncPipe],
selector: 'teacher',
template: `
<h3>TEACHERS</h3>
<div *ngFor="let teacher of teacher$ | async">
{{ teacher.firstname }} {{ teacher.lastname }} - {{ teacher.version }}
</div>
`,
styles: [
`
:host {
display: block;
width: fit-content;
height: fit-content;
border: 1px solid red;
padding: 4px;
}
`,
],
})
export class TeacherComponent {
teacher$ = this.store.select(TeacherSelectors.selectTeachers);
constructor(private store: Store) {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ngrx-power-of-effect</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,9 +0,0 @@
import { appConfig } from './app/app.config';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

View File

@@ -1,52 +0,0 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes recent versions of Safari, Chrome (including
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -1 +0,0 @@
/* You can add global styles to this file, and also import other style files */

View File

@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [],
"target": "ES2022",
"useDefineForClassFields": false
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"include": [
"**/*.ts",
"../../../libs/power-of-effect/backend/src/lib/fake-backend.service.ts"
],
"compilerOptions": {
"types": []
}
}

View File

@@ -1,28 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.editor.json"
}
],
"compilerOptions": {
"target": "es2020",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -1,46 +1,44 @@
/* eslint-disable @angular-eslint/component-selector */
import { AsyncPipe, NgFor } from '@angular/common';
import { Component, Input, inject } from '@angular/core';
import { BehaviorSubject, take } from 'rxjs';
import { Component, inject, input, signal } from '@angular/core';
import { take } from 'rxjs';
import { AppService } from './app.service';
import { TopicType } from './localDB.service';
@Component({
selector: 'button-delete-topic',
imports: [AsyncPipe],
template: `
<button (click)="deleteTopic()"><ng-content></ng-content></button>
<div>{{ message$$ | async }}</div>
<button (click)="deleteTopic()"><ng-content /></button>
<div>{{ message() }}</div>
`,
})
export class ButtonDeleteComponent {
@Input() topic!: TopicType;
readonly topic = input.required<TopicType>();
message$$ = new BehaviorSubject<string>('');
message = signal('');
private service = inject(AppService);
deleteTopic() {
this.service
.deleteOldTopics(this.topic)
.deleteOldTopics(this.topic())
.pipe(take(1))
.subscribe((result) =>
this.message$$.next(
this.message.set(
result
? `All ${this.topic} have been deleted`
: `Error: deletion of some ${this.topic} failed`,
? `All ${this.topic()} have been deleted`
: `Error: deletion of some ${this.topic()} failed`,
),
);
}
}
@Component({
imports: [AsyncPipe, NgFor, ButtonDeleteComponent],
imports: [ButtonDeleteComponent],
selector: 'app-root',
template: `
<div *ngFor="let item of all$ | async">
{{ item.id }} - {{ item.topic }}
</div>
@for (info of allInfo(); track info.id) {
<div>{{ info.id }} - {{ info.topic }}</div>
}
<button-delete-topic topic="food">Delete Food</button-delete-topic>
<button-delete-topic topic="sport">Delete Sport</button-delete-topic>
@@ -50,5 +48,5 @@ export class ButtonDeleteComponent {
export class AppComponent {
private service = inject(AppService);
all$ = this.service.getAll$;
allInfo = this.service.getAllInfo;
}

View File

@@ -1,23 +1,19 @@
import { inject, Injectable } from '@angular/core';
import { merge, mergeMap, Observable, of, take } from 'rxjs';
import { merge, Observable, of } from 'rxjs';
import { LocalDBService, TopicType } from './localDB.service';
@Injectable({ providedIn: 'root' })
export class AppService {
private dbService = inject(LocalDBService);
getAll$ = this.dbService.infos$;
getAllInfo = this.dbService.infos;
deleteOldTopics(type: TopicType): Observable<boolean> {
return this.dbService.searchByType(type).pipe(
take(1),
mergeMap((topicToDelete) =>
topicToDelete.length > 0
? topicToDelete
const infoByType = this.dbService.searchByType(type);
return infoByType.length > 0
? infoByType
.map((t) => this.dbService.deleteOneTopic(t.id))
.reduce((acc, curr) => merge(acc, curr), of(true))
: of(true),
),
);
: of(true);
}
}

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { randomError } from '@angular-challenges/shared/utils';
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { computed, Injectable, signal } from '@angular/core';
import { of } from 'rxjs';
export type TopicType = 'food' | 'book' | 'sport';
@@ -31,21 +30,17 @@ const initialState: DBState = {
};
@Injectable({ providedIn: 'root' })
export class LocalDBService extends ComponentStore<DBState> {
constructor() {
super(initialState);
}
export class LocalDBService {
private state = signal(initialState);
infos$ = this.select((state) => state.infos);
infos = computed(() => this.state().infos);
searchByType = (type: TopicType) =>
this.select((state) => state.infos.filter((i) => i.topic === type));
this.infos().filter((i) => i.topic === type);
deleteOne = this.updater(
(state, id: number): DBState => ({
infos: state.infos.filter((i) => i.id !== id),
}),
);
deleteOne = (id: number) => {
this.state.set({ infos: this.state().infos.filter((i) => i.id !== id) });
};
deleteOneTopic = (id: number) =>
randomError({

View File

@@ -1,40 +0,0 @@
---
title: 🟠 Effect vs Selector
description: Challenge 2 is about learning the difference between effects and selectors in NgRx
author: thomas-laforge
contributors:
- tomalaforge
- tomer953
- svenson95
- jdegand
- LMFinney
challengeNumber: 2
command: ngrx-effect-vs-selector
blogLink: https://medium.com/@thomas.laforge/ngrx-effect-vs-reducer-vs-selector-58337ab59043
videoLinks:
- link: https://youtu.be/7fr6JBRocQM
alt: Effect vs selector video by Amos Lucian Isaila
flag: ES
sidebar:
order: 113
---
For this exercise, you will have a dashboard of activities displaying the name, the main teacher and a list of possible substitutes.
## Information
In NgRx, **selectors** is a very powerful tool that is often **misused**. You should use them as soon as you need to transform an already existing data in the store.
- You shouldn't store **derived state**. This is error-prone because when your data changes, you will have to change it at multiple places => you should have only one place of truth with that data, and every transformation should be done in a **selector**.
- Inside a component, you shouldn't transform a selector (using the map operator), and you shouldn't have to call a selector from a function in your view. The useful logic for preparing data for a component should be done in a **selector**.
## Statement
You will have to refactor this working example of a dashboard of activities.
## Constraints
- Only **one action** should be dispatched from a component (or none, if you can solve the problem with Effect lifecycle hooks).
- Status effect is useless. Using **combineLatest** should be a red flag. Effects are made for side effects, not for transforming data. That's a selector's role.
- Status state might not be useful; it's only a **derived state** of existing state.

View File

@@ -1,46 +0,0 @@
---
title: 🔴 Power of Effect
description: Challenge 7 is about creating an NgRx effect with another Rxjs Hot observable
author: thomas-laforge
contributors:
- tomalaforge
- tomer953
- jdegand
- LMFinney
challengeNumber: 7
command: ngrx-power-of-effect
sidebar:
order: 206
---
## Information
This application exhibits local and global state confusion. Right now, a notification service is used to update the component lists of students and teachers. You need to add schools to this service, but you _cannot_. The school component uses its _own_ local state inside a `ComponentStore`. Thus, you are unable to dispatch an action in the notification service that the school component can respond to (remember, component stores do not have `actions`). So, how can we get around these issues?
Injection tokens and NgRx effects can greatly help.
`NgRx Effects` is a very powerful library developed by the NgRx team. Effects subscribe to a HOT Observable and listen for all events dispatched inside an application. `NgRx Effects` can subscribe to _any_ observable, which means we can wrap a hot observable inside an effect and add logic to it. You don't have to worry about the local or global state. Although you should be mindful of bad practices, when you refactor this application, you should make a determination of what should be a part of the local state and what should be a part of the global state.
In this exercise, we will need to find a way to create a very powerful, scalable, and maintainable push message listener.
### Step 1
Create an injection token to `inject` the push service inside each component. Injection tokens are very powerful. If you are unfamiliar with them, you may want to complete the [Injection token challenge](https://angular-challenges.vercel.app/challenges/angular/39-injection-token) first. This [article](https://netbasal.com/the-hidden-power-of-injectiontoken-factory-functions-in-angular-d42d5575859b) is also a great resource.
_Eliminate_ the notification service. It is not extensible. Testing (not required for this challenge) the notification service would also be overly complicated. You would need to test each branching scenario. Injection tokens can easily be mocked.
Since the notification service is global, all component lists update, even if a user is not on that route. We need to decouple that logic. The notification messages should display only on their respective routes.
### Step 2
Create one NgRx effect or component store effect for each push type, and implement your logic.
### Step 3
Show an [Angular Material snackbar](https://material.angular.io/components/snack-bar/overview) notification when an add event happens. Make each notification distinct.
### Step 4
Load your effect only when necessary.
The application contains a root route, a lazy loaded route, and a component with a local state (implemented with `ComponentStore`).

View File

@@ -1,36 +0,0 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "lib",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "lib",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -1,7 +0,0 @@
# power-of-effect-backend
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test power-of-effect-backend` to execute the unit tests.

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
export default {
displayName: 'power-of-effect-backend',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
coverageDirectory: '../../../coverage/libs/power-of-effect/backend',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -1,21 +0,0 @@
{
"name": "power-of-effect-backend",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/power-of-effect/backend/src",
"prefix": "lib",
"tags": [],
"targets": {
"test": {
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverage": true
}
}
}
}
}

View File

@@ -1,2 +0,0 @@
export * from './lib/fake-backend.service';
export * from './lib/push.service';

View File

@@ -1,105 +0,0 @@
import {
randSchool,
randStudent,
randTeacher,
} from '@angular-challenges/power-of-effect/model';
import { Injectable, inject } from '@angular/core';
import { randCompanyName, randFirstName } from '@ngneat/falso';
import { concatLatestFrom } from '@ngrx/operators';
import { map, tap, timer } from 'rxjs';
import { FakeDBService } from './fake-db.service';
import { PushService } from './push.service';
@Injectable({ providedIn: 'root' })
export class FakeBackendService {
private fakeDbService = inject(FakeDBService);
private pushService = inject(PushService);
getAllTeachers = () => this.fakeDbService.teachers$;
getAllStudents = () => this.fakeDbService.students$;
getAllSchools = () => this.fakeDbService.schools$;
start() {
this.fakeAddTeacher();
this.fakeUpdateTeacher();
this.fakeAddStudent();
this.fakeUpdateStudent();
this.fakeAddSchool();
this.fakeUpdateSchool();
}
private fakeAddTeacher() {
timer(0, 4000)
.pipe(
map(() => randTeacher()),
tap((teacher) => this.pushService.pushData(teacher)),
tap((teacher) => this.fakeDbService.addTeacher(teacher)),
)
.subscribe();
}
private fakeUpdateTeacher() {
timer(8000, 5000)
.pipe(
concatLatestFrom(() => this.fakeDbService.randomTeacher$),
map(([, teacher]) => ({
...teacher,
firstname: randFirstName(),
version: teacher.version + 1,
})),
tap((teacher) => this.pushService.pushData(teacher)),
tap((teacher) => this.fakeDbService.updateTeacher(teacher)),
)
.subscribe();
}
private fakeAddStudent() {
timer(0, 2000)
.pipe(
map(() => randStudent()),
tap((student) => this.pushService.pushData(student)),
tap((student) => this.fakeDbService.addStudent(student)),
)
.subscribe();
}
private fakeUpdateStudent() {
timer(8000, 6000)
.pipe(
concatLatestFrom(() => this.fakeDbService.randomStudents$),
map(([, student]) => ({
...student,
firstname: randFirstName(),
version: student.version + 1,
})),
tap((student) => this.pushService.pushData(student)),
tap((student) => this.fakeDbService.updateSudent(student)),
)
.subscribe();
}
private fakeAddSchool() {
timer(0, 2000)
.pipe(
map(() => randSchool()),
tap((school) => this.pushService.pushData(school)),
tap((school) => this.fakeDbService.addSchool(school)),
)
.subscribe();
}
private fakeUpdateSchool() {
timer(8000, 4000)
.pipe(
concatLatestFrom(() => this.fakeDbService.randomSchool$),
map(([, school]) => ({
...school,
name: randCompanyName(),
version: school.version + 1,
})),
tap((school) => this.pushService.pushData(school)),
tap((school) => this.fakeDbService.updateSchool(school)),
)
.subscribe();
}
}

View File

@@ -1,81 +0,0 @@
import {
School,
Student,
Teacher,
} from '@angular-challenges/power-of-effect/model';
import { Injectable } from '@angular/core';
import { randNumber } from '@ngneat/falso';
import { ComponentStore } from '@ngrx/component-store';
interface AppState {
teachers: Teacher[];
students: Student[];
schools: School[];
}
@Injectable({ providedIn: 'root' })
export class FakeDBService extends ComponentStore<AppState> {
readonly teachers$ = this.select((state) => state.teachers);
readonly randomTeacher$ = this.select(
this.teachers$,
(teachers) => teachers[randNumber({ max: teachers.length - 1 })],
);
readonly students$ = this.select((state) => state.students);
readonly randomStudents$ = this.select(
this.students$,
(students) => students[randNumber({ max: students.length - 1 })],
);
readonly schools$ = this.select((state) => state.schools);
readonly randomSchool$ = this.select(
this.schools$,
(schools) => schools[randNumber({ max: schools.length - 1 })],
);
constructor() {
super({ teachers: [], students: [], schools: [] });
}
addTeacher = this.updater(
(state, teacher: Teacher): AppState => ({
...state,
teachers: [...state.teachers, teacher],
}),
);
updateTeacher = this.updater(
(state, teacher: Teacher): AppState => ({
...state,
teachers: state.teachers.map((t) => (t.id === teacher.id ? teacher : t)),
}),
);
addStudent = this.updater(
(state, student: Student): AppState => ({
...state,
students: [...state.students, student],
}),
);
updateSudent = this.updater(
(state, student: Student): AppState => ({
...state,
students: state.students.map((t) => (t.id === student.id ? student : t)),
}),
);
addSchool = this.updater(
(state, school: School): AppState => ({
...state,
schools: [...state.schools, school],
}),
);
updateSchool = this.updater(
(state, school: School): AppState => ({
...state,
schools: state.schools.map((t) => (t.id === school.id ? school : t)),
}),
);
}

View File

@@ -1,15 +0,0 @@
import { Push } from '@angular-challenges/power-of-effect/model';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PushService {
private notificationSubject = new BehaviorSubject<Push | undefined>(
undefined,
);
notification$ = this.notificationSubject.asObservable();
pushData(data: Push) {
this.notificationSubject.next(data);
}
}

View File

@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';

View File

@@ -1,28 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"target": "es2020",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -1,17 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/test-setup.ts",
"**/*.spec.ts",
"jest.config.ts",
"**/*.test.ts"
],
"include": ["**/*.ts"]
}

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
}

View File

@@ -1,36 +0,0 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "lib",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "lib",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -1,7 +0,0 @@
# power-of-effect-model
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test power-of-effect-model` to execute the unit tests.

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
export default {
displayName: 'power-of-effect-model',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
coverageDirectory: '../../../coverage/libs/power-of-effect/model',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -1,21 +0,0 @@
{
"name": "power-of-effect-model",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/power-of-effect/model/src",
"prefix": "lib",
"tags": [],
"targets": {
"test": {
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverage": true
}
}
}
}
}

View File

@@ -1,4 +0,0 @@
export * from './lib/push.model';
export * from './lib/school.model';
export * from './lib/student.model';
export * from './lib/teacher.model';

View File

@@ -1,5 +0,0 @@
export type PushType = 'teacher' | 'student' | 'school';
export interface Push {
type: PushType;
}

View File

@@ -1,21 +0,0 @@
import { incrementalNumber, randCompanyName } from '@ngneat/falso';
import { Push } from './push.model';
export interface School extends Push {
id: number;
name: string;
version: number;
}
const factorySchool = incrementalNumber();
export const randSchool = (): School => ({
id: factorySchool(),
name: randCompanyName(),
version: 0,
type: 'school',
});
export const isSchool = (notif: Push): notif is School => {
return notif.type === 'school';
};

View File

@@ -1,23 +0,0 @@
import { incrementalNumber, randFirstName, randLastName } from '@ngneat/falso';
import { Push } from './push.model';
export interface Student extends Push {
id: number;
firstname: string;
lastname: string;
version: number;
}
const factoryStudent = incrementalNumber();
export const randStudent = (): Student => ({
id: factoryStudent(),
firstname: randFirstName(),
lastname: randLastName(),
version: 0,
type: 'student',
});
export const isStudent = (notif: Push): notif is Student => {
return notif.type === 'student';
};

View File

@@ -1,23 +0,0 @@
import { incrementalNumber, randFirstName, randLastName } from '@ngneat/falso';
import { Push } from './push.model';
export interface Teacher extends Push {
id: number;
firstname: string;
lastname: string;
version: number;
}
const factoryTeacher = incrementalNumber();
export const randTeacher = (): Teacher => ({
id: factoryTeacher(),
firstname: randFirstName(),
lastname: randLastName(),
version: 0,
type: 'teacher',
});
export const isTeacher = (notif: Push): notif is Teacher => {
return notif.type === 'teacher';
};

View File

@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';

View File

@@ -1,28 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"target": "es2020",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -1,17 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/test-setup.ts",
"**/*.spec.ts",
"jest.config.ts",
"**/*.test.ts"
],
"include": ["**/*.ts"]
}

View File

@@ -1,10 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"]
}

View File

@@ -1,36 +0,0 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "lib",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "lib",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -1,231 +0,0 @@
# NgRx CallState ComponentStore
NgRx CallState ComponentStore is a small library that extends the **@Ngrx/component-store** by adding a loading and error state to your custom state.
## Installation
Requires @Ngrx/component-store
Yarn:
```bash
yarn add @tomalaforge/ngrx-callstate-store
```
NPM:
```bash
npm i @tomalaforge/ngrx-callstate-store
```
## Introduction
When making XHR calls or any asynschronous tasks, you always need a loading or error state. By using `CallStateComponentStore`, you can easily manage the loading and error states of your async tasks, which makes your code more organized and maintainable.
## Example
```typescript
@Injectable()
export class AppStore extends CallStateComponentStore<{todos: Todo[]}> {
readonly todos$ = this.select((state) => state.todos);
readonly vm$ = this.select({
todos: this.todos$,
loading: this.loading$,
}, {debounce: true});
constructor(private todoService: TodoService) {
super({ todos: [] });
}
readonly fetchTodo = this.effect<void>(
pipe(
tap(() => this.startLoading()),
switchMap(() =>
this.todoService.getAllTodo().pipe(
tapResponse(
(todos) => this.stopLoading({ todos }),
(error: unknown) => this.handleError(error, {todos: []})
)
)
)
)
);
```
By extending your class with `CallStateComponentStore`, a `CallState` property is added to your state.
> You don't need to provide a state if you only want to use the `CallState` property.
```typescript
export type LoadingState = 'INIT' | 'LOADING' | 'LOADED';
export interface ErrorState {
error: CustomError;
}
export type CallState = LoadingState | ErrorState;
```
> You can override [`CustomError`](#errorstate) as needed.
## API
### initialization
##### setInitState
The `setInitState` method lets you initialize your custom state if you are not using the constructor.
```typescript
setInitState = (state: T): void
```
### updater
##### startLoading
The `startLoading` method sets the `CallState` property to the `LOADING` state. You can pass optional state properties if you want to patch your own state.
```typescript
startLoading = (state: Optional<T>): void
```
##### stopLoading
The `stopLoading` method sets the `CallState` to the `LOADED` state. You can pass an optional state properties as well.
```typescript
stopLoading = (state: Optional<T>): void
```
##### updateCallState
The `updateCallState` method updates the callState with the inputed value.
```typescript
updateCallState = (callState: CallState): void
```
##### handleError
The `handleError` method handles errors. You can pass an optional state.
```typescript
handleError = (error: unknown, state: Optional<T>): void
```
### selector
##### isLoading$
`isLoading$` return a boolean, true if state is loading, false otherwise
##### isLoadingWithFlicker$
`isLoadingWithFlicker$` return the same as `isLoading$` but with a small delay. This can be useful when you don't want your page to flicker.
##### isLoaded$
`isLoaded$` return a boolean, true if state is loaded, false otherwise
##### callState$
`isLoading$` return the `CallState`
##### error$
`isLoading$` return your error message using the `getErrorMessage` of your `ErrorState`. [(see below)](#errorstate)
---
### Customize the library
##### ErrorState
You can provide your own implementation of The `ErrorState` by implementing `ErrorHandler`
```typescript
export interface ErrorHandler<T extends CustomError> {
toError: (error: unknown) => T;
getErrorMessage: (error?: T) => string | undefined;
}
```
The `toError` method converts the error input into the desired error object.
The `getErrorMessage` method returns the well-formed error message that you want to display to your user.
The current implementation is as follow:
```typescript
export const UNKNOWN_ERROR_CAUSE = 'UNKNOWN_ERROR';
export const UNKNOWN_ERROR_MESSAGE = 'unknown error occured';
export class CallStateError {
name: string;
message: string;
stack?: string;
constructor(name = '', message = '') {
this.name = name;
this.message = message;
}
}
export class CallStateErrorHandler implements ErrorHandler<CallStateError> {
toError = (error: unknown): CallStateError => {
if (error instanceof CallStateError) return error;
if (error instanceof Error) return new CallStateError(error.name, error.message);
return new CallStateError(UNKNOWN_ERROR_CAUSE, UNKNOWN_ERROR_MESSAGE);
};
getErrorMessage = (error?: CallStateError): string | undefined => {
return error?.message;
};
}
```
Let's say you want to customize it as follow:
```typescript
export const UNKNOWN_ERROR_MESSAGE = 'unknown error occured';
export class MyError {
message: string;
code: number
constructor(message = '', code = 404) {
this.message = message;
this.code = code;
}
}
export class MyErrorHandler implements ErrorHandler<MyError> {
toError = (error: unknown): MyError => {
if (error instanceof MyError) return error;
if (error instanceof Error)
return new MyError(error.message);
return new MyError(UNKNOWN_ERROR_MESSAGE);
};
getErrorMessage = (error?: MyError): string | undefined => {
return error.code error?.message;
};
}
```
Now to override the default implementation, you need to provide it as follow :
```typescript
provideErrorHandler(MyErrorHandler);
```
> You can provide it at root level to apply it to your whole application or at the component level for more specific implementation.
##### Flicker Delay
The default delay is 300ms but you can override it by providing it as follow:
```typescript
provideFlickerDelay(500);
```

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
export default {
displayName: 'shared-ngrx-callstate-store',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
coverageDirectory: '../../../coverage/libs/shared/ngrx-callstate-store',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -1,7 +0,0 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist/libs/shared/ngrx-callstate-store",
"lib": {
"entryFile": "src/index.ts"
}
}

View File

@@ -1,27 +0,0 @@
{
"name": "@tomalaforge/ngrx-callstate-store",
"version": "0.0.4",
"description": "Enhance NgRx component-store by providing a loading/error state",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/tomalaforge/angular-challenges/tree/main/libs/shared/ngrx-callstate-store"
},
"keywords": [
"angular",
"state",
"ngrx/component-store"
],
"author": {
"name": "Thomas Laforge"
},
"peerDependencies": {
"@angular/common": "^15.0.0",
"@angular/core": "^15.0.0"
},
"dependencies": {
"tslib": "^2.3.0"
}
}

View File

@@ -1,37 +0,0 @@
{
"name": "shared-ngrx-callstate-store",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/shared/ngrx-callstate-store/src",
"prefix": "lib",
"tags": [],
"targets": {
"build": {
"executor": "@nx/angular:package",
"outputs": ["{workspaceRoot}/dist/{projectRoot}"],
"options": {
"project": "libs/shared/ngrx-callstate-store/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "libs/shared/ngrx-callstate-store/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "libs/shared/ngrx-callstate-store/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"coverage": true
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More