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": "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
}
}
}
}
}

View File

@@ -1,2 +0,0 @@
export * from './lib/call-state-component-store';
export * from './lib/external.model';

View File

@@ -1,105 +0,0 @@
/* eslint-disable @typescript-eslint/ban-types */
import {
inject,
Inject,
Injectable,
InjectionToken,
Optional,
} from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { Observable, of, switchMap } from 'rxjs';
import {
CallState,
CallStateError,
getErrorCallState,
} from './call-state.model';
import { ERROR_TOKEN, FLICKER_TIME } from './external.model';
import { nonFlickerLoader } from './non-flicker-loader';
export const INITIAL_TOKEN = new InjectionToken('initial data');
export interface CallStateComponentState {
callState: CallState;
}
@Injectable()
export class CallStateComponentStore<
U extends object | void = void,
T extends
| (U & CallStateComponentState)
| CallStateComponentState = U extends void
? CallStateComponentState
: U & CallStateComponentState,
> extends ComponentStore<T> {
private error = inject(ERROR_TOKEN);
private flickerTime = inject(FLICKER_TIME);
constructor(@Inject(INITIAL_TOKEN) @Optional() initialState: U) {
super({ callState: 'INIT', ...initialState } as T);
}
readonly isLoading$: Observable<boolean> = this.select(
(state) => state.callState === 'LOADING',
);
readonly isLoadingWithFlicker$: Observable<boolean> = this.select(
(state) => state.callState === 'LOADING',
).pipe(
switchMap((loading) => nonFlickerLoader(of(loading), this.flickerTime)),
);
readonly isLoaded$: Observable<boolean> = this.select(
(state) => state.callState === 'LOADED',
).pipe(switchMap((loaded) => of(loaded)));
readonly callState$ = this.select((state) => state.callState);
readonly error$: Observable<string | undefined> = this.select((state) =>
this.error.getErrorMessage(getErrorCallState(state.callState)),
);
readonly updateCallState = this.updater(
(state, callState: CallState | undefined): T => {
return {
...(state as object),
callState: callState ?? 'LOADED',
} as T;
},
);
readonly startLoading = this.updater(
(state, patchedState: Partial<U> | void): T => {
return {
...(state as object),
...patchedState,
callState: 'LOADING',
} as T;
},
);
readonly stopLoading = this.updater(
(state, patchedState: Partial<U> | void): T => {
return {
...(state as object),
...patchedState,
callState: 'LOADED',
} as T;
},
);
protected handleError(
error: unknown,
patchedState: Partial<U> = {},
): CallStateError {
const err = this.error.toError(error);
this.patchState({
callState: { error: err },
...patchedState,
} as Partial<T>);
return err;
}
setInitState(initialState: U) {
this.setState({ callState: 'INIT', ...initialState } as T);
}
}

View File

@@ -1,51 +0,0 @@
import { CustomError, ErrorHandler } from './external.model';
export type LoadingState = 'INIT' | 'LOADING' | 'LOADED';
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;
};
}
export interface ErrorState {
error: CustomError;
}
export type CallState = LoadingState | ErrorState;
export const getErrorCallState = (
callState: CallState,
): CustomError | undefined => {
if (isErrorState(callState)) {
return callState.error;
}
return undefined;
};
export const isLoadedOrInError = (callState: CallState): boolean =>
callState === 'LOADED' || isErrorState(callState);
export const isErrorState = (callState: CallState): callState is ErrorState =>
Object.prototype.hasOwnProperty.call(callState, 'error');

View File

@@ -1,32 +0,0 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
import {
ClassProvider,
InjectionToken,
Type,
ValueProvider,
} from '@angular/core';
import { CallStateErrorHandler } from './call-state.model';
export interface ErrorHandler<T extends CustomError> {
toError: (error: unknown) => T;
getErrorMessage: (error?: T) => string | undefined;
}
export interface CustomError {}
export const ERROR_TOKEN = new InjectionToken<ErrorHandler<any>>('error', {
factory: () => new CallStateErrorHandler(),
});
export const provideErrorHandler = <T extends ErrorHandler<any>>(
errorHandlerClass: Type<T>,
): ClassProvider => ({ provide: ERROR_TOKEN, useClass: errorHandlerClass });
export const FLICKER_TIME = new InjectionToken<number>('flicker', {
factory: () => 300,
});
export const provideFlickerDelay = (delay: number): ValueProvider => ({
provide: FLICKER_TIME,
useValue: delay,
});

View File

@@ -1,20 +0,0 @@
import { combineLatest, map, mapTo, Observable, startWith, timer } from 'rxjs';
/**
* Delay the first emition of data$ value. Instead, it emits "true" until duration is elapsed
*/
export const nonFlickerLoader = (
data$: Observable<boolean>,
duration = 300,
): Observable<boolean> => {
const isTrueWhileDuration$ = timer(duration).pipe(
mapTo(false),
startWith(true),
);
return combineLatest([data$, isTrueWhileDuration$]).pipe(
map(([data, isTrueWhileDuration]) =>
isTrueWhileDuration ? isTrueWhileDuration : data,
),
);
};

View File

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

View File

@@ -1,32 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.lib.prod.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"target": "es2022",
"useDefineForClassFields": false,
"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,9 +0,0 @@
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

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"]
}