Repository: egorgrushin/painless-redux Branch: master Commit: b5ca0bc04700 Files: 87 Total size: 207.6 KB Directory structure: gitextract__my3mksh/ ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── src/ │ ├── affect-loading-state/ │ │ ├── affect-loading-state.ts │ │ └── types.ts │ ├── dispatcher/ │ │ ├── dispatcher.ts │ │ └── types.ts │ ├── entity/ │ │ ├── action-creators.ts │ │ ├── action-creators.types.ts │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── entity.int-spec.ts │ │ ├── entity.spec.ts │ │ ├── entity.ts │ │ ├── methods/ │ │ │ ├── dispatch/ │ │ │ │ ├── dispatch.ts │ │ │ │ └── types.ts │ │ │ ├── mixed/ │ │ │ │ ├── mixed.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── select/ │ │ │ ├── select.ts │ │ │ └── types.ts │ │ ├── reducer.ts │ │ ├── reducers/ │ │ │ ├── dictionary.spec.ts │ │ │ ├── dictionary.ts │ │ │ ├── ids.spec.ts │ │ │ ├── ids.ts │ │ │ ├── instance.spec.ts │ │ │ ├── instance.ts │ │ │ ├── loading-state.ts │ │ │ ├── loading-states.ts │ │ │ ├── pages.spec.ts │ │ │ └── pages.ts │ │ ├── selectors.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── index.ts │ ├── painless-redux/ │ │ ├── action-creators.ts │ │ ├── constants.ts │ │ ├── painless-redux.ts │ │ ├── reducers.ts │ │ ├── register.ts │ │ ├── selectors.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── select-manager/ │ │ ├── select-manager.ts │ │ └── types.ts │ ├── shared/ │ │ ├── change/ │ │ │ ├── actions.ts │ │ │ ├── reducer.spec.ts │ │ │ ├── reducer.ts │ │ │ ├── selectors.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── loading-state/ │ │ │ ├── actions.ts │ │ │ ├── reducers.spec.ts │ │ │ ├── reducers.ts │ │ │ ├── selectors.ts │ │ │ └── types.ts │ │ ├── system/ │ │ │ ├── actions.ts │ │ │ ├── reducers.ts │ │ │ ├── system.ts │ │ │ └── types.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── system-types.ts │ ├── testing/ │ │ ├── helpers.ts │ │ └── store.ts │ ├── utils.ts │ └── workspace/ │ ├── action-creators.ts │ ├── actions.ts │ ├── constants.ts │ ├── methods/ │ │ ├── dispatch/ │ │ │ ├── dispatch.ts │ │ │ └── types.ts │ │ ├── mixed/ │ │ │ ├── mixed.ts │ │ │ └── types.ts │ │ └── select/ │ │ ├── select.ts │ │ └── types.ts │ ├── reducer.ts │ ├── selectors.ts │ ├── types.ts │ ├── utils.ts │ ├── workspace.spec.ts │ └── workspace.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ **/*.spec.ts ================================================ FILE: .eslintrc.json ================================================ { // Настройки проекта "env": { // Проект для браузера "browser": true, // Включаем возможности ES6 "es6": true, // Добавляем возможности ES2017 "es2017": true }, // Наборы правил "extends": [ // Базовый набор правил eslint "eslint:recommended", // Отключаем правила из базового набора "plugin:@typescript-eslint/eslint-recommended", // Базовые правила для TypeScript "plugin:@typescript-eslint/recommended", // Правила TS, требующие инфо о типах "plugin:@typescript-eslint/recommended-requiring-type-checking" ], // Движок парсинга "parser": "@typescript-eslint/parser", "parserOptions": { // Движку нужен проект TS для правил с типами "project": "tsconfig.json", "tsconfigRootDir": "." }, // Плагин с наборами правил для TypeScript "plugins": [ "@typescript-eslint" ], "rules": { "@typescript-eslint/interface-name-prefix": "off", "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/unbound-method": "off", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-unused-vars": "off" } } ================================================ FILE: .gitignore ================================================ dist node_modules .idea /compiled *.metadata.json ================================================ FILE: .npmignore ================================================ node_modules .idea /compiled /src/testing ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Egor Grushin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # painless-redux Reducers-actions-selectors free reactive state management in redux-way # Overview This package allows you to use CRUD (Create, Read, Update and Delete) manipulations with entities and workspaces. General features: - It provides several simple methods such as get, create, remove, change etc. for using on Entity instance. - It provides loading state management (i.e. isLoading and error). - Underhood it uses any redux-like library you want to (e.g. [@ngrx/store](https://github.com/ngrx/platform)), so it means you can use [Redux DevTools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=ru) but with this library you don't have to create boilerplate code (e.g. reducers, actions, selectors, action creators etc.). - All methods working with outer data sources (e.g. requests passed to `get$` method) are [RxJS](https://github.com/ReactiveX/rxjs) powered. - It supports optimistic change, remove, add - It provides Workspace (documentation will be ready soon) which allows you store filter, sorting, ui states etc. # Requirements 1. To be familiar with [redux](https://github.com/reduxjs/redux) 2. To be familiar with [RxJS](https://github.com/ReactiveX/rxjs) # Documentation [Here](https://github.com/egorgrushin/painless-redux/wiki) # Plain use 1. install using npm: `npm i painless-redux` 2. create an store: ```typescript import { createPainlessRedux, RxStore } from 'painless-redux'; const store: RxStore = ; export const PAINLESS_REDUX_STORE = createPainlessRedux(store); ``` 3. create an entity: ```typescript import { createEntity } from 'painless-redux'; import { PAINLESS_REDUX_STORE } from './store'; export interface Painter { id: number | string; fullName: string; born: number; } const PaintersEntity = createEntity({ name: 'painters' }); PAINLESS_REDUX_STORE.registerSlot(PaintersEntity); export PaintersEntity; ``` 4. add new entity ```typescript PaintersEntity.add({ id: 1, fullName: 'Vincent van Gogh', born: 1853 }); ``` 5. get entity or all entities ```typescript PaintersEntity.getById$(1).subscribe((painter: Painter) => {}); PaintersEntity.get$().subscribe((painters: Painter[]) => {}); ``` # Use with Angular [This adapter](https://github.com/egorgrushin/ngx-painless-redux) will help you to connect `painless-redux` to your Angular project, who uses [@ngrx/store](https://github.com/ngrx/platform). # Use with React [This adapter](https://www.npmjs.com/package/react-painless-redux) will help you to connect `painless-redux` to your React project, who uses [@reduxjs/toolkit](https://www.npmjs.com/package/@reduxjs/toolkit). # Common use This part can be difficult to understand, but this is main feature of this library. Commonly you need to load some entities from outer source (e.g. your backend api) with given filter. To achieve this you need to prepare your data source using RxJS's observable and use `Entity.get$` method like this: ```typescript import { Observable, of } from 'rxjs'; import { PaintersEntity } from './painters'; const init = () => { const config = {}; getPainters$(config).subscribe((painters: Painter[]) => { // emits: // 1. undefined immediately // 2. painters array when getPaintersFromApi$'s observable emits. }); } const getPainters$ = (config: unknown): Observable { const dataSource$ = getPaintersFromApi$(config); return PaintersEntity.get$(config, dataSource$); } const getPaintersFromApi$ = (config: unknown): Observable => { // use can use any data source you need, this is for demo purposes. const painters: Painter[] = [ { id: 1, fullName: 'Leonardo da Vinci', born: 1452 }, { id: 2, fullName: 'Vincent van Gogh', born: 1853 }, { id: 3, fullName: 'Pablo Picasso', born: 1881 }, ]; return of(painters); } ``` `Entity.get$` algorithm is described [here](https://github.com/egorgrushin/painless-redux/wiki/Entity#get_observable) # Pagination `Entity.get$` method supports pagination. For this you have to pass `paginator` BehaviorSubject as the last argument: ```typescript import { Observable, of, BehaviorSubject } from 'rxjs'; import { Pagination } from 'painless-redux'; import { PaintersEntity } from './painters'; const init = () => { const paginator = new BehaviorSubject(false); const config = {}; getPainters$(config, paginator).subscribe((painters: Painter[]) => { // emits: // 1. undefined immediately // 2. painters array when getPaintersFromApi$'s observable emits. // idle 3000ms // 3. painters array from second emit merged with another getPaintersFromApi$'s observable emits. }); setTimeout(() => { paginator.next(true); }, 3000) } const getPainters$ = (config: unknown, paginator: BehaviorSubject): Observable { const dataSource = ({ from, to, size, index }: Pagination) => getPaintersFromApi$(config, from, to); return PaintersEntity.get$(config, dataSource, null, paginator); } const getPaintersFromApi$ = (config: unknown, from: number, to: number): Observable => { // use can use any data source you need, this is for demo purposes. const painters: Painter[] = [ { id: 1, fullName: 'Leonardo da Vinci', born: 1452 }, { id: 2, fullName: 'Vincent van Gogh', born: 1853 }, { id: 3, fullName: 'Pablo Picasso', born: 1881 }, ].slice(from, to); return of(painters); } ================================================ FILE: package.json ================================================ { "name": "painless-redux", "version": "4.1.17", "description": "Reducers-actions-selectors free reactive state management in redux-way", "main": "dist/index.js", "scripts": { "build": "rimraf dist && tsc", "preversion": "npm run test && npm run build", "test": "jest" }, "jest": { "roots": [ "/src" ], "transform": { "^.+\\.tsx?$": "ts-jest" }, "testRegex": "(/__tests__/.*|(\\.|/)(test|spec|int-spec))\\.tsx?$", "moduleFileExtensions": [ "ts", "tsx", "js", "jsx", "json", "node" ], "preset": "ts-jest", "moduleNameMapper": { "^lodash-es$": "/node_modules/lodash/index.js" } }, "repository": { "type": "git", "url": "git+https://github.com/egorgrushin/painless-redux.git" }, "keywords": [ "redux", "rxjs" ], "types": "dist/index.d.ts", "author": "Grushin Egor", "license": "MIT", "bugs": { "url": "https://github.com/egorgrushin/painless-redux/issues" }, "homepage": "https://github.com/egorgrushin/painless-redux#readme", "dependencies": { "lodash-es": "^4.17.21", "object-hash": "^2.0.3", "reselect": "4.0.0", "uuid": "^8.3.2" }, "devDependencies": { "@types/crypto-js": "^3.1.43", "@types/jest": "^24.9.1", "@types/lodash-es": "^4.17.5", "@types/node": "^12.7.5", "@types/object-hash": "^1.3.1", "@types/uuid": "^8.3.2", "@typescript-eslint/eslint-plugin": "^2.20.0", "@typescript-eslint/parser": "^2.20.0", "eslint": "^6.8.0", "jest": "^25.2.7", "jest-marbles": "^2.5.1", "lodash": "^4.17.21", "rimraf": "^2.6.3", "rxjs": "^6.5.3", "ts-jest": "^25.2.1", "typescript": "^3.8.2" }, "peerDependencies": { "rxjs": "^6.3.3" } } ================================================ FILE: src/affect-loading-state/affect-loading-state.ts ================================================ import { EMPTY, Observable, of, OperatorFunction, throwError } from 'rxjs'; import { catchError, finalize, switchMap, tap } from 'rxjs/operators'; import { AffectLoadingStateFactory, AffectStateSetter } from './types'; import { isFunction } from 'lodash-es'; export const affectLoadingStateOperatorFactory = ( setter: AffectStateSetter, rethrow: boolean = true, ) => ( ...pipes: Array> ): OperatorFunction => ( source: Observable, ) => { let stateCleared: boolean; return (source as any).pipe( tap((value: T) => { setter?.({ isLoading: true, error: undefined }, false, value); stateCleared = false; }), switchMap((value: T) => (of(value) as any).pipe( ...pipes, catchError((error: E) => { setter?.({ isLoading: false, error }, false, undefined); stateCleared = true; return rethrow ? throwError(error) : EMPTY; }), )), tap((value: T) => { setter?.({ isLoading: false }, false, value); stateCleared = true; }), finalize(() => { if (stateCleared) return; setter?.({ isLoading: false }, true, undefined); }), ); }; export const affectLoadingStateFactory = ( setter: AffectStateSetter, rethrow: boolean = true, ): AffectLoadingStateFactory => (...pipesOrObs: any) => { const obs = pipesOrObs[0]; const isPipes = isFunction(obs); const operatorFactory = affectLoadingStateOperatorFactory(setter, rethrow); if (isPipes) return operatorFactory(...pipesOrObs as OperatorFunction[]); const operator = operatorFactory(switchMap(() => obs)); return (of(undefined) as any).pipe(operator); }; ================================================ FILE: src/affect-loading-state/types.ts ================================================ import { LoadingState } from '../system-types'; import { Observable, OperatorFunction } from 'rxjs'; export interface AffectStateSetter { ( loadingState: LoadingState, isInterrupted: boolean, value: T | undefined, ): void; } export interface AffectLoadingStateFactory { (...pipes: Array>): OperatorFunction; (observable: Observable): Observable; (...pipesOrObs: any): OperatorFunction | Observable; } ================================================ FILE: src/dispatcher/dispatcher.ts ================================================ import { ActionCreator, AnyAction, RxStore, SameShaped } from '../system-types'; import { Dispatcher } from './types'; const createCreateAction = ( actionCreators: SameShaped>, ) => ( actionName: keyof TActionTypes, args: any[], options?: unknown, ): TActions => { const actionCreator = actionCreators[actionName]; return actionCreator(...args, options); }; export const createDispatcher = ( rxStore: RxStore, actionCreators: SameShaped>, ): Dispatcher => { const dispatch = (action: AnyAction): void => rxStore.dispatch(action); const createAction = createCreateAction(actionCreators); const createAndDispatch = ( actionName: keyof TActionTypes, args: any[], options?: unknown, ): TActions => { const action = createAction(actionName, args, options); dispatch(action); return action; }; return { dispatch, createAndDispatch, }; }; ================================================ FILE: src/dispatcher/types.ts ================================================ export interface Dispatcher { dispatch(action: TActions): void; createAndDispatch( actionName: keyof TActionTypes, args: any[], options?: any, ): TActions; } ================================================ FILE: src/entity/action-creators.ts ================================================ import { EntityActionTypes, EntitySchema } from './types'; import { createAdd, createAddList, createChange, createChangeList, createClear, createClearAll, createRemove, createRemoveList, createResolveAdd, createResolveChange, createResolveChangeList, createResolveRemove, createResolveRemoveList, createRestoreRemoved, createRestoreRemovedList, createSetLoadingState, createSetLoadingStates, } from './actions'; import { createBatch } from '../shared/system/actions'; import { EntityActionCreators } from './action-creators.types'; export const createEntityActionCreators = ( actionTypes: EntityActionTypes, schema: EntitySchema, ): EntityActionCreators => ({ ADD: createAdd(actionTypes, schema), RESOLVE_ADD: createResolveAdd(actionTypes, schema), ADD_LIST: createAddList(actionTypes, schema), CHANGE: createChange(actionTypes), RESOLVE_CHANGE: createResolveChange(actionTypes), REMOVE: createRemove(actionTypes), RESOLVE_REMOVE: createResolveRemove(actionTypes), RESTORE_REMOVED: createRestoreRemoved(actionTypes), REMOVE_LIST: createRemoveList(actionTypes), RESOLVE_REMOVE_LIST: createResolveRemoveList(actionTypes), RESTORE_REMOVED_LIST: createRestoreRemovedList(actionTypes), SET_LOADING_STATE: createSetLoadingState(actionTypes, schema), CLEAR: createClear(actionTypes, schema), CLEAR_ALL: createClearAll(actionTypes), BATCH: createBatch(actionTypes), CHANGE_LIST: createChangeList(actionTypes), SET_LOADING_STATES: createSetLoadingStates(actionTypes), RESOLVE_CHANGE_LIST: createResolveChangeList(actionTypes), }); ================================================ FILE: src/entity/action-creators.types.ts ================================================ import { EntityAddOptions, EntityInternalAddListOptions, EntityInternalAddOptions, EntityInternalSetLoadingStateOptions, EntityRemoveListOptions, EntityRemoveOptions, EntityType, IdPatch, } from './types'; import {DeepPartial, Id, LoadingState} from '../system-types'; import {ChangeOptions} from '../shared/change/types'; // this types for public use export interface EntityActionCreators { ADD_LIST: ( entities: EntityType[], config?: unknown, isReplace?: boolean, hasMore?: boolean, metadata?: TPageMetadata, options?: EntityInternalAddListOptions, ) => { payload: { entities: EntityType[]; isReplace: boolean; hasMore: boolean; configHash: string; metadata: TPageMetadata | undefined }; options: EntityInternalAddListOptions; type: 'ADD_LIST' }; RESTORE_REMOVED: (id: Id) => { payload: { id: Id }; type: 'RESTORE_REMOVED' }; ADD: ( entity: EntityType, config?: unknown, tempId?: string, options?: EntityInternalAddOptions, ) => { payload: { configHash: string; tempId: string | undefined; entity: { id: Id } }; options: EntityInternalAddOptions; type: 'ADD' }; RESOLVE_ADD: ( result: EntityType, success: boolean, tempId: string, config?: unknown, options?: EntityAddOptions, ) => { payload: { result: EntityType; success: boolean; configHash: string; tempId: string }; options: EntityAddOptions; type: 'RESOLVE_ADD' }; CLEAR_ALL: () => { type: 'CLEAR_ALL' }; RESOLVE_REMOVE: ( id: Id, success: boolean, options?: EntityRemoveOptions, ) => { payload: { success: boolean; id: Id }; options: EntityRemoveOptions; type: 'RESOLVE_REMOVE' }; REMOVE: ( id: Id, options?: EntityRemoveOptions, ) => { payload: { id: Id }; options: EntityRemoveOptions; type: 'REMOVE' }; REMOVE_LIST: ( ids: Id[], options?: EntityRemoveOptions, ) => { payload: { ids: Id[] }; options: EntityRemoveListOptions; type: 'REMOVE_LIST' }; RESOLVE_REMOVE_LIST: ( ids: Id[], success: boolean, options?: EntityRemoveOptions, ) => { payload: { success: boolean; ids: Id[] }; options: EntityRemoveListOptions; type: 'RESOLVE_REMOVE_LIST' }; RESTORE_REMOVED_LIST: ( ids: Id[], ) => { payload: { ids: Id[] }; type: 'RESTORE_REMOVED_LIST' }; CHANGE: ( id: Id, patch: DeepPartial, changeId?: string, options?: ChangeOptions, ) => { payload: { patch: DeepPartial; changeId: string | undefined; id: Id }; readonly options: ChangeOptions; type: 'CHANGE' }; CHANGE_LIST: ( patches: IdPatch[], changeId?: string, options?: ChangeOptions, ) => { payload: { patches: IdPatch[]; changeId: string | undefined }; readonly options: ChangeOptions; type: 'CHANGE_LIST' }; BATCH: (actions: T[]) => { payload: { actions: T[] }; type: 'BATCH' }; SET_LOADING_STATE: ( state: LoadingState, config?: unknown, id?: Id, key?: string, options?: EntityInternalSetLoadingStateOptions, ) => { payload: { configHash: string; state: LoadingState; id: Id | undefined; key: string | undefined }; options: { maxPagesCount: number }; readonly type: 'SET_LOADING_STATE' }; SET_LOADING_STATES: ( state: LoadingState, ids: Id[], options?: EntityInternalSetLoadingStateOptions, ) => { payload: { ids: Id[]; state: LoadingState }; options: { maxPagesCount: number }; readonly type: 'SET_LOADING_STATES' }; RESOLVE_CHANGE: ( id: Id, changeId: string, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ) => { payload: { success: boolean; remotePatch: DeepPartial | undefined; changeId: string; id: Id }; readonly options: ChangeOptions; type: 'RESOLVE_CHANGE' }; RESOLVE_CHANGE_LIST: ( patches: IdPatch[], changeId: string, success: boolean, options?: ChangeOptions, ) => { payload: { patches: IdPatch[]; changeId: string; success: boolean }; readonly options: ChangeOptions; type: 'RESOLVE_CHANGE_LIST' }; CLEAR: (config: unknown) => { payload: { configHash: string }; type: 'CLEAR' }; } ================================================ FILE: src/entity/actions.ts ================================================ import { EntityActionTypes, EntityAddOptions, EntityInternalAddListOptions, EntityInternalAddOptions, EntityInternalSetLoadingStateOptions, EntityRemoveListOptions, EntityRemoveOptions, EntitySchema, EntityType, IdPatch, } from './types'; import { DeepPartial, Id, LoadingState } from '../system-types'; import { typedDefaultsDeep } from '../utils'; import * as loadingStateActions from '../shared/loading-state/actions'; import * as changeActions from '../shared/change/actions'; import { MAX_PAGES_COUNT } from './constants'; import { ChangeOptions } from '../shared/change/types'; import { SystemActions } from '../shared/system/actions'; export const createAddByHash = (types: EntityActionTypes) => ( entity: EntityType, configHash: string, tempId?: string, options?: EntityInternalAddOptions, ) => { options = typedDefaultsDeep(options, { merge: true, maxPagesCount: MAX_PAGES_COUNT }); const payload = { entity, configHash, tempId }; return { type: types.ADD, payload, options } as const; }; export const createAdd = (types: EntityActionTypes, schema: EntitySchema) => ( entity: EntityType, config?: unknown, tempId?: string, options?: EntityInternalAddOptions, ) => { const configHash = schema.hashFn(config); options = typedDefaultsDeep(options, { maxPagesCount: MAX_PAGES_COUNT }); tempId = options.optimistic ? tempId : undefined; entity = options.optimistic ? { ...entity, id: tempId as string } : entity; return createAddByHash(types)(entity, configHash, tempId, options); }; export const createResolveAdd = (types: EntityActionTypes, schema: EntitySchema) => ( result: EntityType, success: boolean, tempId: string, config?: unknown, options?: EntityAddOptions, ) => { const configHash = schema.hashFn(config); options = typedDefaultsDeep(options, { merge: true }); const payload = { result, configHash, tempId, success }; return { type: types.RESOLVE_ADD, payload, options } as const; }; export const createAddList = (types: EntityActionTypes, schema: EntitySchema) => ( entities: EntityType[], config?: unknown, isReplace: boolean = false, hasMore: boolean = false, metadata?: TPageMetadata, options?: EntityInternalAddListOptions, ) => { const configHash = schema.hashFn(config); options = typedDefaultsDeep(options, { merge: true, maxPagesCount: MAX_PAGES_COUNT }); const payload = { entities, configHash, isReplace, hasMore, metadata }; return { type: types.ADD_LIST, payload, options } as const; }; export const createRemove = (types: EntityActionTypes) => ( id: Id, options?: EntityRemoveOptions, ) => { options = typedDefaultsDeep(options); const payload = { id }; return { type: types.REMOVE, payload, options } as const; }; export const createRemoveList = (types: EntityActionTypes) => ( ids: Id[], options?: EntityRemoveListOptions, ) => { options = typedDefaultsDeep(options); const payload = { ids }; return { type: types.REMOVE_LIST, payload, options } as const; }; export const createResolveRemoveList = (types: EntityActionTypes) => ( ids: Id[], success: boolean, options?: EntityRemoveListOptions, ) => { options = typedDefaultsDeep(options); const payload = { ids, success }; return { type: types.RESOLVE_REMOVE_LIST, payload, options } as const; }; export const createSetLoadingState = (types: EntityActionTypes, schema: EntitySchema) => ( state: LoadingState, config?: unknown, id?: Id, key?: string, options?: EntityInternalSetLoadingStateOptions, ) => { const actionCreator = loadingStateActions.createSetLoadingState(types); const action = actionCreator(state, key, options); const configHash = schema.hashFn(config); return { ...action, payload: { ...action.payload, configHash, id }, options: { ...action.options, maxPagesCount: options?.maxPagesCount ?? MAX_PAGES_COUNT }, } as const; }; export const createSetLoadingStates = (types: EntityActionTypes) => ( state: LoadingState, ids: Id[], options?: EntityInternalSetLoadingStateOptions, ) => { return { type: types.SET_LOADING_STATES, payload: { state, ids }, options: { maxPagesCount: options?.maxPagesCount ?? MAX_PAGES_COUNT }, } as const; }; export const createChange = (types: EntityActionTypes) => ( id: Id, patch: DeepPartial, changeId?: string, options?: ChangeOptions, ) => { const actionCreator = changeActions.createChange(types); const action = actionCreator(patch, changeId, options); return { ...action, type: types.CHANGE, payload: { ...action.payload, id }, } as const; }; export const createChangeList = (types: EntityActionTypes) => ( patches: IdPatch[], changeId?: string, options?: ChangeOptions, ) => { options = typedDefaultsDeep(options, { merge: true }); return { type: types.CHANGE_LIST, payload: { patches, changeId }, options, } as const; }; export const createResolveChange = (types: EntityActionTypes) => ( id: Id, changeId: string, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ) => { const actionCreator = changeActions.createResolveChange(types); const action = actionCreator(changeId, success, remotePatch, options); return { ...action, type: types.RESOLVE_CHANGE, payload: { ...action.payload, id }, } as const; }; export const createResolveChangeList = (types: EntityActionTypes) => ( patches: IdPatch[], changeId: string, success: boolean, options?: ChangeOptions, ) => { options = typedDefaultsDeep(options, { merge: true }); return { type: types.RESOLVE_CHANGE_LIST, payload: { patches, changeId, success }, options, } as const; }; export const createResolveRemove = (types: EntityActionTypes) => ( id: Id, success: boolean, options?: EntityRemoveOptions, ) => { options = typedDefaultsDeep(options); return { type: types.RESOLVE_REMOVE, payload: { id, success }, options } as const; }; export const createRestoreRemoved = (types: EntityActionTypes) => ( id: Id, ) => { return { type: types.RESTORE_REMOVED, payload: { id } } as const; }; export const createRestoreRemovedList = (types: EntityActionTypes) => ( ids: Id[], ) => { return { type: types.RESTORE_REMOVED_LIST, payload: { ids } } as const; }; export const createClear = (types: EntityActionTypes, schema: EntitySchema) => (config: unknown) => { const configHash = schema.hashFn(config); return { type: types.CLEAR, payload: { configHash } } as const; }; export const createClearAll = (types: EntityActionTypes) => () => { return { type: types.CLEAR_ALL } as const; }; type SelfActionCreators = ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType | ReturnType export type EntityActions = ReturnType | SystemActions; ================================================ FILE: src/entity/constants.ts ================================================ import { EntityActionTypes } from './types'; export const DEFAULT_PAGE_SIZE = 300; export const MAX_PAGES_COUNT = Infinity; export const ENTITY_TYPE_NAMES: Array = [ 'ADD', 'RESOLVE_ADD', 'ADD_LIST', 'SET_LOADING_STATE', 'CHANGE', 'RESOLVE_CHANGE', 'REMOVE', 'RESOLVE_REMOVE', 'RESTORE_REMOVED', 'REMOVE_LIST', 'RESOLVE_REMOVE_LIST', 'RESTORE_REMOVED_LIST', 'CLEAR', 'CLEAR_ALL', 'BATCH', 'RESOLVE_CHANGE_LIST', 'CHANGE_LIST', 'SET_LOADING_STATES', ]; ================================================ FILE: src/entity/entity.int-spec.ts ================================================ import {Id} from '../system-types'; import {Entity, EntityAddOptions, EntityRemoveOptions, Page} from './types'; import {createEntity} from './entity'; import {PainlessRedux} from '../painless-redux/types'; import {createPainlessRedux} from '../painless-redux/painless-redux'; import {TestStore} from '../testing/store'; import {cold} from 'jest-marbles'; import {switchMap} from 'rxjs/operators'; import {mocked} from 'ts-jest/utils'; import * as uuid from 'uuid'; import {ColdObservable} from 'rxjs/internal/testing/ColdObservable'; jest.mock('uuid'); type TestEntity = { id: Id; image?: string; age?: number; name?: string; } type TPageMetadata = any; describe('[Integration] Entity', () => { let entity: Entity; let pr: PainlessRedux; let store: TestStore; const filter = null; const user: TestEntity = {id: 1, name: 'John'}; beforeEach(() => { store = new TestStore({}, (state) => state); pr = createPainlessRedux(store, {useAsapSchedulerInLoadingGuards: false}); entity = createEntity(pr, {name: 'test', maxPagesCount: 2}); }); describe('#get$', () => { test('should emit created earlier instance ', () => { // arrange entity.add(user); const expected$ = cold('a', {a: [user]}); // act const actual$ = entity.get$(filter); // assert expect(actual$).toBeObservable(expected$); }); test('should emit instance after response', () => { // arrange const remote$ = cold(' --a', {a: {data: [user]}}); const expected$ = cold('a-b ', {a: undefined, b: [user]}); // act const actual$ = entity.get$(filter, remote$); // assert expect(actual$).toBeObservable(expected$); }); test('should not load instance if exist with single option ', () => { // arrange entity.add(user); const remote$ = cold(' --a', {a: [user]}); const expected$ = cold('a ', {a: [user]}); // act const actual$ = entity.get$(filter, remote$, {single: true}); // assert expect(actual$).toBeObservable(expected$); }); test('should not subscribe to remote$ if exist with single option ', () => { // arrange entity.add(user); const remote$ = cold(' --a', {a: [user]}); // act entity.get$(filter, remote$, {single: true}).subscribe(); // assert expect(remote$).toHaveNoSubscriptions(); }); }); describe('getById$', () => { test('should emit created earlier instance ', () => { // arrange entity.add(user); const expected$ = cold('a', {a: user}); // act const actual$ = entity.getById$(user.id); // assert expect(actual$).toBeObservable(expected$); }); test('should emit instance after response', () => { // arrange const remote$ = cold(' --a', {a: user}); const expected$ = cold('a-b ', {a: undefined, b: user}); // act const actual$ = entity.getById$(user.id, remote$); // assert expect(actual$).toBeObservable(expected$); }); test('should not load instance if exist with single option ', () => { // arrange entity.add(user); const remote$ = cold(' --a', {a: user}); const expected$ = cold('a ', {a: user}); // act const actual$ = entity.getById$(user.id, remote$, {single: true}); // assert expect(actual$).toBeObservable(expected$); }); test('should not subscribe to remote$ if exist with single option ', () => { // arrange entity.add(user); const remote$ = cold(' --a', {a: user}); // act entity.getById$(user.id, remote$, {single: true}).subscribe(); // assert expect(remote$).toHaveNoSubscriptions(); }); }); describe('getLoadingState$', () => { test('should return loading state', () => { // act const actual$ = entity.getLoadingState$(); // assert const expected$ = cold('a', {a: {isLoading: false}}); expect(actual$).toBeObservable(expected$); }); test.each` actionName | action ${'get$'} | ${(remote$: ColdObservable) => entity.get$(filter, remote$)} ${'getById$'} | ${(remote$: ColdObservable) => entity.getById$(user.id, remote$)} ${'addRemote$'} | ${(remote$: ColdObservable) => entity.addRemote$(user, user.id, remote$)} ${'changeRemote$'} | ${(remote$: ColdObservable) => entity.changeRemote$(user.id, {}, remote$)} ${'removeRemote$'} | ${(remote$: ColdObservable) => entity.removeRemote$(user.id, remote$)} `('should set loading state during remote$ for entity.$actionName', ({actionName, action}) => { // arrange const remoteMarble = ' --a'; const loadingStateMarble = 'a-b'; const response = actionName === 'get$' ? {data: undefined} : undefined; const remote$ = cold(remoteMarble, {a: response}); // act const actual$ = entity.getLoadingState$(); action(remote$).subscribe(); // assert const expected$ = cold(loadingStateMarble, { a: {isLoading: true}, b: {isLoading: false}, }); expect(actual$).toBeObservable(expected$); }); }); describe('getLoadingStates$', () => { test('should return loading states', () => { // act const actual$ = entity.getLoadingStates$(); // assert const expected$ = cold('a', {a: {}}); expect(actual$).toBeObservable(expected$); }); test.each` actionName | action ${'getById$'} | ${(remote$: ColdObservable) => entity.getById$(user.id, remote$)} ${'changeRemote$'} | ${(remote$: ColdObservable) => entity.changeRemote$(user.id, {}, remote$)} `('should set loading state during remote$ for entity.$actionName', ({action}) => { // arrange const remoteMarble = ' --a'; const loadingStateMarble = 'a-b'; const remote$ = cold(remoteMarble, {a: undefined}); // act const actual$ = entity.getLoadingStates$(); action(remote$).subscribe(); // assert const expected$ = cold(loadingStateMarble, { a: {[user.id]: {isLoading: true}}, b: {[user.id]: {isLoading: false}}, }); expect(actual$).toBeObservable(expected$); }); test('should set and then remove loading state for entity.removeRemote$', () => { // arrange const remoteMarble = ' --a'; const loadingStateMarble = 'a-(bc)'; const remote$ = cold(remoteMarble, {a: undefined}); // act const actual$ = entity.getLoadingStates$(); entity.removeRemote$(user.id, remote$).subscribe(); // assert const expected$ = cold(loadingStateMarble, { a: {[user.id]: {isLoading: true}}, b: {[user.id]: {isLoading: false}}, c: {}, }); expect(actual$).toBeObservable(expected$); }); }); describe('#changeRemote$', () => { const patch = {name: 'Alice'}; const remotePatch = {name: 'Mike'}; beforeEach(() => { entity.add(user); }); test.each` useResponsePatch ${false} ${true} `( 'should apply either patch or remotePatch after response based on useResponsePatch: $useResponsePatch', ({useResponsePatch}) => { // arrange const resultPatch = useResponsePatch ? remotePatch : patch; const remote$ = cold(' --a', {a: remotePatch}); const expected$ = cold('a-b ', {a: [user], b: [{...user, ...resultPatch}]}); const actual$ = entity.get$(filter); const options = {useResponsePatch}; // act entity.changeRemote$(user.id, patch, remote$, options).subscribe(); // assert expect(actual$).toBeObservable(expected$); }, ); test('should ignore in case of response fail', () => { // arrange const remote$ = cold(' --#'); const expected$ = cold('a', {a: [user]}); const actual$ = entity.get$(filter); // act entity.changeRemote$(user.id, patch, remote$).subscribe(); // assert expect(actual$).toBeObservable(expected$); }); test.each` useResponsePatch ${false} ${true} `( 'should apply patch immediately and based on useResponsePatch: $useResponsePatch apply remotePatch', ({useResponsePatch}) => { // arrange const remoteMarble = ' --a'; const expectedMarble = 'a-b-c'; const actMarble = ' --a'; const remote$ = cold(remoteMarble, {a: remotePatch}); const patched = {...user, ...patch}; const resultPatched = useResponsePatch ? {...patched, ...remotePatch} : patched; const expected$ = cold(expectedMarble, { a: [user], b: [patched], c: [resultPatched], }); const actual$ = entity.get$(filter); const options = {optimistic: true, useResponsePatch}; // act cold(actMarble).pipe( switchMap(() => entity.changeRemote$(user.id, patch, remote$, options)), ).subscribe(); // assert expect(actual$).toBeObservable(expected$); }, ); }); describe('#removeRemote', () => { beforeEach(() => { entity.add(user); }); test.each` remoteMarble | expectedMarble ${'--a'} | ${'a-b-b'} ${'--#'} | ${'a-b-a'} `('should optimistic remove or undo for $remoteMarble', ({remoteMarble, expectedMarble}) => { // arrange const remote$ = cold(remoteMarble); const expected$ = cold(expectedMarble, {a: [user], b: []}); const actual$ = entity.get$(filter); const options: EntityRemoveOptions = {optimistic: true}; // act cold('--a', {a: null}).pipe( switchMap(() => entity.removeRemote$(user.id, remote$, options)), ).subscribe(); // assert expect(actual$).toBeObservable(expected$); }); }); describe('#addRemote', () => { test.each` remoteMarble | expectedMarble ${'--a'} | ${'a-b-c'} ${'--#'} | ${'a-b-d'} `('should optimistic add with response.id or undo for $remoteMarble', ({remoteMarble, expectedMarble}) => { // arrange const newId = '999'; const tempId = '666'; mocked(uuid.v4).mockReturnValueOnce(tempId); const response = {id: newId}; const remote$ = cold(remoteMarble, {a: response}); const expected$ = cold(expectedMarble, { a: undefined, b: [{...user, id: tempId}], c: [{...user, id: newId}], d: [], }); const actual$ = entity.get$(filter); const options: EntityAddOptions = {optimistic: true}; // act cold('--a').pipe( switchMap(() => entity.addRemote$(user, filter, remote$, options)), ).subscribe(); // assert expect(actual$).toBeObservable(expected$); }); }); describe('#getAll$', () => { test('should return all entities', () => { // arrange const user1 = {id: 1, name: 'User 1'}; const user2 = {id: 2, name: 'User 2'}; const actual$ = entity.getAll$(); const actMarble = ' -a-b'; const expectedMarble = 'ab-c'; const expected$ = cold(expectedMarble, {a: [], b: [user1], c: [user1, user2]}); // act cold(actMarble, {a: user1, b: user2}).subscribe((value) => { entity.add(value, Math.random()); }); // assert expect(actual$).toBeObservable(expected$); }); }); describe('#clear', () => { test('should clear entities from specific page', () => { // arrange const user1 = {id: 1, name: 'User 1'}; const user2 = {id: 2, name: 'User 2'}; const filter1 = Math.random(); const filter2 = Math.random(); entity.add(user1, filter1); entity.add(user2, filter2); const actual$ = entity.get$(filter1); const actMarble = ' --a'; const expectedMarble = 'a-b'; const expected$ = cold(expectedMarble, {a: [user1], b: undefined}); // act cold(actMarble).subscribe(() => { entity.clear(filter1); }); // assert expect(actual$).toBeObservable(expected$); }); }); describe('#clearAll', () => { test('should clear all entities', () => { // arrange const user1 = {id: 1, name: 'User 1'}; const user2 = {id: 2, name: 'User 2'}; entity.add(user1, Math.random()); entity.add(user2, Math.random()); const actual$ = entity.getAll$(); const actMarble = ' --a'; const expectedMarble = 'a-b'; const expected$ = cold(expectedMarble, {a: [user1, user2], b: []}); // act cold(actMarble).subscribe(() => { entity.clearAll(); }); // assert expect(actual$).toBeObservable(expected$); }); }); describe('#maxPagesCount', () => { test('should shift all pages order and remove first', () => { // arrange const user1 = {id: 1, name: 'User 1'}; const user2 = {id: 2, name: 'User 2'}; const user3 = {id: 3, name: 'User 2'}; entity.add(user1, Math.random()); entity.add(user2, Math.random()); const actual$ = entity.getPages$(); const actMarble = ' --a'; const expectedMarble = 'a-b'; const page1: Page = {ids: [user1.id], order: 0}; const page2: Page = {ids: [user2.id], order: 1}; const page3: Page = {ids: [user3.id]}; const expected$ = cold(expectedMarble, { a: [page1, page2], b: [{...page2, order: 0}, {...page3, order: 1}], }); // act cold(actMarble).subscribe(() => { entity.add(user3, Math.random()); }); // assert expect(actual$).toBeObservable(expected$); }); }); }); ================================================ FILE: src/entity/entity.spec.ts ================================================ import { Id, LoadingState } from '../system-types'; import { getOrderedMarbleStream, initStoreWithPr } from '../testing/helpers'; import { cold } from 'jest-marbles'; import { TestStore } from '../testing/store'; import { createEntity } from './entity'; import { createPainlessRedux } from '../painless-redux/painless-redux'; import { PainlessRedux } from '../painless-redux/types'; import { Entity, EntityInternalSetLoadingStateOptions, EntitySchema, Pagination } from './types'; import { BehaviorSubject } from 'rxjs'; import { mocked } from 'ts-jest/utils'; import * as uuid from 'uuid'; import { ChangeOptions } from '../shared/change/types'; import { RequestOptions } from '../shared/types'; jest.mock('uuid'); interface TestEntity { name: string; } describe('Entity', () => { let entity: Entity; let pr: PainlessRedux; let store: TestStore; let user: any; let schema: Partial>; let user2: any; let user3: any; let reducer: any; let idFn = jest.fn((data) => data.id); const setLoadingStateActionFactory = ( state: LoadingState, id?: Id, options?: RequestOptions, ) => entity.actionCreators.SET_LOADING_STATE( state, undefined, id, undefined, options as unknown as EntityInternalSetLoadingStateOptions, ); beforeEach(() => { store = new TestStore(undefined, (state) => state); pr = createPainlessRedux(store, { useAsapSchedulerInLoadingGuards: false }); schema = { name: 'test', pageSize: 2, id: idFn, }; entity = createEntity(pr, schema); reducer = initStoreWithPr(store, pr); user = { id: 1, name: 'John' }; user2 = { id: 2, name: 'Alex' }; user3 = { id: 3, name: 'Frank' }; }); describe('#add', () => { test('should add entity', () => { // arrange const addAction = entity.actionCreators.ADD(user); const actions$ = getOrderedMarbleStream(addAction); // act entity.add(user); // assert expect(store.actions$).toBeObservable(actions$); }); test('should resolve an id from schema.id even it has id already, then add an entity', () => { // arrange mocked(idFn).mockImplementationOnce((data): string | number => `$${data.name}`); const userWithIgnoredId = { name: user.name, id: 'should not be used' }; const addAction = entity.actionCreators.ADD({ ...userWithIgnoredId, id: `$${userWithIgnoredId.name}`, }); const actions$ = getOrderedMarbleStream(addAction); // act entity.add(userWithIgnoredId); // assert expect(store.actions$).toBeObservable(actions$); }); xtest('should resolve an id if it is not existed and schema.id is not defined, then add an entity', () => { // arrange const randomId = 'adav3r2brh35'; mocked(uuid.v4).mockReturnValueOnce(randomId); const userWithoutId = { name: user.name }; const addAction = entity.actionCreators.ADD({ ...userWithoutId, id: randomId, }); const actions$ = getOrderedMarbleStream(addAction); // act entity.add(userWithoutId); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#addList', () => { test('should add entities', () => { // arrange const users = [user, user2]; const addListAction = entity.actionCreators.ADD_LIST(users); const actions$ = getOrderedMarbleStream(addListAction); // act entity.addList(users); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#change', () => { test('should change entity', () => { // arrange const patch = { name: 'Frank' }; const changeAction = entity.actionCreators.CHANGE(user.id, patch); const actions$ = getOrderedMarbleStream(changeAction); // act entity.change(user.id, patch); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#changeRemote', () => { const patch = { name: 'Frank' }; const responsePatch = { name: 'Alice' }; const changeId = 'vn3n5k'; mocked(uuid.v4).mockReturnValue(changeId); test.each` useResponsePatch ${false} ${true} `('should change entity remotely with useResponsePatch: $useResponsePatch', ({ useResponsePatch }) => { // arrange const options: ChangeOptions = { useResponsePatch, rethrow: false }; const id = user.id; const remote$ = cold(' --a| ', { a: responsePatch }); const resolvePatch = useResponsePatch ? responsePatch : patch; const changeAction = entity.actionCreators.CHANGE(id, resolvePatch, changeId, options); const actions$ = cold('a-(bc)', { a: setLoadingStateActionFactory({ isLoading: true }, id, options), b: changeAction, c: setLoadingStateActionFactory({ isLoading: false }, id, options), }); // act entity.changeRemote$(id, patch, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); describe('#optimistic', () => { test.each` useResponsePatch ${false} ${true} `('should change entity remotely with useResponsePatch: $useResponsePatch', ({ useResponsePatch }) => { const options: ChangeOptions = { optimistic: true, useResponsePatch, rethrow: false }; const remote$ = cold(' --a| ', { a: responsePatch }); const id = user.id; const changeAction = entity.actionCreators.CHANGE(id, patch, changeId, options); const resolvePatch = useResponsePatch ? responsePatch : undefined; const resolveChangeAction = entity.actionCreators.RESOLVE_CHANGE( id, changeId, true, resolvePatch, options, ); const actions$ = cold('a-b', { a: changeAction, b: resolveChangeAction, }); // act entity.changeRemote$(id, patch, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should resolve unsuccessfully if response failed', () => { const options: ChangeOptions = { optimistic: true, rethrow: false }; const error = 'Error'; const failedRemote$ = cold(' --#|', undefined, error); const id = user.id; const changeAction = entity.actionCreators.CHANGE(id, patch, changeId, options); const resolveChangeAction = entity.actionCreators.RESOLVE_CHANGE( id, changeId, false, undefined, options, ); const actions$ = cold('a-(bc)', { a: changeAction, b: resolveChangeAction, c: setLoadingStateActionFactory({ isLoading: false, error }, id, options), }); // act entity.changeRemote$(id, patch, failedRemote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); }); }); describe('#remove', () => { test('should remove entity', () => { // arrange const removeAction = entity.actionCreators.REMOVE(user.id); const actions$ = getOrderedMarbleStream(removeAction); // act entity.remove(user.id); // assert expect(store.actions$).toBeObservable(actions$); }); test('should remote remove entity', () => { // arrange const options = { rethrow: false }; const removeAction = entity.actionCreators.REMOVE(user.id, options); const remote$ = cold(' --a| ', { a: null }); const actions$ = cold('a-(bc)', { a: setLoadingStateActionFactory({ isLoading: true }, user.id, options), b: setLoadingStateActionFactory({ isLoading: false }, user.id, options), c: removeAction, }); // act entity.removeRemote$(user.id, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should optimistic remove entity', () => { // arrange const options = { optimistic: true, rethrow: false }; const removeAction = entity.actionCreators.REMOVE(user.id, options); const resolveRemoveAction = entity.actionCreators.RESOLVE_REMOVE(user.id, true, options); const remote$ = cold(' --a| ', { a: null }); const actions$ = cold('a-b', { a: removeAction, b: resolveRemoveAction, }); // act entity.removeRemote$(user.id, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should optimistic undo if failed', () => { // arrange const options = { optimistic: true, rethrow: false }; const error = 'Failed'; const id = user.id; const removeAction = entity.actionCreators.REMOVE(id, options); const resolveRemoveAction = entity.actionCreators.RESOLVE_REMOVE(id, false, options); const remote$ = cold(' --#| ', undefined, error); const actions$ = cold('a-(bc)', { a: removeAction, b: resolveRemoveAction, c: setLoadingStateActionFactory({ isLoading: false, error }, id, options), }); // act entity.removeRemote$(id, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#removeList', () => { test('should remove entities', () => { // arrange const ids = [user.id, user2.id]; const removeAction = entity.actionCreators.REMOVE_LIST(ids); const actions$ = getOrderedMarbleStream(removeAction); // act entity.removeList(ids); // assert expect(store.actions$).toBeObservable(actions$); }); test('should remote remove entities', () => { // arrange const options = { rethrow: false }; const ids = [user.id, user2.id]; const removeAction = entity.actionCreators.REMOVE_LIST(ids, options); const remote$ = cold(' --a| ', { a: null }); const actions$ = cold('a-(bc)', { a: entity.actionCreators.SET_LOADING_STATES({ isLoading: true }, ids), b: entity.actionCreators.SET_LOADING_STATES({ isLoading: false }, ids), c: removeAction, }); // act entity.removeListRemote$(ids, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should optimistic remove entities', () => { // arrange const ids = [user.id, user2.id]; const options = { optimistic: true, rethrow: false }; const removeAction = entity.actionCreators.REMOVE_LIST(ids, options); const resolveRemoveAction = entity.actionCreators.RESOLVE_REMOVE_LIST(ids, true, options); const remote$ = cold(' --a| ', { a: null }); const actions$ = cold('a-b', { a: removeAction, b: resolveRemoveAction, }); // act entity.removeListRemote$(ids, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should optimistic undo if failed', () => { // arrange const ids = [user.id, user2.id]; const options = { optimistic: true, rethrow: false }; const error = 'Failed'; const removeAction = entity.actionCreators.REMOVE_LIST(ids, options); const resolveRemoveAction = entity.actionCreators.RESOLVE_REMOVE_LIST(ids, false, options); const remote$ = cold(' --#| ', undefined, error); const actions$ = cold('a-(bc)', { a: removeAction, b: resolveRemoveAction, c: entity.actionCreators.SET_LOADING_STATES({ isLoading: false, error }, ids), }); // act entity.removeListRemote$(ids, remote$, options).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#create', () => { test('should create entity', () => { // arrange const createAction = entity.actionCreators.ADD(user); const actions$ = cold('a', { a: createAction }); // act entity.add(user); // assert expect(store.actions$).toBeObservable(actions$); }); test('should remote create entity', () => { // arrange const options = { rethrow: false }; const remote$ = cold('--a|', { a: user }); const createAction = entity.actionCreators.ADD(user, undefined, undefined, options); const actions$ = cold('a-(bc)', { a: setLoadingStateActionFactory({ isLoading: true }, undefined, options), b: createAction, c: setLoadingStateActionFactory({ isLoading: false }, undefined, options), }); // act const actual = entity.addRemote$(user, undefined, remote$, options); // assert expect(actual).toBeObservable(remote$); expect(store.actions$).toBeObservable(actions$); }); }); describe('#getById$', () => { test('should return observable to entity', () => { // arrange const storeObs = cold('a', { a: undefined }); // act const actual = entity.getById$(1); // assert expect(actual).toBeObservable(storeObs); }); test('should load entity', () => { // arrange const remote$ = cold('--a|', { a: user }); const addAction = entity.actionCreators.ADD(user); const actions$ = cold('a-(bc)', { a: setLoadingStateActionFactory({ isLoading: true }, user.id), b: addAction, c: setLoadingStateActionFactory({ isLoading: false }, user.id), }); // act entity.getById$(user.id, remote$).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should load entity with tuple id', () => { // arrange const data = { objectId: '23a4123', type: 5, name: 'Some object 1' }; const remote$ = cold('--a|', { a: data }); const id = [data.objectId, data.type].toString(); const addAction = entity.actionCreators.ADD({ id, ...data }); const actions$ = cold('a-(bc)', { a: setLoadingStateActionFactory({ isLoading: true }, id), b: addAction, c: setLoadingStateActionFactory({ isLoading: false }, id), }); // act entity.getById$(id, remote$).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#get$', () => { test('should return observable to entity', () => { // arrange const storeObs = cold('a', { a: undefined }); // act const actual = entity.get$(null); // assert expect(actual).toBeObservable(storeObs); }); test('should load entities', () => { // arrange const users = [user]; const remote$ = cold('--a|', { a: { data: users } }); const filter = null; const addAction = entity.actionCreators.ADD_LIST(users, filter, true, false); const actions$ = cold('a-(bc)', { a: setLoadingStateActionFactory({ isLoading: true }), b: addAction, c: setLoadingStateActionFactory({ isLoading: false }), }); // act entity.get$(filter, remote$).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); xtest('should log if observable source throws error', () => { // arrange const error = new Error('Some error'); const remote$ = cold('#|', null, error); global.console.error = jest.fn(); // act entity.get$(null, remote$).subscribe(); // assert expect(global.console.error).toBeCalled(); }); test('should set error if observable source throws error', () => { // arrange const error = 'Some error'; const remote$ = cold('--#|', null, error); const actions$ = cold('a-b', { a: setLoadingStateActionFactory({ isLoading: true }), b: setLoadingStateActionFactory({ isLoading: false, error }), }); // act entity.get$(null, remote$).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); test('should page entities', () => { // arrange const remoteMarble = ' --a| --a| --a| --a| '; const paginationMarble = '-------a------b------b-----'; const actionsMarble = ' a-(bc)-a-(ec)-a-(bc)-a-(bc)'; const filter = null; const options = undefined; const users1 = [user, user2]; const users2 = [user3]; const remote$ = (value: Pagination) => { const users = value.index ? users2 : users1; return cold('--a|', { a: { data: users } }); }; const addAction = entity.actionCreators.ADD_LIST(users1, filter, true, true); const addAction2 = entity.actionCreators.ADD_LIST(users2, filter); const paginator = new BehaviorSubject(true); const setLoadingStateTrueAction = setLoadingStateActionFactory({ isLoading: true }); const setLoadingStateFalseAction = setLoadingStateActionFactory({ isLoading: false }); cold(paginationMarble, { a: true, b: false, }).subscribe((value) => { paginator.next(value); }); const actions$ = cold(actionsMarble, { a: setLoadingStateTrueAction, b: addAction, c: setLoadingStateFalseAction, e: addAction2, }); // act entity.get$(filter, remote$, options, paginator).subscribe(); // assert expect(store.actions$).toBeObservable(actions$); }); }); describe('#getDictionary$', () => { test('should return page as dictionary observable', () => { // arrange const storeObs = cold('a', { a: {} }); // act const actual = entity.getDictionary$(undefined); // assert expect(actual).toBeObservable(storeObs); }); }); }); ================================================ FILE: src/entity/entity.ts ================================================ import { Entity, EntityActionTypes, EntitySchema, EntityState } from './types'; import { createEntitySelectors } from './selectors'; import { PainlessRedux, SlotTypes } from '../painless-redux/types'; import { createEntityActionTypes, createIdResolver, getFullEntitySchema } from './utils'; import { createEntityReducer } from './reducer'; import { EntityActions } from './actions'; import { createDispatchEntityMethods } from './methods/dispatch/dispatch'; import { createEntityActionCreators } from './action-creators'; import { createSelectEntityMethods } from './methods/select/select'; import { createMixedEntityMethods } from './methods/mixed/mixed'; export const createEntity = ( pr: PainlessRedux, schema?: Partial>, ): Entity => { const fullSchema = getFullEntitySchema(schema); const actionTypes = createEntityActionTypes(fullSchema.name); const actionCreators = createEntityActionCreators(actionTypes, fullSchema); const reducer = createEntityReducer(actionTypes, fullSchema); const { selector, dispatcher, selectManager, } = pr.registerSlot, EntityActionTypes, EntityActions>( SlotTypes.Entity, fullSchema.name, reducer, actionCreators, ); const selectors = createEntitySelectors(selector, fullSchema.hashFn); const idResolver = createIdResolver(fullSchema); const selectMethods = createSelectEntityMethods(selectManager, selectors); const dispatchMethods = createDispatchEntityMethods(dispatcher, idResolver, selectMethods, fullSchema); const mixedMethods = createMixedEntityMethods(dispatchMethods, selectMethods, fullSchema, pr.schema); const { changeWithId, resolveChange, ...publicDispatchMethods } = dispatchMethods; const { get$, getDictionary$, getById$, ...publicSelectMethods } = selectMethods; return { ...publicDispatchMethods, ...mixedMethods, ...publicSelectMethods, actionCreators, }; }; ================================================ FILE: src/entity/methods/dispatch/dispatch.ts ================================================ import { Dispatcher } from '../../../dispatcher/types'; import { EntityActionTypes, EntityAddListOptions, EntityAddOptions, EntityInternalAddListOptions, EntityInternalAddOptions, EntityInternalSetLoadingStateOptions, EntityRemoveListOptions, EntityRemoveOptions, EntitySchema, EntitySetLoadingStateOptions, EntityType, IdPatch, IdPatchRequest, } from '../../types'; import { EntityActions } from '../../actions'; import { DeepPartial, Id, LoadingState } from '../../../system-types'; import { DispatchEntityMethods } from './types'; import { isNil } from 'lodash-es'; import { affectLoadingStateFactory } from '../../../affect-loading-state/affect-loading-state'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { normalizePatch } from '../../../shared/change/utils'; import { SelectEntityMethods } from '../select/types'; import { AffectLoadingStateFactory } from '../../..'; export const createDispatchEntityMethods = ( dispatcher: Dispatcher, idResolver: (data: T) => EntityType, selectMethods: SelectEntityMethods, schema: EntitySchema, ): DispatchEntityMethods => { const add = ( entity: T, config?: unknown, options?: EntityAddOptions, ) => { entity = idResolver(entity); const internalOptions: EntityInternalAddOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; return dispatcher.createAndDispatch('ADD', [entity, config, undefined], internalOptions); }; const addWithId = ( entity: T, tempId: string, config?: unknown, options?: EntityAddOptions, ) => { const internalOptions: EntityInternalAddOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; return dispatcher.createAndDispatch('ADD', [entity, config, tempId], internalOptions); }; const resolveAdd = ( result: T, success: boolean, tempId: string, config?: unknown, options?: EntityAddOptions, ) => { return dispatcher.createAndDispatch('RESOLVE_ADD', [result, success, tempId, config], options); }; const addList = ( entities: T[], config?: unknown, isReplace: boolean = false, hasMore: boolean = false, metadata?: TPageMetadata, options?: EntityAddListOptions, ) => { const internalOptions: EntityInternalAddListOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; entities = entities.map((entity) => idResolver(entity)); return dispatcher.createAndDispatch('ADD_LIST', [entities, config, isReplace, hasMore, metadata], internalOptions); }; const changeWithId = ( id: Id, patch: PatchRequest, changeId: string | undefined, options?: ChangeOptions, ) => { const normalizedPatch = normalizePatch(patch, selectMethods.getById$(id)); return dispatcher.createAndDispatch('CHANGE', [id, normalizedPatch, changeId], options); }; const change = ( id: Id, patch: PatchRequest, options?: ChangeOptions, ) => { return changeWithId(id, patch, undefined, options); }; const changeListWithId = ( patches: IdPatchRequest[], changeId: string | undefined, options?: ChangeOptions, ) => { const normalizedPatches: IdPatch[] = patches.map((patch) => ({ ...patch, patch: normalizePatch(patch.patch, selectMethods.getById$(patch.id)), })); return dispatcher.createAndDispatch('CHANGE_LIST', [normalizedPatches, changeId], options); }; const changeList = ( patches: IdPatchRequest[], options?: ChangeOptions, ) => { return changeListWithId(patches, undefined, options); }; const resolveChange = ( id: Id, changeId: Id, success: boolean, remotePatch: DeepPartial, options?: ChangeOptions, ) => { return dispatcher.createAndDispatch('RESOLVE_CHANGE', [id, changeId, success, remotePatch], options); }; const resolveChangeList = ( patches: IdPatch[], changeId: string, success: boolean, options?: ChangeOptions, ) => { return dispatcher.createAndDispatch('RESOLVE_CHANGE_LIST', [patches, changeId, success], options); }; const remove = ( id: Id, options?: EntityRemoveOptions, ) => { return dispatcher.createAndDispatch('REMOVE', [id], options); }; const resolveRemove = ( id: Id, success: boolean, options?: EntityRemoveOptions, ) => { return dispatcher.createAndDispatch('RESOLVE_REMOVE', [id, success], options); }; const restoreRemoved = ( id: Id, ) => { return dispatcher.createAndDispatch('RESTORE_REMOVED', [id]); }; const removeList = ( ids: Id[], options?: EntityRemoveListOptions, ) => { return dispatcher.createAndDispatch('REMOVE_LIST', [ids], options); }; const resolveRemoveList = ( ids: Id[], success: boolean, options?: EntityRemoveListOptions, ) => { return dispatcher.createAndDispatch('RESOLVE_REMOVE_LIST', [ids, success], options); }; const restoreRemovedList = ( ids: Id[], ) => { return dispatcher.createAndDispatch('RESTORE_REMOVED_LIST', [ids]); }; const setLoadingState = ( state: LoadingState, config?: unknown, options?: EntitySetLoadingStateOptions, ) => { const internalOptions: EntityInternalSetLoadingStateOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; return dispatcher.createAndDispatch('SET_LOADING_STATE', [state, config, undefined, undefined], internalOptions); }; const setLoadingStateById = ( id: Id, state: LoadingState, options?: EntitySetLoadingStateOptions, ) => { const internalOptions: EntityInternalSetLoadingStateOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; return dispatcher.createAndDispatch('SET_LOADING_STATE', [state, undefined, id, undefined], internalOptions); }; const setLoadingStateForKey = ( id: Id, key: string, state: LoadingState, options?: EntitySetLoadingStateOptions, ) => { const internalOptions: EntityInternalSetLoadingStateOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; return dispatcher.createAndDispatch('SET_LOADING_STATE', [state, undefined, id, key], internalOptions); }; const setLoadingStateByIds = ( ids: Id[], state: LoadingState, options?: EntitySetLoadingStateOptions, ) => { const internalOptions: EntityInternalSetLoadingStateOptions = { ...options, maxPagesCount: schema.maxPagesCount, }; return dispatcher.createAndDispatch('SET_LOADING_STATES', [state, ids], internalOptions); }; const clear = (config: unknown) => { return dispatcher.createAndDispatch('CLEAR', [config]); }; const clearAll = () => { return dispatcher.createAndDispatch('CLEAR_ALL', []); }; const setLoadingStateBus = ( state: LoadingState, id?: Id, config?: unknown, key?: string, options?: EntitySetLoadingStateOptions, ) => { if (!isNil(id)) { if (!isNil(key)) { return setLoadingStateForKey(id, key, state, options); } else { return setLoadingStateById(id, state, options); } } else { if (state.error) { console.error(state.error); } return setLoadingState(state, config, options); } }; const affectLoadingState = ( config?: unknown, key?: string, rethrow?: boolean, ) => { const setter = (state: LoadingState) => { setLoadingStateBus(state, undefined, config, key); }; return affectLoadingStateFactory(setter, rethrow); }; const affectLoadingStateById = ( id?: Id, key?: string, rethrow?: boolean, ) => { const setter = (state: LoadingState) => { setLoadingStateBus(state, id, undefined, key); }; return affectLoadingStateFactory(setter, rethrow); }; const affectLoadingStateByConfigOrId = ( config?: unknown, id?: Id, key?: string, rethrow?: boolean, ): AffectLoadingStateFactory => { const setter = (state: LoadingState) => { setLoadingStateBus(state, id, config, key); }; return affectLoadingStateFactory(setter, rethrow); }; const batch = ( actions: EntityActions[], ) => { return dispatcher.createAndDispatch('BATCH', [actions]); }; return { add, addWithId, addList, change, changeList, changeListWithId, changeWithId, resolveChange, resolveChangeList, resolveAdd, remove, resolveRemove, restoreRemoved, removeList, resolveRemoveList, restoreRemovedList, setLoadingState, clear, clearAll, setLoadingStateBus, setLoadingStateById, setLoadingStateForKey, affectLoadingState, affectLoadingStateById, affectLoadingStateByConfigOrId, batch, setLoadingStateByIds, }; }; ================================================ FILE: src/entity/methods/dispatch/types.ts ================================================ import { EntityAddListOptions, EntityAddOptions, EntityRemoveListOptions, EntityRemoveOptions, EntitySetLoadingStateOptions, IdPatch, IdPatchRequest, } from '../../types'; import { DeepPartial, Id, LoadingState } from '../../../system-types'; import { EntityActions } from '../../actions'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { AffectLoadingStateFactory } from '../../..'; export interface DispatchEntityMethods { add( data: T, config?: unknown, options?: EntityAddOptions, ): EntityActions; addWithId( data: T, tempId: string, config?: unknown, options?: EntityAddOptions, ): EntityActions; resolveAdd( data: T | undefined, success: boolean, tempId: string, config?: unknown, options?: EntityAddOptions, ): EntityActions; addList( data: T[], config?: unknown, isReplace?: boolean, hasMore?: boolean, metadata?: TPageMetadata, options?: EntityAddListOptions, ): EntityActions; change( id: Id, patch: PatchRequest, options?: ChangeOptions, ): EntityActions; changeList( patches: IdPatchRequest[], options?: ChangeOptions, ): EntityActions; changeListWithId( patches: IdPatchRequest[], changeId: string, options?: ChangeOptions, ): EntityActions; changeWithId( id: Id, patch: PatchRequest, changeId: string, options?: ChangeOptions, ): EntityActions; resolveChange( id: Id, changeId: string, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ): EntityActions; resolveChangeList( patches: IdPatch[], changeId: string, success: boolean, options?: ChangeOptions, ): EntityActions; remove( id: Id, options?: EntityRemoveOptions, ): EntityActions; resolveRemove( id: Id, success: boolean, options?: EntityRemoveOptions, ): EntityActions; restoreRemoved( id: Id, ): EntityActions; removeList( ids: Id[], options?: EntityRemoveListOptions, ): EntityActions; resolveRemoveList( ids: Id[], success: boolean, options?: EntityRemoveListOptions, ): EntityActions; restoreRemovedList( ids: Id[], ): EntityActions; setLoadingState( state: LoadingState, config?: unknown, options?: EntitySetLoadingStateOptions, ): EntityActions; clear(config: unknown): EntityActions; clearAll(): EntityActions; setLoadingStateById( id: Id, state: LoadingState, options?: EntitySetLoadingStateOptions, ): EntityActions; setLoadingStateByIds( ids: Id[], state: LoadingState, options?: EntitySetLoadingStateOptions, ): EntityActions; setLoadingStateForKey( id: Id, key: string, state: LoadingState, options?: EntitySetLoadingStateOptions, ): EntityActions; setLoadingStateBus( state: LoadingState, id?: Id, config?: unknown, key?: string, options?: EntitySetLoadingStateOptions, ): EntityActions; batch( actions: EntityActions[], ): EntityActions; affectLoadingState( config?: unknown, key?: string, rethrow?: boolean, ): AffectLoadingStateFactory; affectLoadingStateById( id?: Id, key?: string, rethrow?: boolean, ): AffectLoadingStateFactory; affectLoadingStateByConfigOrId( config?: unknown, id?: Id, key?: string, rethrow?: boolean, ): AffectLoadingStateFactory; } ================================================ FILE: src/entity/methods/mixed/mixed.ts ================================================ import { EntityAddOptions, EntityGetListOptions, EntityGetOptions, EntityLoadListOptions, EntityLoadOptions, EntityRemoveListOptions, EntityRemoveOptions, EntitySchema, IdPatch, IdPatchRequest, PaginatedResponse, Pagination, Response$Factory, ResponseArray, } from '../../types'; import { BehaviorSubject, EMPTY, merge, Observable, of } from 'rxjs'; import { DispatchEntityMethods } from '../dispatch/types'; import { SelectEntityMethods } from '../select/types'; import { getPaginated$ } from '../../utils'; import { DeepPartial, Dictionary, Id, LoadingState } from '../../../system-types'; import { createMixedEntityMethodsUtils } from './utils'; import { MixedEntityMethods } from './types'; import { v4 } from 'uuid'; import { PainlessReduxSchema } from '../../../painless-redux/types'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { getPatchByOptions, getResolvePatchByOptions, normalizePatch } from '../../../shared/change/utils'; import { getRemotePipe, guardIfLoading } from '../../../shared/utils'; import { switchMap } from 'rxjs/operators'; import { typedDefaultsDeep } from '../../../utils'; export const createMixedEntityMethods = ( dispatchMethods: DispatchEntityMethods, selectMethods: SelectEntityMethods, schema: EntitySchema, prSchema: PainlessReduxSchema, ): MixedEntityMethods => { const { getPaginator, tryInvoke, } = createMixedEntityMethodsUtils(dispatchMethods, selectMethods, schema, prSchema); const loadList$ = ( config: unknown, dataSource: Observable> | Response$Factory, options?: EntityLoadListOptions, paginatorSubj?: BehaviorSubject, ): Observable> => { const store$ = selectMethods.get$(config); const sourcePipe = getRemotePipe, PaginatedResponse>({ options, store$, emitOnSuccess: true, remoteObsOrFactory: (pagination: Pagination) => getPaginated$(dataSource, pagination), success: (result?: PaginatedResponse) => { if (!result) return; const { index, size, response } = result; const data = response.data ?? []; const isReplace = index === 0; const hasMore = response.hasMore ?? data.length >= size; const metadata = response.metadata; return dispatchMethods.addList(data, config, isReplace, hasMore, metadata, options); }, setLoadingState: (state) => dispatchMethods.setLoadingStateBus(state, undefined, config, undefined, options), }, ); const paginator = getPaginator(config, paginatorSubj, options); return paginator.pipe(sourcePipe); }; const loadById$ = ( id: Id, dataSource$: Observable, options?: EntityLoadOptions, ): Observable => { const store$ = selectMethods.getById$(id); const sourcePipe = getRemotePipe({ options, store$, emitOnSuccess: true, remoteObsOrFactory: dataSource$, success: (response) => { if (!response) return; const entity = { ...response, id }; return dispatchMethods.add(entity, undefined, options); }, setLoadingState: (state) => dispatchMethods.setLoadingStateBus(state, id, undefined, undefined, options), }); const loadingState$ = selectMethods.getLoadingStateById$(id, prSchema.useAsapSchedulerInLoadingGuards); return guardIfLoading(loadingState$).pipe(sourcePipe); }; const tryInvokeList$ = ( store$: Observable, config: unknown, dataSource?: Observable> | Response$Factory, options?: EntityGetListOptions, paginatorSubj?: BehaviorSubject, ) => { const invoker = (ds: Observable> | Response$Factory) => loadList$( config, ds, options, paginatorSubj, ); return tryInvoke(store$, invoker, dataSource); }; const get$ = ( config: unknown, dataSource?: Observable> | Response$Factory, options?: EntityGetListOptions, paginatorSubj?: BehaviorSubject, ): Observable => { const store$ = selectMethods.get$(config); return tryInvokeList$( store$, config, dataSource, options, paginatorSubj, ); }; const getDictionary$ = ( config: unknown, dataSource?: Observable> | Response$Factory, options?: EntityGetListOptions, paginatorSubj?: BehaviorSubject, ): Observable> => { const store$ = selectMethods.getDictionary$(config); return tryInvokeList$( store$, config, dataSource, options, paginatorSubj, ); }; const getById$ = ( id: Id, dataSource?: Observable, options?: EntityGetOptions, ): Observable => { const store$ = selectMethods.getById$(id); if (dataSource) { const remote$ = loadById$(id, dataSource, options).pipe(switchMap(() => EMPTY)); return merge(store$, remote$); } return store$; }; const addRemote$ = ( entity: T, config: unknown, dataSource$: Observable, options?: EntityAddOptions, ): Observable => { const tempId = v4(); options = typedDefaultsDeep(options, { rethrow: true }); const { addWithId, resolveAdd } = dispatchMethods; const sourcePipe = getRemotePipe({ options, remoteObsOrFactory: dataSource$, success: (result) => { const newEntity = options?.optimistic ? entity : result; if (!newEntity) return; return addWithId(newEntity, tempId, config, options); }, emitOnSuccess: true, optimistic: options.optimistic, optimisticResolve: (success, result) => resolveAdd(result, success, tempId, config, options), setLoadingState: (state) => dispatchMethods.setLoadingStateBus(state, undefined, config, undefined, options), }); return of(null).pipe(sourcePipe); }; const changeRemote$ = ( id: Id, patch: PatchRequest, dataSource$: Observable | undefined>, options?: ChangeOptions, ): Observable | undefined> => { options = typedDefaultsDeep(options, { rethrow: true }); const changeId = v4(); const { changeWithId, resolveChange, setLoadingStateBus } = dispatchMethods; const { getLoadingStateById$, getById$ } = selectMethods; const normalizedPatch = normalizePatch(patch, getById$(id)); const sourcePipe = getRemotePipe | undefined, DeepPartial | undefined>({ options, remoteObsOrFactory: dataSource$, success: ( response?: DeepPartial, ) => { const patchToApply = getPatchByOptions(normalizedPatch, response, options) ?? {}; return changeWithId(id, patchToApply, changeId, options); }, emitOnSuccess: true, optimistic: options.optimistic, optimisticResolve: ( success: boolean, response?: DeepPartial, ) => { const patchToApply = getResolvePatchByOptions(normalizedPatch, response, options); return resolveChange(id, changeId, success, patchToApply, options); }, setLoadingState: (state) => setLoadingStateBus(state, id, undefined, undefined, options), }); const loadingState$ = getLoadingStateById$(id, prSchema.useAsapSchedulerInLoadingGuards); return guardIfLoading(loadingState$).pipe(sourcePipe); }; const changeListRemote$ = ( patches: IdPatchRequest[], dataSource$: Observable[] | undefined>, options?: ChangeOptions, ): Observable[] | undefined> => { options = typedDefaultsDeep(options, { rethrow: true }); const changeId = v4(); const { changeListWithId, resolveChangeList, setLoadingStateByIds } = dispatchMethods; const { getLoadingStateByIds$, getById$ } = selectMethods; const normalizedPatches: IdPatch[] = patches.map((patch) => ({ ...patch, patch: normalizePatch(patch.patch, getById$(patch.id)), })); const ids = normalizedPatches.map((patch) => patch.id); const sourcePipe = getRemotePipe[] | undefined, IdPatch[]>({ options, remoteObsOrFactory: dataSource$, success: ( response?: IdPatch[] | undefined, ) => { const patchesToApply = normalizedPatches.map((idPatch) => { const responsePatch = response?.find((r) => r.id === idPatch.id); const patch = getPatchByOptions(idPatch.patch, responsePatch?.patch, options) ?? {}; return { ...idPatch, patch }; }); return changeListWithId(patchesToApply, changeId, options); }, emitOnSuccess: true, optimistic: options?.optimistic, optimisticResolve: ( success: boolean, response?: IdPatch[] | undefined, ) => { const patchesToApply = normalizedPatches.map((idPatch) => { const responsePatch = response?.find((r) => r.id === idPatch.id); const patch = getResolvePatchByOptions(idPatch.patch, responsePatch?.patch, options) ?? {}; return { ...idPatch, patch }; }); return resolveChangeList(patchesToApply, changeId, success, options); }, setLoadingState: (state) => setLoadingStateByIds(ids, state, options), }); const loadingState$ = getLoadingStateByIds$(ids, prSchema.useAsapSchedulerInLoadingGuards); return guardIfLoading(loadingState$).pipe(sourcePipe); }; const removeRemote$ = ( id: Id, observable: Observable, options?: EntityRemoveOptions, ): Observable => { options = typedDefaultsDeep(options, { rethrow: true }); const { remove, resolveRemove } = dispatchMethods; const sourcePipe = getRemotePipe({ options, remoteObsOrFactory: observable, success: () => remove(id, options), emitSuccessOutsideAffectState: true, emitOnSuccess: true, optimistic: options?.optimistic, optimisticResolve: (success: boolean) => resolveRemove(id, success, options), setLoadingState: (state) => dispatchMethods.setLoadingStateBus(state, id, undefined, undefined, options), }); const loadingState$ = selectMethods.getLoadingStateById$(id, prSchema.useAsapSchedulerInLoadingGuards); return guardIfLoading(loadingState$).pipe(sourcePipe); }; const removeListRemote$ = ( ids: Id[], observable: Observable, options?: EntityRemoveListOptions, ): Observable => { options = typedDefaultsDeep(options, { rethrow: true }); const { removeList, setLoadingStateByIds, resolveRemoveList } = dispatchMethods; const sourcePipe = getRemotePipe({ options, remoteObsOrFactory: observable, success: () => removeList(ids, options), emitSuccessOutsideAffectState: true, emitOnSuccess: true, optimistic: options?.optimistic, optimisticResolve: (success: boolean) => resolveRemoveList(ids, success, options), setLoadingState: (state) => setLoadingStateByIds(ids, state, options), }); const loadingState$ = selectMethods.getLoadingStateByIds$(ids, prSchema.useAsapSchedulerInLoadingGuards); return guardIfLoading(loadingState$).pipe(sourcePipe); }; return { loadList$, loadById$, get$, getDictionary$, getById$, addRemote$, changeRemote$, removeRemote$, removeListRemote$, changeListRemote$, }; }; ================================================ FILE: src/entity/methods/mixed/types.ts ================================================ import { EntityAddOptions, EntityGetListOptions, EntityGetOptions, EntityLoadListOptions, EntityLoadOptions, EntityRemoveListOptions, EntityRemoveOptions, IdPatch, IdPatchRequest, PaginatedResponse, Response$Factory, ResponseArray, } from '../../types'; import { BehaviorSubject, Observable } from 'rxjs'; import { DeepPartial, Dictionary, Id } from '../../../system-types'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; export interface MixedEntityMethods { loadList$( config: unknown, dataSource: Observable> | Response$Factory, options?: EntityLoadListOptions, paginatorSubj?: BehaviorSubject, ): Observable>; loadById$( id: Id, dataSource$: Observable, options?: EntityLoadOptions, ): Observable; get$( config: unknown, dataSource?: Observable> | Response$Factory, options?: EntityGetListOptions, paginatorSubj?: BehaviorSubject, ): Observable; getDictionary$( config: unknown, dataSource?: Observable> | Response$Factory, options?: EntityGetListOptions, paginatorSubj?: BehaviorSubject, ): Observable>; getById$( id: Id, dataSource?: Observable, options?: EntityGetOptions, ): Observable; addRemote$( entity: T, config: unknown, dataSource$: Observable, options?: EntityAddOptions, ): Observable; changeRemote$( id: Id, patch: PatchRequest, dataSource$: Observable | undefined>, options?: ChangeOptions, ): Observable | undefined>; changeListRemote$( patches: IdPatchRequest[], dataSource$: Observable[] | undefined>, options?: ChangeOptions, ): Observable[] | undefined>; removeRemote$( id: Id, observable: Observable, options?: EntityRemoveOptions, ): Observable; removeListRemote$( ids: Id[], observable: Observable, options?: EntityRemoveListOptions, ): Observable; } ================================================ FILE: src/entity/methods/mixed/utils.ts ================================================ import { BehaviorSubject, EMPTY, merge, Observable, of } from 'rxjs'; import { EntityGetListOptions, EntitySchema, Page, Pagination } from '../../types'; import { map, scan, switchMap, take } from 'rxjs/operators'; import { DispatchEntityMethods } from '../dispatch/types'; import { SelectEntityMethods } from '../select/types'; import { PainlessReduxSchema } from '../../../painless-redux/types'; import { ObservableOrFactory } from '../../../shared/types'; import { guardIfLoading } from '../../../shared/utils'; export const createMixedEntityMethodsUtils = ( dispatchMethods: DispatchEntityMethods, selectMethods: SelectEntityMethods, schema: EntitySchema, prSchema: PainlessReduxSchema, ) => { const { getPage$, getPageLoadingState$ } = selectMethods; const getPaginator = ( config: unknown, paginatorSubj?: BehaviorSubject, options?: EntityGetListOptions, ): Observable => { paginatorSubj = paginatorSubj ?? new BehaviorSubject(false); const page$ = getPage$(config); const loadingState$ = getPageLoadingState$(config, prSchema.useAsapSchedulerInLoadingGuards); return paginatorSubj.pipe( switchMap((isNext) => guardIfLoading(loadingState$).pipe(map(() => isNext))), scan(( prevIndex: number, isNext: boolean, ) => isNext ? prevIndex + 1 : 0, -1), map((index: number) => { const size = options?.pageSize ?? schema.pageSize; const from = index * size; const to = from + size - 1; return { index, size, from, to }; }), switchMap((paging: Pagination) => page$.pipe( take(1), map((page: Page | undefined) => !page || page.hasMore !== false), switchMap((hasMore: boolean) => paging.index === 0 || hasMore ? of(paging) : EMPTY), )), ); }; const tryInvoke = ( store$: Observable, invoker: (dataSource: ObservableOrFactory) => Observable, dataSource?: ObservableOrFactory, ) => { if (dataSource) { const result$ = invoker(dataSource).pipe(switchMap(() => EMPTY)); return merge(store$, result$); } return store$; }; return { getPaginator, tryInvoke }; }; ================================================ FILE: src/entity/methods/select/select.ts ================================================ import { EntitySelectors } from '../../types'; import { Observable } from 'rxjs'; import { SelectManager } from '../../../select-manager/types'; import { Dictionary, Id } from '../../../system-types'; import { SelectEntityMethods } from './types'; import { toDictionary } from '../../../utils'; export const createSelectEntityMethods = ( selectManager: SelectManager, selectors: EntitySelectors, ): SelectEntityMethods => { const get$ = (config: unknown): Observable => { const selector = selectors.createPageListByConfig(config); return selectManager.select$(selector); }; const getAll$ = (): Observable => { const selector = selectors.all; return selectManager.select$(selector); }; const getDictionary$ = (config: unknown): Observable> => { return get$(config).pipe(toDictionary()); }; const getById$ = (id: Id): Observable => { const selector = selectors.createActual(id); return selectManager.select$(selector); }; const getPage$ = ( config: unknown, isAsap: boolean = false, ) => { const selector = selectors.createPage(config); return selectManager.select$(selector, isAsap); }; const getPages$ = ( isAsap: boolean = false, ) => { const selector = selectors.allPages; return selectManager.select$(selector, isAsap); }; const getPageLoadingState$ = ( config: unknown, isAsap: boolean = false, ) => { const selector = selectors.createPageLoadingState(config); return selectManager.select$(selector, isAsap); }; const getLoadingStateById$ = ( id: Id, isAsap: boolean = false, ) => { const selector = selectors.createLoadingStateById(id); return selectManager.select$(selector, isAsap); }; const getLoadingState$ = () => { const selector = selectors.loadingState; return selectManager.select$(selector); }; const getLoadingStates$ = () => { const selector = selectors.loadingStates; return selectManager.select$(selector); }; const getLoadingStateByIds$ = (ids: Id[]) => { const selector = selectors.createLoadingStateByIds(ids); return selectManager.select$(selector); }; return { get$, getById$, getDictionary$, getPage$, getPageLoadingState$, getLoadingStateById$, getLoadingStateByIds$, getAll$, getPages$, getLoadingState$, getLoadingStates$, }; }; ================================================ FILE: src/entity/methods/select/types.ts ================================================ import { Observable } from 'rxjs'; import { Dictionary, Id, LoadingState } from '../../../system-types'; import { Page } from '../../types'; export interface SelectEntityMethods { get$(config: unknown): Observable; getDictionary$(config: unknown): Observable>; getById$(id: Id): Observable; getLoadingState$(): Observable; getLoadingStates$(): Observable>; getLoadingStateById$( id: Id, isAsap?: boolean, ): Observable; getLoadingStateByIds$( ids: Id[], isAsap?: boolean, ): Observable; getPage$( config: unknown, isAsap?: boolean, ): Observable | undefined>; getPageLoadingState$( config: unknown, isAsap?: boolean, ): Observable; getAll$(): Observable; getPages$(): Observable[]>; } ================================================ FILE: src/entity/reducer.ts ================================================ import { EntityActionTypes, EntitySchema, EntityState } from './types'; import { Reducer } from '../system-types'; import { combineReducers } from '../shared/utils'; import { createDictionaryReducer } from './reducers/dictionary'; import { createIdsReducer } from './reducers/ids'; import { createPagesReducer } from './reducers/pages'; import { createChange, createResolveChange, createSetLoadingState, EntityActions } from './actions'; import { createByIdLoadingStatesReducer } from './reducers/loading-states'; import { createEntityLoadingStateReducer } from './reducers/loading-state'; import { batchActionsReducerFactory } from '../shared/system/reducers'; const createBaseReducer = ( actionTypes: EntityActionTypes, ): Reducer, EntityActions> => combineReducers, EntityActions>({ dictionary: createDictionaryReducer(actionTypes), ids: createIdsReducer(actionTypes), pages: createPagesReducer(actionTypes), loadingStates: createByIdLoadingStatesReducer(actionTypes), loadingState: createEntityLoadingStateReducer(actionTypes), }); const createListReducer = ( actionTypes: EntityActionTypes, schema: EntitySchema, ): Reducer, EntityActions> => { const baseReducer = createBaseReducer(actionTypes); return (state: EntityState, action: EntityActions) => { switch (action.type) { case actionTypes.CHANGE_LIST: { const actionCreator = createChange(actionTypes); const { payload: { patches, changeId }, options } = action; const actions = patches.map((patch) => actionCreator( patch.id, patch.patch, changeId, options, )); return actions.reduce(baseReducer, state); } case actionTypes.RESOLVE_CHANGE_LIST: { const actionCreator = createResolveChange(actionTypes); const { payload: { patches, changeId, success }, options } = action; const actions = patches.map((patch) => actionCreator( patch.id, changeId, success, patch.patch, options, )); return actions.reduce(baseReducer, state); } case actionTypes.SET_LOADING_STATES: { const actionCreator = createSetLoadingState(actionTypes, schema); const { payload: { ids, state: loadingState }, options } = action; const actions = ids.map((id) => actionCreator( loadingState, undefined, id, undefined, options, )); return actions.reduce(baseReducer, state); } default: return baseReducer(state, action); } }; }; export const createEntityReducer = ( actionTypes: EntityActionTypes, schema: EntitySchema, ): Reducer, EntityActions> => { const listReducer = createListReducer(actionTypes, schema); return batchActionsReducerFactory(actionTypes, listReducer); }; ================================================ FILE: src/entity/reducers/dictionary.spec.ts ================================================ import { Id } from '../../system-types'; import { createTestHelpers } from '../../testing/helpers'; import { createDictionaryReducer } from './dictionary'; type TestEntity = { id: Id; profile?: { image?: string; age?: number; name?: string; }, } type TPageMetadata = any; const { reducer, actionCreators, } = createTestHelpers(createDictionaryReducer); describe('dictionary', () => { test('should return default state', () => { // act const actual = reducer(undefined, { type: 'INIT' } as any); // assert expect(actual).toEqual({}); }); test('should add entity', () => { // arrange const entity: TestEntity = { id: 1 }; const action = actionCreators.ADD(entity); // act const actual = reducer(undefined, action); // assert const expected = { [entity.id]: { actual: entity } }; expect(actual).toEqual(expected); }); test('should add entities', () => { // arrange const entities = [{ id: 1 }, { id: 2 }]; const action = actionCreators.ADD_LIST(entities, null); // act const actual = reducer(undefined, action); // assert const expected = entities.reduce(( memo: any, entity: any, ) => { memo[entity.id] = { actual: entity }; return memo; }, {}); expect(actual).toEqual(expected); }); test.each` options ${{ merge: true }} ${{ merge: false }} `('should merge entity with the same if options.merge option passed, otherwise replace ($options)', ({ options }) => { // arrange const entity = { id: 1, name: 'entity 1' }; const action = actionCreators.ADD(entity); const entity2 = { id: 1, age: 1 }; const action2 = actionCreators.ADD(entity2, undefined, undefined, options); // act const instances = [action, action2].reduce(reducer, undefined); const actual = instances[entity.id].actual; // assert const expected = options.merge ? { id: 1, name: 'entity 1', age: 1 } : { id: 1, age: 1 }; expect(actual).toEqual(expected); }); test('should remove entity', () => { // arrange const entity: TestEntity = { id: 1 }; const action = actionCreators.REMOVE(entity.id); // act const actual = reducer({ [entity.id]: { actual: entity, changes: [], }, }, action); // assert const expected = {}; expect(actual).toEqual(expected); }); test('should remove entities', () => { // arrange const entity1: TestEntity = { id: 1 }; const entity2: TestEntity = { id: 2 }; const action = actionCreators.REMOVE_LIST([entity1.id, entity2.id]); // act const actual = reducer({ [entity1.id]: { actual: entity1, changes: [], }, [entity2.id]: { actual: entity2, changes: [], }, }, action); // assert const expected = {}; expect(actual).toEqual(expected); }); describe('#CHANGE', () => { test.each` options ${{ merge: true }} ${{ merge: false }} `('should override entity when no merge option passed and merge otherwise ($options)', ({ options }) => { // arrange const entity: TestEntity = { id: 1, profile: { image: '1.png' } }; const patch = { profile: { age: 18 } }; const action = actionCreators.CHANGE(entity.id, patch, undefined, options); // act const instances = reducer({ [entity.id]: { actual: entity, changes: [], }, }, action); // assert const expected = options.merge ? { image: '1.png', age: 18 } : { age: 18 }; const actual = instances[entity.id].actual.profile; expect(actual).toEqual(expected); }); test.each` ifNotExist ${undefined} ${true} `( 'should either create entity or ignore based on options.ifNotExist=$ifNotExist', ({ ifNotExist }) => { // arrange const id = 1; const patch = { profile: { age: 18 } }; const action = actionCreators.CHANGE(id, patch, undefined, { ifNotExist }); // act const actual = reducer(undefined, action); // assert const expected = ifNotExist ? { [id]: { actual: { id, profile: { age: 18 } } }, } : {}; const expectedKeys = ifNotExist ? [id.toString()] : []; expect(Object.keys(actual)).toEqual(expectedKeys); expect(actual).toEqual(expected); }, ); }); }); ================================================ FILE: src/entity/reducers/dictionary.ts ================================================ import { Dictionary } from '../../system-types'; import { createAddByHash, createResolveChange, EntityActions } from '../actions'; import { EntityActionTypes, EntityInstanceState } from '../types'; import { keyBy } from 'lodash-es'; import { createInstanceReducer } from './instance'; const addInstances = ( state: Dictionary>, instances: EntityInstanceState[], ): Dictionary> => { const newInstances = keyBy>(instances, 'actual.id'); return { ...state, ...newInstances }; }; export const createDictionaryReducer = ( types: EntityActionTypes, ) => { const instanceReducer = createInstanceReducer(types); return ( state: Dictionary> = {}, action: EntityActions, ): Dictionary> => { switch (action.type) { case types.ADD: { const entity = action.payload.entity; const instanceState = state[entity.id]; const instance = instanceReducer(instanceState, action); if (!instance) return state; return addInstances(state, [instance]); } case types.RESOLVE_ADD: { const { payload: { success, result, tempId }, options, } = action; const optimisticCreated = state[tempId]; if (!optimisticCreated) return state; const { [tempId]: deleted, ...rest } = state; if (!success) return rest; const id = result.id; const patch = { ...optimisticCreated.actual, id }; const resolveChangeAction = createResolveChange(types)(tempId, tempId, true, patch, options); const instance = instanceReducer(state[tempId], resolveChangeAction); if (!instance) return state; return addInstances(state, [instance]); } case types.ADD_LIST: { const { payload: { entities, configHash }, options } = action; const add = createAddByHash(types); const instances = entities.map((entity) => { const action = add(entity, configHash, undefined, options); const instanceState = state[entity.id]; return instanceReducer(instanceState, action) as EntityInstanceState; }); return addInstances(state, instances); } case types.CHANGE: case types.RESOLVE_CHANGE: { const { payload: { id }, options: { ifNotExist } } = action; const instanceState = state[id]; if (instanceState === undefined && !ifNotExist) return state; const instance = instanceReducer(instanceState, action) as EntityInstanceState; return { ...state, [id]: instance }; } case types.REMOVE: case types.RESOLVE_REMOVE: case types.RESTORE_REMOVED: { const { payload: { id } } = action; const instanceState = state[id]; const instance = instanceReducer(instanceState, action); if (instance) return { ...state, [id]: instance }; const { [id]: deleted, ...rest } = state; return rest; } case types.REMOVE_LIST: case types.RESOLVE_REMOVE_LIST: case types.RESTORE_REMOVED_LIST: { const { payload: { ids } } = action; return ids.reduce((memo, id) => { const instanceState = state[id]; const instance = instanceReducer(instanceState, action); if (instance) { memo[id] = instance; } else { delete memo[id]; } return memo; }, { ...state }); } case types.CLEAR_ALL: { return {}; } default: return state; } }; }; ================================================ FILE: src/entity/reducers/ids.spec.ts ================================================ import { createIdsReducer } from './ids'; import { createTestHelpers } from '../../testing/helpers'; const { reducer, actionCreators, } = createTestHelpers(createIdsReducer); describe('ids', () => { describe('#ADD', () => { test('should add entity id', () => { // arrange const entity = { id: 1 }; const action = actionCreators.ADD(entity); // act const actual = reducer(undefined, action); // assert const expected = [entity.id]; expect(actual).toEqual(expected); }); test('should add entity id to options.pasteIndex', () => { // arrange const action = actionCreators.ADD({ id: 99 }, undefined, undefined, { pasteIndex: 2 }); // act const actual = reducer([1, 2, 3, 4], action); // assert const expected = [1, 2, 99, 3, 4]; expect(actual).toEqual(expected); }); }); test('should return default state', () => { // act const actual = reducer(undefined, { type: 'INIT' } as any); // assert expect(actual).toEqual([]); }); test('should add entity ids from payload.$source', () => { // arrange const entities = [{ id: 1 }, { id: 2 }]; const action = actionCreators.ADD_LIST(entities); // act const actual = reducer(undefined, action); // assert const expected = entities.map((entity) => entity.id); expect(actual).toEqual(expected); }); test('should remove entity id', () => { // arrange const entity = { id: 3 }; const action = actionCreators.REMOVE(entity.id);// act const actual = reducer([1, 2, entity.id, 4], action); // assert const expected = [1, 2, 4]; expect(actual).toEqual(expected); }); test('should remove entities ids', () => { // arrange const entity1 = { id: 3 }; const entity2 = { id: 4 }; const action = actionCreators.REMOVE_LIST([entity1.id, entity2.id]);// act const actual = reducer([1, 2, entity1.id, entity2.id], action); // assert const expected = [1, 2]; expect(actual).toEqual(expected); }); test.each` exist ${true} ${false} `('should add entity id when change if options.ifNotExist options passed and not exist($exist)', ({ exist }) => { // arrange const action = actionCreators.CHANGE(1, {}, undefined, { ifNotExist: true }); const initialState = exist ? [1] : []; // act const actual = reducer(initialState, action); // assert const expected = [1]; expect(actual).toEqual(expected); }); }); ================================================ FILE: src/entity/reducers/ids.ts ================================================ import { EntityActionTypes, EntityInsertOptions } from '../types'; import { EntityActions } from '../actions'; import { Id } from '../../system-types'; import { isNil } from 'lodash-es'; import { removeFromArray } from '../../utils'; const getOnlyNewIds = ( state: Id[], ids: Id[], ): Id[] => { return ids.filter((id: Id) => !state.includes(id)); }; const addIds = ( state: Id[], ids: Id[], options?: EntityInsertOptions, ): Id[] => { const newIds = getOnlyNewIds(state, ids); if (options && !isNil(options.pasteIndex)) { const pre = state.slice(0, options.pasteIndex); const post = state.slice(options.pasteIndex); return pre.concat(newIds, post); } return state.concat(newIds); }; export const createIdsReducer = ( types: EntityActionTypes, ) => ( state: Id[] = [], action: EntityActions, ): Id[] => { switch (action.type) { case types.ADD: { const entity = action.payload.entity; return addIds(state, [entity.id], action.options); } case types.CHANGE: { const index = state.indexOf(action.payload.id); if (index === -1) { if (!action.options.ifNotExist) return state; return addIds(state, [action.payload.id]); } return state; } case types.ADD_LIST: { const entities = action.payload.entities; const ids = entities.map((e) => e.id); return addIds(state, ids, action.options); } case types.REMOVE: { const { payload: { id }, options: { safe, optimistic } } = action; if (optimistic || safe) return state; return removeFromArray(state, [id]); } case types.REMOVE_LIST: { const { payload: { ids }, options: { safe, optimistic } } = action; if (optimistic || safe) return state; return removeFromArray(state, ids); } case types.RESOLVE_REMOVE: { const { payload: { success, id }, options: { safe } } = action; if (!success || safe) return state; return removeFromArray(state, [id]); } case types.RESOLVE_ADD: { const { payload: { success, result, tempId } } = action; if (!success) return removeFromArray(state, [tempId]); return state.map((id) => { if (id === tempId) return result.id; return id; }); } case types.CLEAR_ALL: { return []; } default: return state; } }; ================================================ FILE: src/entity/reducers/instance.spec.ts ================================================ import { createTestHelpers } from '../../testing/helpers'; import { createInstanceReducer } from './instance'; import { EntityInstanceState, EntityType } from '../types'; import { Change } from '../../shared/change/types'; const { reducer, actionCreators, } = createTestHelpers(createInstanceReducer); describe('instance', () => { test('should return default state', () => { // act const actual = reducer(undefined, { type: 'INIT' } as any); // assert expect(actual).toEqual(undefined); }); describe('#ADD', () => { test.each` merge ${true} ${false} `('should add entity as stable change (without id) and merge=$merge if there are changes', ({ merge }) => { // arrange const entity: EntityType = { id: 1 }; const action = actionCreators.ADD(entity, undefined, undefined, { merge }); const existChange = { stable: false, patch: {}, merge: false }; const state: EntityInstanceState = { actual: entity, changes: [existChange], }; // act const actual = reducer(state, action); // assert const addChange: Change = { stable: true, patch: entity, merge }; const expected = { actual: entity, changes: [existChange, addChange], }; expect(actual).toEqual(expected); }); }); }); ================================================ FILE: src/entity/reducers/instance.ts ================================================ import { EntityActionTypes, EntityInstanceState, EntityType } from '../types'; import { EntityActions } from '../actions'; import { createInstanceByChanges, getMergedChanges } from '../../shared/change/utils'; import { createChangeReducer } from '../../shared/change/reducer'; import { createChange } from '../../shared/change/actions'; export const createInstanceReducer = (types: EntityActionTypes) => { const changeReducer = createChangeReducer(types); const createChangeAction = createChange>(types); return ( state: EntityInstanceState | undefined, action: EntityActions, ): EntityInstanceState | undefined => { switch (action.type) { case types.ADD: { const { options: { optimistic, merge }, payload: { entity, tempId }, } = action; const instance = createInstanceByChanges( state, entity as EntityType, merge, !optimistic, tempId, ); return getMergedChanges(instance, true); } case types.CHANGE: { const { options, payload: { id, patch, changeId }, } = action; const { ifNotExist } = options; if (!ifNotExist && !state) return state; const patchWithId = { id, ...patch }; const resultPatch = ifNotExist ? patchWithId : patch; const changeAction = createChangeAction(resultPatch, changeId, options); return changeReducer(state, changeAction) as EntityInstanceState; } case types.RESOLVE_CHANGE: { return changeReducer(state, action) as EntityInstanceState; } case types.REMOVE: case types.REMOVE_LIST: { if (!state) return state; const { options: { optimistic, safe } } = action; if (safe || optimistic) return { ...state, removed: true }; return undefined; } case types.RESOLVE_REMOVE: case types.RESOLVE_REMOVE_LIST: { if (!state) return state; const { payload: { success }, options: { safe }, } = action; if (safe || state.removed === false) return state; if (success) return undefined; return { ...state, removed: false }; } case types.RESTORE_REMOVED: case types.RESTORE_REMOVED_LIST: { if (!state) return state; return { ...state, removed: false }; } default: return state; } }; }; ================================================ FILE: src/entity/reducers/loading-state.ts ================================================ import { LoadingState } from '../../system-types'; import { createLoadingStateReducer } from '../../shared/loading-state/reducers'; import { EntityActionTypes } from '../types'; import { EntityActions } from '../actions'; import { RequestOptions } from '../../shared/types'; export const createEntityLoadingStateReducer = ( types: EntityActionTypes, ) => ( state: LoadingState | undefined, action: EntityActions, ) => { const loadingStateReducer = createLoadingStateReducer(types); if (action.type !== types.SET_LOADING_STATE) return state; if ((action.options as RequestOptions)?.globalLoadingState === false) return state; return loadingStateReducer(state, action); }; ================================================ FILE: src/entity/reducers/loading-states.ts ================================================ import { EntityActionTypes } from '../types'; import { Dictionary, Id, LoadingState } from '../../system-types'; import { EntityActions } from '../actions'; import { createLoadingStateReducer } from '../../shared/loading-state/reducers'; import { isNil } from 'lodash-es'; import { removeFromObject } from '../../utils'; const removeState = ( state: Dictionary, ids: Id[] = [], condition: boolean = true, ): Dictionary => { if (!condition) return state; return removeFromObject(state, ids); }; export const createByIdLoadingStatesReducer = ( types: EntityActionTypes, ) => { const entityLoadingStateReducer = createLoadingStateReducer(types); return ( state: Dictionary = {}, action: EntityActions, ): Dictionary => { switch (action.type) { case types.SET_LOADING_STATE: { const id = action.payload.id; if (isNil(id)) return state; const byId = entityLoadingStateReducer(state[id], action) as LoadingState; return { ...state, [id]: byId }; } case types.RESOLVE_ADD: { const { payload: { tempId } } = action; return removeState(state, [tempId]); } case types.REMOVE: { const { payload: { id }, options: { optimistic } } = action; return removeState(state, [id], !optimistic); } case types.RESOLVE_REMOVE: { const { payload: { success, id } } = action; return removeState(state, [id], !success); } case types.REMOVE_LIST: { const { payload: { ids }, options: { optimistic } } = action; return removeState(state, ids, !optimistic); } case types.RESOLVE_REMOVE_LIST: { const { payload: { success, ids } } = action; return removeState(state, ids, !success); } case types.CLEAR_ALL: { return {}; } default: return state; } }; }; ================================================ FILE: src/entity/reducers/pages.spec.ts ================================================ import { createPagesReducer } from './pages'; import { EntityActions } from '../actions'; import { createTestHelpers } from '../../testing/helpers'; jest.mock('../utils'); const { reducer, actionCreators, } = createTestHelpers(createPagesReducer); describe('pages', () => { test('should return default value', () => { // act const actual = reducer(undefined, { type: 'INIT' } as any); // assert expect(actual).toEqual({}); }); test('should remove id for all pages, otherwise default', () => { // arrange const action: EntityActions = actionCreators.REMOVE(1); const initialState = { 1: { ids: [1, 2] }, 2: { ids: [2, 1] }, }; // act const actual = reducer(initialState, action); // assert const expected = { 1: { ids: [2] }, 2: { ids: [2] }, }; expect(actual).toEqual(expected); }); test('should remove ids for all pages, otherwise default', () => { // arrange const action: EntityActions = actionCreators.REMOVE_LIST([1, 2]); const initialState = { 1: { ids: [1, 2, 3] }, 2: { ids: [2, 1, 5] }, }; // act const actual = reducer(initialState, action); // assert const expected = { 1: { ids: [3] }, 2: { ids: [5] }, }; expect(actual).toEqual(expected); }); }); ================================================ FILE: src/entity/reducers/pages.ts ================================================ import { EntityActionTypes, EntityType, Page } from '../types'; import { Dictionary } from '../../system-types'; import { EntityActions } from '../actions'; import { isNil, uniq } from 'lodash-es'; import { createLoadingStateReducer } from '../../shared/loading-state/reducers'; import { MAX_PAGES_COUNT } from '../constants'; import { removeFromArray } from '../../utils'; const addList = ( state: Page | undefined, data: EntityType[], ): Page => { const newIds = data.map(entity => entity.id); const oldIds = state?.ids ?? []; return { ...state, ids: uniq(oldIds.concat(newIds)), }; }; const createPageReducer = ( types: EntityActionTypes, ) => { const loadingStateReducer = createLoadingStateReducer(types); return ( state: Page | undefined, action: EntityActions, ): Page | undefined => { switch (action.type) { case types.ADD_LIST: { let newState = state ?? { ids: [] }; if (!isNil(action.payload.hasMore)) { newState = { ...newState, hasMore: action.payload.hasMore }; } if (!isNil(action.payload.metadata)) { newState = { ...newState, metadata: action.payload.metadata as TPageMetadata } } if (action.payload.isReplace) { newState = { ...newState, ids: undefined }; } return addList(newState, action.payload.entities); } case types.ADD: { return addList(state, [action.payload.entity]); } case types.REMOVE: { const { payload: { id }, options: { safe, optimistic } } = action; if (optimistic || safe) return state; // this check needs to clear immutable reference updating. // It means, no state mutating if this id doesn't exist here if (!state?.ids?.includes(id)) return state; return { ...state, ids: removeFromArray(state.ids, [id]), }; } case types.RESOLVE_REMOVE: { const { payload: { success, id }, options: { safe } } = action; if (!success || safe) return state; // this check needs to clear immutable reference updating. // It means, no state mutating if this id doesn't exist here if (!state?.ids?.includes(id)) return state; return { ...state, ids: removeFromArray(state.ids, [id]), }; } case types.REMOVE_LIST: { const { payload: { ids }, options: { safe, optimistic } } = action; if (optimistic || safe) return state; if (!state?.ids) return state; // this check needs to clear immutable reference updating. // It means, no state mutating if this id doesn't exist here const hasIds = ids.some((id) => state.ids?.includes(id)); if (!hasIds) return state; return { ...state, ids: removeFromArray(state.ids, ids), }; } case types.RESOLVE_REMOVE_LIST: { const { payload: { success, ids }, options: { safe } } = action; if (!success || safe) return state; if (!state?.ids) return state; // this check needs to clear immutable reference updating. // It means, no state mutating if this id doesn't exist here const hasIds = ids.some((id) => state.ids?.includes(id)); if (!hasIds) return state; return { ...state, ids: removeFromArray(state.ids, ids), }; } case types.RESOLVE_ADD: { const { payload: { success, tempId, result } } = action; if (!state) return state; if (success) return { ...state, ids: state.ids?.map((id) => { if (id === tempId) return result.id; return id; }), }; return { ...state, ids: removeFromArray(state?.ids ?? [], [tempId]), }; } case types.SET_LOADING_STATE: { return { ...state, ids: state?.ids, loadingState: loadingStateReducer(state?.loadingState, action), }; } default: return state; } }; }; export const createPagesReducer = ( types: EntityActionTypes, ) => { const pageReducer = createPageReducer(types); return ( state: Dictionary> = {}, action: EntityActions, ): Dictionary> => { switch (action.type) { case types.SET_LOADING_STATE: case types.ADD_LIST: case types.ADD: { const { payload: { configHash }, options: { maxPagesCount = MAX_PAGES_COUNT }, } = action; if (isNil(configHash)) return state; const pageExist = configHash in state; const page = pageReducer(state[configHash], action); if (!page) return state; const newState = { ...state, [configHash]: page }; if (pageExist) return newState; const pageHashes = Object.keys(newState); const pagesCount = pageHashes.length; page.order = pagesCount - 1; if (pagesCount <= maxPagesCount) return newState; return pageHashes.reduce((memo: Dictionary>, hash: string) => { const existPage = newState[hash]; if (existPage.order === 0) return memo; memo[hash] = { ...existPage, order: (existPage.order ?? pagesCount) - 1, }; return memo; }, {}); } case types.RESOLVE_ADD: case types.REMOVE: case types.RESOLVE_REMOVE: case types.REMOVE_LIST: case types.RESOLVE_REMOVE_LIST: { return Object.keys(state).reduce(( memo: Dictionary>, hash: string, ) => { const page = pageReducer(state[hash], action); if (!page) return memo; memo[hash] = page; return memo; }, {}); } case types.CLEAR: { const hash = action.payload.configHash; const { [hash]: deleted, ...rest } = state; return rest; } case types.CLEAR_ALL: { return {}; } default: return state; } }; }; ================================================ FILE: src/entity/selectors.ts ================================================ import { createSelector, Selector } from 'reselect'; import { ActualSelector, BaseEntitySelectors, DictionarySelector, EntityInstanceState, EntitySelectors, EntityState, EntityType, IdsSelector, ListSelector, LoadingStatesSelector, Page, PageSelector, PagesListSelector, PagesSelector, } from './types'; import { PainlessReduxState } from '../painless-redux/types'; import { HashFn, Id, LoadingState } from '../system-types'; import { createLoadingStateSelector } from '../shared/loading-state/selectors'; import { LoadingStateSelector } from '../shared/loading-state/types'; import { isNil, values } from 'lodash-es'; import { getChangeableActual } from '../shared/change/selectors'; export const createDictionarySelector = ( selector: Selector>, ): DictionarySelector => createSelector(selector, (s) => s.dictionary); export const createIdsSelector = ( selector: Selector>, ): IdsSelector => createSelector(selector, (s) => s.ids); export const createPagesSelector = ( selector: Selector>, ): PagesSelector => createSelector(selector, (s) => s.pages); export const createLoadingStatesSelector = ( selector: Selector>, ): LoadingStatesSelector => createSelector(selector, (s) => s.loadingStates); const createCreateLoadingStateById = ( selector: LoadingStatesSelector, ) => (id: Id) => createSelector( selector, (loadingStates) => loadingStates[id], ); const createCreateLoadingStateByIds = ( selector: LoadingStatesSelector, ) => (ids: Id[]) => createSelector( selector, (loadingStates) => ids.reduce((memo: LoadingState, id: Id) => { const loadingState = loadingStates[id]; memo.isLoading = memo.isLoading || (loadingState?.isLoading ?? false); memo.error = memo.error || loadingState?.error; return memo; }, { isLoading: false }), ); export const createBaseEntitySelectors = ( selector: Selector>, ): BaseEntitySelectors => { const ids = createIdsSelector(selector); const dictionary = createDictionarySelector(selector); const pages = createPagesSelector(selector); const loadingState = createLoadingStateSelector>(selector); const loadingStates = createLoadingStatesSelector(selector); const createLoadingStateById = createCreateLoadingStateById(loadingStates); const createLoadingStateByIds = createCreateLoadingStateByIds(loadingStates); return { ids, dictionary, pages, loadingState, loadingStates, createLoadingStateById, createLoadingStateByIds, }; }; const getActual = ( instance: EntityInstanceState | undefined, ): EntityType | undefined => { if (!instance || instance.removed) return undefined; return getChangeableActual(instance); }; export const createCreateActualSelector = ( dictionarySelector: DictionarySelector, ) => ( id: Id, ): ActualSelector => createSelector( dictionarySelector, (dictionary) => getActual(dictionary[id]), ); export const createListSelectorFromPages = ( pagesSelector: PagesSelector, ): PagesListSelector => createSelector( pagesSelector, (pages) => values(pages), ); export const createListSelector = ( dictionarySelector: DictionarySelector, ) => ( idsSelector: IdsSelector, ): ListSelector => createSelector( idsSelector, dictionarySelector, (ids, dict) => { if (!ids) return undefined; return ids.map(id => getActual(dict[id])) .filter((actual) => !isNil(actual)) as T[]; }, ); export const createPageSelector = ( pagesSelector: PagesSelector, hash: string, ): PageSelector => createSelector(pagesSelector, (pages) => pages[hash]); export const createCreatePageIdsSelector = (pagesSelector: PagesSelector) => ( hash: string, ): IdsSelector => { const pageSelector = createPageSelector(pagesSelector, hash); return createSelector( pageSelector, (page: Page | undefined) => { if (!page) return undefined; return page.ids; }, ); }; export const createCreatePageIdsByConfigSelector = ( pagesSelector: PagesSelector, hashFn: HashFn, ) => ( config: unknown, ): IdsSelector => { const hash = hashFn(config); return createCreatePageIdsSelector(pagesSelector)(hash); }; export const createCreatePageByConfigSelector = ( pagesSelector: PagesSelector, hashFn: HashFn, ) => ( config: unknown, ): PageSelector => { const hash = hashFn(config); return createPageSelector(pagesSelector, hash); }; export const createCreatePageLoadingState = ( pagesSelector: PagesSelector, hashFn: HashFn, ) => ( config: unknown, ): LoadingStateSelector> => { const hash = hashFn(config); const pageSelector = createPageSelector(pagesSelector, hash); return createSelector( pageSelector, (page) => page?.loadingState, ); }; // TODO(egorgrushin): refactor here export const createEntitySelectors = ( selector: Selector>, hashFn: HashFn, ): EntitySelectors => { const { dictionary, loadingStates, loadingState, pages, ids, createLoadingStateById, createLoadingStateByIds, } = createBaseEntitySelectors(selector); const createListSelectorByIds = createListSelector(dictionary); const all = createListSelectorByIds(ids); const allPages = createListSelectorFromPages(pages); const createActual = createCreateActualSelector(dictionary); const createPageIds = createCreatePageIdsSelector(pages); const createPageIdsByConfig = createCreatePageIdsByConfigSelector(pages, hashFn); const createPage = createCreatePageByConfigSelector(pages, hashFn); const createPageLoadingState = createCreatePageLoadingState(pages, hashFn); const createPageListByConfig = (config: unknown) => { const pageIdsSelector = createPageIdsByConfig(config); return createListSelectorByIds(pageIdsSelector); }; return { dictionary, ids, pages, loadingStates, loadingState, all, createActual, createPageIds, createPageIdsByConfig, createListSelectorByIds, createPageListByConfig, createPage, createPageLoadingState, createLoadingStateById, createLoadingStateByIds, allPages, }; }; ================================================ FILE: src/entity/types.ts ================================================ import {Observable} from 'rxjs'; import {Selector} from 'reselect'; import {DeepPartial, Dictionary, HashFn, Id, LoadingState} from '../system-types'; import {ChangeableState, PatchRequest} from '../shared/change/types'; import { LoadingStateActionTypes, LoadingStateSelector, LoadingStateSelectors, LoadingStateSetOptions, LoadingStateState, } from '../shared/loading-state/types'; import {SelectEntityMethods} from './methods/select/types'; import {DispatchEntityMethods} from './methods/dispatch/types'; import {MixedEntityMethods} from './methods/mixed/types'; import {SystemActionTypes} from '../shared/system/types'; import {EntityActionCreators} from './action-creators.types'; import {RequestOptions} from '../shared/types'; export type EntityType = T & { id: Id }; export interface EntitySchema { name: string; hashFn: HashFn; pageSize: number; maxPagesCount: number; id?(data: T): Id; } export interface EntityLoadOptions extends EntityInsertOptions, RequestOptions { } export interface EntityGetOptions extends EntityAddOptions { } export interface EntityGetListOptions extends EntityLoadListOptions { } export interface EntityLoadListOptions extends EntityAddListOptions, RequestOptions { pageSize?: number; } export interface EntityAddOptions extends EntityOptimisticOptions, EntityInsertOptions, RequestOptions { } interface EntityInternalOptions { maxPagesCount?: number; } export interface EntityInternalAddOptions extends EntityAddOptions, EntityInternalOptions { } export interface EntityInternalAddListOptions extends EntityAddListOptions, EntityInternalOptions { } export interface EntityOptimisticOptions { optimistic?: boolean; } export interface EntityRemoveOptions extends EntityOptimisticOptions, RequestOptions { safe?: boolean; } export interface EntityRemoveListOptions extends EntityOptimisticOptions, RequestOptions { safe?: boolean; } export interface EntitySetLoadingStateOptions extends LoadingStateSetOptions { } export interface EntityInternalSetLoadingStateOptions extends EntitySetLoadingStateOptions, EntityInternalOptions { } export interface EntityInsertOptions { pasteIndex?: number; merge?: boolean; single?: boolean; } export interface EntityAddListOptions extends EntityInsertOptions { } export interface Pagination { index: number; size: number; from: number; to: number; } export interface ResponseArray { data: T[]; hasMore?: boolean; metadata?: TPageMetadata; } export interface PaginatedResponse extends Pagination { response: ResponseArray; } export type Response$Factory = (pagination: Pagination) => Observable>; export interface Page { ids: Id[] | undefined; order?: number; hasMore?: boolean; loadingState?: LoadingState; metadata?: TPageMetadata; } export interface IdPatchRequest { id: Id; patch: PatchRequest; } export interface IdPatch { id: Id; patch: DeepPartial; } export interface EntityInstanceState extends ChangeableState> { removed?: boolean; } export interface EntityState extends LoadingStateState { ids: Id[]; dictionary: Dictionary>; pages: Dictionary>; loadingStates: Dictionary; } export type IdsSelector = Selector, Id[] | undefined>; export type DictionarySelector = Selector, Dictionary>>; export type PagesSelector = Selector, Dictionary>>; export type PagesListSelector = Selector, Page[]>; export type PageSelector = Selector, Page | undefined>; export type LoadingStatesSelector = Selector, Dictionary>; export type ActualSelector = Selector, T | undefined>; export type ListSelector = Selector, T[] | undefined>; export interface BaseEntitySelectors extends LoadingStateSelectors> { ids: IdsSelector; dictionary: DictionarySelector; pages: PagesSelector; loadingStates: LoadingStatesSelector; createLoadingStateById: (id: Id) => LoadingStateSelector>; createLoadingStateByIds: (ids: Id[]) => LoadingStateSelector>; } export interface EntitySelectors extends BaseEntitySelectors { createActual: (id: Id) => ActualSelector; createPage: (config: unknown) => PageSelector; createPageIds: (hash: string) => IdsSelector; createPageLoadingState: (config: unknown) => LoadingStateSelector>; createPageIdsByConfig: (config: unknown) => IdsSelector; createListSelectorByIds: (idsSelector: IdsSelector) => ListSelector; createPageListByConfig: (config: unknown) => ListSelector; allPages: PagesListSelector; all: ListSelector; } export interface EntityActionTypes extends SystemActionTypes { ADD: 'ADD'; RESOLVE_ADD: 'RESOLVE_ADD'; ADD_LIST: 'ADD_LIST'; SET_LOADING_STATE: LoadingStateActionTypes['SET_LOADING_STATE']; CHANGE: 'CHANGE'; RESOLVE_CHANGE: 'RESOLVE_CHANGE'; REMOVE: 'REMOVE'; RESOLVE_REMOVE: 'RESOLVE_REMOVE'; RESTORE_REMOVED: 'RESTORE_REMOVED'; REMOVE_LIST: 'REMOVE_LIST'; RESOLVE_REMOVE_LIST: 'RESOLVE_REMOVE_LIST'; RESTORE_REMOVED_LIST: 'RESTORE_REMOVED_LIST'; CLEAR: 'CLEAR'; CLEAR_ALL: 'CLEAR_ALL'; CHANGE_LIST: 'CHANGE_LIST'; RESOLVE_CHANGE_LIST: 'RESOLVE_CHANGE_LIST'; SET_LOADING_STATES: 'SET_LOADING_STATES'; } export type PublicDispatchEntityMethods = Omit, 'changeWithId' | 'changeListWithId' | 'resolveChange' | 'resolveAdd' | 'resolveRemove' | 'resolveChangeList'> export type PublicSelectEntityMethods = Omit, 'get$' | 'getDictionary$' | 'getById$'> export interface Entity extends PublicSelectEntityMethods, PublicDispatchEntityMethods, MixedEntityMethods { actionCreators: EntityActionCreators; } ================================================ FILE: src/entity/utils.ts ================================================ import { createActionTypes, hashIt, typedDefaultsDeep } from '../utils'; import { EntityActionTypes, EntitySchema, EntityType, PaginatedResponse, Pagination, Response$Factory, ResponseArray, } from './types'; import { DEFAULT_PAGE_SIZE, ENTITY_TYPE_NAMES, MAX_PAGES_COUNT } from './constants'; import { v4 } from 'uuid'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { getObservable$ } from '../shared/utils'; export const getHash = (config: unknown): string => hashIt(config); export const getFullEntitySchema = ( schema?: Partial>, ): EntitySchema => typedDefaultsDeep(schema, { name: '', hashFn: getHash, pageSize: DEFAULT_PAGE_SIZE, maxPagesCount: MAX_PAGES_COUNT, }) as EntitySchema; export const createIdResolver = ( schema: EntitySchema, ) => (data: T): EntityType => { if (schema.id) return { ...data, id: schema.id(data) }; if ('id' in data) return data as EntityType; return { ...data, id: v4() }; }; export const createEntityActionTypes = ( entityName: string, ): EntityActionTypes => createActionTypes(ENTITY_TYPE_NAMES, entityName); export const getPaginated$ = ( dataSource: Observable> | Response$Factory, pagination: Pagination, ): Observable> => getObservable$(dataSource, pagination).pipe( map((response) => ({ ...pagination, response })), ); ================================================ FILE: src/index.ts ================================================ export * from './painless-redux/painless-redux'; export * from './painless-redux/types'; export * from './shared/types'; export * from './shared/change/types'; export * from './shared/change/actions'; export * from './shared/loading-state/types'; export * from './shared/system/types'; export * from './workspace/workspace'; export * from './workspace/types'; export { WorkspaceActions } from './workspace/actions'; export * from './workspace/action-creators'; export * from './entity/entity'; export * from './entity/types'; export * from './entity/action-creators'; export * from './entity/action-creators.types'; export { EntityActions } from './entity/actions'; export * from './affect-loading-state/affect-loading-state'; export * from './affect-loading-state/types'; export * from './system-types'; export { hashString, hashIt, snapshot, merge, } from './utils'; export { actionSanitizer, } from './workspace/utils'; ================================================ FILE: src/painless-redux/action-creators.ts ================================================ ================================================ FILE: src/painless-redux/constants.ts ================================================ import { SystemActionTypes } from '../shared/system/types'; export const SYSTEM_TYPE_NAMES: Array = []; ================================================ FILE: src/painless-redux/painless-redux.ts ================================================ import { PainlessRedux, PainlessReduxSchema, SlotTypes } from './types'; import { ActionCreator, AnyAction, Reducer, RxStore, SameShaped } from '../system-types'; import { createDispatcher } from '../dispatcher/dispatcher'; import { typedDefaultsDeep } from '../utils'; import { createSlotSelector, createSlotsSelector } from './selectors'; import { getWrappedReducer } from './reducers'; import { createSelectManager } from '../select-manager/select-manager'; import { createRegister } from './register'; import { createSelector } from 'reselect'; import { createSystemActionTypes } from './utils'; export const createPainlessRedux = ( rxStore: RxStore, schema?: Partial, ): PainlessRedux => { const fullSchema = typedDefaultsDeep(schema, { name: '@store', entityDomainName: 'entities', workspaceDomainName: 'workspaces', useAsapSchedulerInLoadingGuards: true, selector: (state: any) => state, }) as PainlessReduxSchema; const domainName = fullSchema.name; const initialValue = {}; const selector = createSelector(fullSchema.selector, (state: any) => state[domainName]); const slotsSelector = createSlotsSelector(fullSchema, selector); const systemActionTypes = createSystemActionTypes(domainName); const { value: register, checkSlotUniq, addNewSlotToRegister } = createRegister(); const getReducer = () => getWrappedReducer(fullSchema, register, systemActionTypes, initialValue); const registerSlotReducer = ( type: SlotTypes, name: string, reducer: Reducer, ) => { checkSlotUniq(type, name); addNewSlotToRegister(type, name, reducer); const fullReducer = getReducer(); rxStore.addReducer(domainName, fullReducer); }; const registerSlot = ( type: SlotTypes, name: string, reducer: Reducer, actionCreators: SameShaped>, ) => { const dispatcher = createDispatcher(rxStore, actionCreators); const selectManager = createSelectManager(rxStore); const currentSlotsSelector = slotsSelector(type); const selector = createSlotSelector(currentSlotsSelector, name); registerSlotReducer(type, name, reducer); return { dispatcher, selector, selectManager, }; }; return { registerSlot, getReducer, name: domainName, schema: fullSchema, }; }; ================================================ FILE: src/painless-redux/reducers.ts ================================================ import { PainlessReduxRegister, PainlessReduxSchema, PainlessReduxState, SlotTypes } from './types'; import { AnyAction, PayloadAction, Reducer } from '../system-types'; import { combineReducers } from '../shared/utils'; import { SystemActionTypes } from '../shared/system/types'; export const getSlotReducer = ( register: PainlessReduxRegister, type: SlotTypes, ): Reducer => { const slots = register[type]; return combineReducers(slots); }; export const createFullReducer = ( schema: PainlessReduxSchema, register: PainlessReduxRegister, ): Reducer => combineReducers({ [schema.entityDomainName]: getSlotReducer(register, SlotTypes.Entity), [schema.workspaceDomainName]: getSlotReducer(register, SlotTypes.Workspace), }); export const getWrappedReducer = ( schema: PainlessReduxSchema, register: PainlessReduxRegister, types: SystemActionTypes, initialState: PainlessReduxState, ): Reducer => { return createFullReducer(schema, register); // const reducerMiddlewareFactory = undoReducerFactory(types, initialState); // return reducerMiddlewareFactory(reducer); }; ================================================ FILE: src/painless-redux/register.ts ================================================ import { PainlessReduxRegister, SlotTypes } from './types'; import { isNil } from 'lodash-es'; import { AnyAction, Reducer } from '../system-types'; export const createRegister = () => { const value: PainlessReduxRegister = { [SlotTypes.Entity]: {}, [SlotTypes.Workspace]: {}, }; const checkSlotUniq = ( type: SlotTypes, name: string, ) => { const existSlotReducer = value[type][name]; if (isNil(existSlotReducer)) return; throw new Error('Slot name is not uniq'); }; const addNewSlotToRegister = ( type: SlotTypes, name: string, reducer: Reducer, ) => { value[type] = { ...value[type], [name]: reducer as Reducer, }; return value; }; return { addNewSlotToRegister, checkSlotUniq, value }; }; ================================================ FILE: src/painless-redux/selectors.ts ================================================ import { PainlessReduxSchema, PainlessReduxState, SlotTypes } from './types'; import { createSelector, Selector } from 'reselect'; import { Dictionary, StrictDictionary } from '../system-types'; export const createEntitiesSelector = ( schema: PainlessReduxSchema, selector: Selector, ) => createSelector( selector, (state: PainlessReduxState) => state[schema.entityDomainName], ); export const createWorkspacesSelector = ( schema: PainlessReduxSchema, selector: Selector, ) => createSelector( selector, (state: PainlessReduxState) => state[schema.workspaceDomainName], ); const SLOTS_SELECTOR_FACTORIES: StrictDictionary = { [SlotTypes.Entity]: createEntitiesSelector, [SlotTypes.Workspace]: createWorkspacesSelector, }; export const createSlotsSelector = ( schema: PainlessReduxSchema, selector: Selector, ) => ( type: SlotTypes, ): Selector> => { const slotSelectorFactory = SLOTS_SELECTOR_FACTORIES[type]; return slotSelectorFactory(schema, selector); }; export const createSlotSelector = ( slotsSelector: Selector>, name: string, ): Selector => createSelector( slotsSelector, (state) => state[name], ); ================================================ FILE: src/painless-redux/types.ts ================================================ import { Selector } from 'reselect'; import { ActionCreator, AnyAction, Dictionary, PayloadAction, Reducer, SameShaped, StrictDictionary, } from '../system-types'; import { Dispatcher } from '../dispatcher/types'; import { SelectManager } from '../select-manager/types'; export enum SlotTypes { Entity, Workspace } export interface PainlessReduxSchema { name: string; entityDomainName: string; workspaceDomainName: string; selector: Selector; useAsapSchedulerInLoadingGuards: boolean; } export type PainlessReduxRegister = StrictDictionary>, SlotTypes>; export type PainlessReduxState = Dictionary; export type PainlessRedux = { name: string; schema: PainlessReduxSchema; registerSlot( type: SlotTypes, name: string, reducer: Reducer, actionCreators: SameShaped>, ): { selector: Selector; dispatcher: Dispatcher; selectManager: SelectManager; }; getReducer(): Reducer; } ================================================ FILE: src/painless-redux/utils.ts ================================================ import { SystemActionTypes } from '../shared/system/types'; import { createActionTypes } from '../utils'; import { SYSTEM_TYPE_NAMES } from './constants'; export const createSystemActionTypes = ( name: string, ): SystemActionTypes => createActionTypes(SYSTEM_TYPE_NAMES, name); ================================================ FILE: src/select-manager/select-manager.ts ================================================ import { RxStore } from '../system-types'; import { asapScheduler, Observable } from 'rxjs'; import { subscribeOn } from 'rxjs/operators'; import { Selector } from 'reselect'; import { select, snapshot } from '../utils'; import { SelectManager } from './types'; export const createSelectManager = (rxStore: RxStore): SelectManager => { const select$ = ( selector: Selector, isAsap: boolean = false, ): Observable => { const selectObs = rxStore.pipe(select(selector)); if (!isAsap) return selectObs; return selectObs.pipe(subscribeOn(asapScheduler)); }; const snapshotFn = ( selector: Selector, ): R | undefined => { const source$ = select$(selector); return snapshot(source$); }; return { select$, snapshot: snapshotFn, }; }; ================================================ FILE: src/select-manager/types.ts ================================================ import { Selector } from 'reselect'; import { Observable } from 'rxjs'; export interface SelectManager { select$(selector: Selector, isAsap?: boolean): Observable; snapshot(selector: Selector): R | undefined; } ================================================ FILE: src/shared/change/actions.ts ================================================ import { ChangeActionTypes, ChangeOptions } from './types'; import { typedDefaultsDeep } from '../../utils'; import { DeepPartial } from '../../system-types'; export const createChange = (types: ChangeActionTypes) => ( patch: DeepPartial, changeId?: string, options?: ChangeOptions, ) => { options = typedDefaultsDeep(options, { merge: true }); const payload = { patch, changeId }; return { type: types.CHANGE, payload, options } as const; }; export const createResolveChange = (types: ChangeActionTypes) => ( changeId: string, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ) => { options = typedDefaultsDeep(options, { merge: true }); const payload = { changeId, success, remotePatch }; return { type: types.RESOLVE_CHANGE, payload, options } as const; }; type SelfActionCreators = ReturnType | ReturnType; export type ChangeActions = ReturnType ================================================ FILE: src/shared/change/reducer.spec.ts ================================================ import { ChangeActionTypes } from './types'; import { createChangeReducer } from './reducer'; import { createChange } from './actions'; const types: ChangeActionTypes = { CHANGE: 'CHANGE', RESOLVE_CHANGE: 'RESOLVE_CHANGE', }; describe('change', () => { const initialActual = { values: [0, 1, 2], key: 'test' }; const initialValue = { actual: initialActual }; const reducer = createChangeReducer(types, initialActual); test('should init value', () => { // act const actual = reducer(undefined, { type: 'INIT' } as any); // assert expect(actual?.actual).toBe(initialActual); }); test('should override arrays while changing even if new array is smaller', () => { // arrange const values = [3, 4]; const action = createChange(types)({ values }); // act const actual = reducer(initialValue, action); // assert expect(actual?.actual.values).toEqual(values); }); test('should override arrays while changing even if new array is bigger', () => { // arrange const values = [3, 4, 5, 6]; const action = createChange(types)({ values }); // act const actual = reducer(initialValue, action); // assert expect(actual?.actual.values).toEqual(values); }); test('should set only given properties to undefined', () => { // arrange const action = createChange(types)({ values: undefined }); // act const actual = reducer(initialValue, action); // assert expect(actual?.actual).toEqual({ ...initialActual, values: undefined }); }); }); ================================================ FILE: src/shared/change/reducer.ts ================================================ import { ChangeableState, ChangeActionTypes } from './types'; import { ChangeActions } from './actions'; import { createInstanceByChanges, getMergedChanges, resolveChanges } from './utils'; export const createChangeReducer = ( types: ChangeActionTypes, initialActual?: T, ) => ( state: ChangeableState | undefined, action: ChangeActions, ): ChangeableState | undefined => { if (!state && initialActual) { state = { actual: initialActual }; } switch (action.type) { case types.CHANGE: { const { payload: { patch, changeId }, options: { merge, optimistic }, } = action; const instance = createInstanceByChanges( state, patch, merge, !optimistic, changeId, ); return getMergedChanges(instance, true); } case types.RESOLVE_CHANGE: { if (!state) return state; const { payload: { success, changeId, remotePatch }, options: { merge, optimistic }, } = action; const instance = createInstanceByChanges( state, remotePatch, merge, !optimistic, changeId, ) as ChangeableState; const changes = resolveChanges(instance?.changes, success, changeId); const newState = { ...instance, changes }; return getMergedChanges(newState, true); } default: return state; } }; ================================================ FILE: src/shared/change/selectors.ts ================================================ import { getMergedChanges } from './utils'; import { ChangeableState } from './types'; export const getChangeableActual = ( instance: ChangeableState | undefined, ): T | undefined => { if (!instance) return undefined; return getMergedChanges(instance)?.actual; }; ================================================ FILE: src/shared/change/types.ts ================================================ import { DeepPartial } from '../../system-types'; import { RequestOptions } from '../types'; export interface ChangeOptions extends RequestOptions { merge?: boolean; ifNotExist?: boolean; optimistic?: boolean; useResponsePatch?: boolean; } export interface ChangeActionTypes { CHANGE: 'CHANGE'; RESOLVE_CHANGE: 'RESOLVE_CHANGE'; } export interface Change { stable: boolean; patch: DeepPartial; merge: boolean; id?: string; } export interface ChangeableState { actual: T; changes?: Change[]; } export type PatchRequest = DeepPartial | ((value: DeepPartial | undefined) => DeepPartial); ================================================ FILE: src/shared/change/utils.ts ================================================ import { DeepPartial } from '../../system-types'; import { Change, ChangeableState, ChangeOptions, PatchRequest } from './types'; import { merge as mergeFn, snapshot } from '../../utils'; import { Observable } from 'rxjs'; export const createEntityChange = ( patch: DeepPartial, stable = false, merge = true, id?: string, ): Change => ({ patch, stable, merge, id }); export const getMergedChanges = ( state: ChangeableState | undefined, onlyStable?: boolean, ): ChangeableState | undefined => { if (!state) return state; let { actual, changes = [] } = state; if (changes.length === 0) return state; let change: Change | undefined; while ((change = changes[0])) { if (onlyStable && !change.stable) break; changes = changes.slice(1); const { merge, patch } = change; actual = merge ? mergeFn(actual, patch) : patch as T; } if (changes.length === 0) return { actual }; return { actual, changes: changes }; }; export const createInstanceByChanges = ( state: ChangeableState | undefined, patch: DeepPartial | undefined, merge: boolean = true, success: boolean = true, id?: string, ): ChangeableState | undefined => { if (!patch) return state; const actual = state?.actual ?? patch as T; const change = createEntityChange(patch, success, merge, id); const changes = state?.changes ?? []; return { actual, changes: changes.concat(change) }; }; export const resolveChanges = ( changes: Change[] | undefined, success: boolean, id: string, ): Change[] | undefined => { if (!changes) return; if (success) { return changes.map((change) => { if (change.id === id) return { ...change, stable: true }; return change; }); } return changes.filter((change) => change.id !== id); }; export const getPatchByOptions = ( patch: DeepPartial, response: DeepPartial | undefined, options?: ChangeOptions, ): DeepPartial => { if (options?.optimistic) return patch; if (options?.useResponsePatch) return response ?? {}; return patch; }; export const getResolvePatchByOptions = ( patch: DeepPartial, response: DeepPartial | undefined, options?: ChangeOptions, ): DeepPartial | undefined => { if (options?.useResponsePatch) return response; }; export const normalizePatch = ( patch: PatchRequest, oldValue$: Observable | undefined>, ): DeepPartial => { if (typeof patch === 'function') { const oldValue = snapshot(oldValue$); return patch(oldValue as DeepPartial); } return patch; }; ================================================ FILE: src/shared/loading-state/actions.ts ================================================ import { LoadingStateActionTypes, LoadingStateSetOptions } from './types'; import { LoadingState } from '../../system-types'; import { typedDefaultsDeep } from '../../utils'; export const createSetLoadingState = (types: LoadingStateActionTypes) => ( state: LoadingState, key?: string, options?: LoadingStateSetOptions, ) => { options = typedDefaultsDeep(options); const payload = { state, key }; return { type: types.SET_LOADING_STATE, payload, options } as const; }; export type LoadingStateActions = ReturnType>; ================================================ FILE: src/shared/loading-state/reducers.spec.ts ================================================ import { createLoadingStateReducer, loadingStateReducer } from './reducers'; import { LoadingState } from '../../system-types'; import { LoadingStateActionTypes } from './types'; import { LoadingStateActions } from './actions'; const types: LoadingStateActionTypes = { SET_LOADING_STATE: 'SET_LOADING_STATE', }; describe('loadingState', () => { let initialState: any; const newState = { isLoading: true }; const someKey = 'some-key'; beforeEach(() => { initialState = {}; }); describe('#createLoadingStateReducer', () => { const reducer = createLoadingStateReducer(types); test('should return default state', () => { // act const actual = reducer(undefined, { type: 'INIT' } as any); // assert expect(actual).toEqual(undefined); }); test('should correct set state', () => { // arrange const action: LoadingStateActions = { type: types.SET_LOADING_STATE, payload: { state: newState, key: undefined, }, options: {}, }; // act const actual = reducer(undefined, action); // assert const expected = newState; expect(actual).toEqual(expected); }); }); describe('#loadingStateReducer', () => { test('should set state for a key', () => { // act const actual = loadingStateReducer(initialState, someKey, newState); // assert const expected: LoadingState = { byKeys: { [someKey]: newState }, isLoading: false, }; expect(actual).toEqual(expected); }); test('should set state as is', () => { // act const actual = loadingStateReducer(initialState, undefined, newState); // assert expect(actual).toEqual(newState); }); }); }); ================================================ FILE: src/shared/loading-state/reducers.ts ================================================ import { Dictionary, LoadingState } from '../../system-types'; import { LoadingStateActionTypes } from './types'; import { LoadingStateActions } from './actions'; const pureLoadingStateReducer = ( state: LoadingState | undefined, newState: LoadingState, ): LoadingState => ({ ...state, ...newState }); export const loadingStateByKeysReducer = ( state: Dictionary = {}, key: string, newState: LoadingState, ): Dictionary => { return { ...state, [key]: pureLoadingStateReducer(state[key], newState), }; }; export const loadingStateReducer = ( state: LoadingState | undefined, key: string | undefined, newState: LoadingState, ): LoadingState => { if (!key) return pureLoadingStateReducer(state, newState); return { ...state, byKeys: loadingStateByKeysReducer(state?.byKeys, key, newState), isLoading: state?.isLoading ?? false, }; }; export const createLoadingStateReducer = ( types: LoadingStateActionTypes, ) => ( state: LoadingState | undefined, action: LoadingStateActions, ) => { switch (action.type) { case types.SET_LOADING_STATE: { const { key, state: newState } = action.payload; return loadingStateReducer(state, key, newState); } default: return state; } }; ================================================ FILE: src/shared/loading-state/selectors.ts ================================================ import { createSelector, Selector } from 'reselect'; import { LoadingStateState } from './types'; export const createLoadingStateSelector = ( selector: Selector, ) => createSelector( selector, (state: T) => state.loadingState ?? { isLoading: false }, ); ================================================ FILE: src/shared/loading-state/types.ts ================================================ import { LoadingState } from '../../system-types'; import { Selector } from 'reselect'; export interface LoadingStateState { loadingState?: LoadingState; } export interface LoadingStateSetOptions { } export interface LoadingStateActionTypes { SET_LOADING_STATE: 'SET_LOADING_STATE'; } export type LoadingStateSelector = Selector export interface LoadingStateSelectors { loadingState: LoadingStateSelector; } ================================================ FILE: src/shared/system/actions.ts ================================================ import { SystemActionTypes } from './types'; export const createBatch = (types: SystemActionTypes) => (actions: T[]) => { const payload = { actions }; return { type: types.BATCH, payload } as const; }; export type SystemActions = ReturnType>; ================================================ FILE: src/shared/system/reducers.ts ================================================ import { AnyAction, Reducer } from '../../system-types'; import { SystemActionTypes } from './types'; export const batchActionsReducerFactory = ( types: SystemActionTypes, reducer: Reducer, ) => (state: TState, action: TAction): TState => { if (action.type === types.BATCH) { const { actions } = (action as any).payload; return actions.reduce(reducer, state); } return reducer(state, action); }; ================================================ FILE: src/shared/system/system.ts ================================================ ================================================ FILE: src/shared/system/types.ts ================================================ export interface SystemActionTypes { BATCH: 'BATCH'; } ================================================ FILE: src/shared/types.ts ================================================ import { AnyAction, LoadingState } from '../system-types'; import { Observable } from 'rxjs'; export type ObservableOrFactory = (Observable) | ((value: S) => Observable); export interface RemotePipeConfig { store$?: Observable; remoteObsOrFactory: ObservableOrFactory; options?: RemoteOptions; success: (result?: TResponse) => AnyAction | undefined; emitSuccessOutsideAffectState?: boolean; emitOnSuccess?: boolean; optimistic?: boolean; optimisticResolve?: ( success: boolean, result?: TResponse, ) => AnyAction | undefined; setLoadingState?: (loadingState: LoadingState) => void; } export interface OptimisticOptions { optimistic?: boolean; } export interface RequestOptions { rethrow?: boolean; globalLoadingState?: boolean; } export interface RemoteOptions extends OptimisticOptions, RequestOptions { single?: boolean; } ================================================ FILE: src/shared/utils.ts ================================================ import { EMPTY, MonoTypeOperatorFunction, Observable, of, OperatorFunction } from 'rxjs'; import { catchError, filter, first, map, switchMap, tap } from 'rxjs/operators'; import { ObservableOrFactory, RemoteOptions, RemotePipeConfig } from './types'; import { isFunction, isNil } from 'lodash-es'; import { AnyAction, CombinedReducers, LoadingState, Reducer } from '../system-types'; import { affectLoadingStateFactory } from '../affect-loading-state/affect-loading-state'; export const getObservable$ = ( observableOrFactory: ObservableOrFactory, value: S, ): Observable => isFunction(observableOrFactory) ? observableOrFactory(value) : observableOrFactory; export const guardByOptions = ( options?: RemoteOptions, ): MonoTypeOperatorFunction => ( source: Observable, ): Observable => source.pipe( filter((storeValue) => !options?.single || isNil(storeValue)), ); export const getRemotePipe = ( { store$, remoteObsOrFactory, options, success, emitSuccessOutsideAffectState, emitOnSuccess, optimistic, optimisticResolve, setLoadingState, }: RemotePipeConfig, ): OperatorFunction => { const trailPipe: OperatorFunction = emitOnSuccess ? map((result: TResponse) => result as unknown as TOutput) : switchMap(() => EMPTY); return (source: Observable): Observable => source.pipe( switchMap((value: TSource) => { const remote$ = getObservable$(remoteObsOrFactory, value); if (optimistic) { success(); return remote$.pipe( tap((response) => optimisticResolve?.(true, response)), catchError((error: any) => { optimisticResolve?.(false); setLoadingState?.({ error, isLoading: false }); return EMPTY; }), ); } const successPipe = tap((result: TResponse) => success(result)); const pipesToAffect: OperatorFunction[] = [ switchMap(() => remote$), ]; const resultPipes: OperatorFunction[] = []; if (emitSuccessOutsideAffectState) { resultPipes.push(successPipe); } else { pipesToAffect.push(successPipe); } if (setLoadingState) { const rethrow = options?.rethrow ?? false; const affectStateObsFactory = affectLoadingStateFactory(setLoadingState, rethrow); const affectPipe = affectStateObsFactory(...pipesToAffect); resultPipes.unshift(affectPipe); } else { resultPipes.unshift(...pipesToAffect); } if (store$) { return (store$ as any).pipe( first(), guardByOptions(options), ...resultPipes, ) as Observable; } return (of(value) as any).pipe(...resultPipes) as Observable; }), trailPipe, ); }; export const guardIfLoading = ( loadingStateObs: Observable, ): Observable => loadingStateObs.pipe( first(), filter((loadingState: LoadingState | undefined) => !loadingState?.isLoading), ); export const combineReducers = ( reducers: CombinedReducers, ): Reducer => { const reducerKeys = Object.keys(reducers) as (keyof TState)[]; return (state: TState, action: any) => { state = state ?? {}; let hasChanged = false; const nextState: Partial = {}; reducerKeys.forEach((reducerKey) => { const subReducer = reducers[reducerKey]; const subState = state[reducerKey]; nextState[reducerKey] = subReducer(subState, action); hasChanged = hasChanged || nextState[reducerKey] !== subState; }) return hasChanged ? nextState as TState : state; }; } ================================================ FILE: src/system-types.ts ================================================ import { Observable } from 'rxjs'; export type Dictionary = Record; export type Id = string | number; export interface LoadingState { byKeys?: Dictionary; isLoading: boolean; error?: E; } export interface RxStore extends Observable { dispatch(action: { type: any }): void; addReducer( key: string, reducer: any, ): void; } export interface AnyAction { type: string; } export interface PayloadAction { type: string; payload: T; } export type Reducer = ( state: S, action: A, ) => S; export type CombinedReducers = { [K in keyof TState]: Reducer; } export type ActionCreator = (...args: any) => TActions; export type HashFn = (ob: any) => string; export type SameShaped = { [K in keyof T]: V } type Key = string | number | symbol; export type StrictDictionary = Record & Record; export type DeepPartial = { [P in keyof T]?: T[P] extends Array ? Array> : T[P] extends ReadonlyArray ? ReadonlyArray> : DeepPartial }; ================================================ FILE: src/testing/helpers.ts ================================================ import { cold } from 'jest-marbles'; import { TestStore } from './store'; import { combineReducers } from '../shared/utils'; import { PainlessRedux } from '../painless-redux/types'; import { EntityActionTypes } from '../entity/types'; import { createEntityActionCreators } from '../entity/action-creators'; import { Reducer } from '../system-types'; import { EntityActions } from '../entity/actions'; import { getHash } from '../entity/utils'; export const initStoreWithPr = ( store: TestStore, pr: PainlessRedux, ) => { const reducer = pr.getReducer(); const globalReducer = combineReducers({ [pr.name]: reducer }); const state = globalReducer({}, { type: 'any' }); store.setState(state); return globalReducer; }; export const getOrderedMarbleStream = (...items: any) => { const { marble, values } = items.reduce(( memo: any, item: any, index: number, ) => { const isFrames = typeof item === 'string'; const isArray = Array.isArray(item); let marblePart: string = index.toString(); if (isFrames) { marblePart = item; } if (isArray) { marblePart = `(${item.map(( subItem: any, subIndex: number, ) => `${index}${subIndex}`)})`; } memo.marble += marblePart; if (!isFrames) { if (isArray) { memo.values = item.reduce(( subMemo: any, subItem: any, subIndex: number, ) => { subMemo[`${index}${subIndex}`] = subItem; return subMemo; }, memo.values); } else { memo.values[index] = item; } } return memo; }, { marble: '', values: {} }); return cold(marble, values); }; export const createTestHelpers = ( reducerFactory: (types: EntityActionTypes) => Reducer, ) => { const types: EntityActionTypes = { ADD: 'ADD', RESOLVE_ADD: 'RESOLVE_ADD', ADD_LIST: 'ADD_LIST', SET_LOADING_STATE: 'SET_LOADING_STATE', CHANGE: 'CHANGE', RESOLVE_CHANGE: 'RESOLVE_CHANGE', REMOVE: 'REMOVE', RESOLVE_REMOVE: 'RESOLVE_REMOVE', RESTORE_REMOVED: 'RESTORE_REMOVED', REMOVE_LIST: 'REMOVE_LIST', RESTORE_REMOVED_LIST: 'RESTORE_REMOVED_LIST', RESOLVE_REMOVE_LIST: 'RESOLVE_REMOVE_LIST', CLEAR: 'CLEAR', CLEAR_ALL: 'CLEAR_ALL', BATCH: 'BATCH', SET_LOADING_STATES: 'SET_LOADING_STATES', CHANGE_LIST: 'CHANGE_LIST', RESOLVE_CHANGE_LIST: 'RESOLVE_CHANGE_LIST', }; const actionCreators = createEntityActionCreators( types, { hashFn: getHash, name: 'test', pageSize: 10, maxPagesCount: 10 }, ); const reducer = reducerFactory(types); return { actionCreators, reducer }; }; ================================================ FILE: src/testing/store.ts ================================================ import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { AnyAction, Reducer, RxStore } from '../system-types'; import { combineReducers } from '../shared/utils'; export class TestStore extends BehaviorSubject implements RxStore { actions$: ReplaySubject = new ReplaySubject(); state: T; constructor( private initialState: T, private reducer: Reducer, ) { super(initialState); this.state = initialState; } setState(data: T) { this.next(data); } dispatch( action: AnyAction, ) { this.actions$.next(action); this.performDispatch(action); } addReducer( key: string, reducer: any, ) { this.reducer = combineReducers({ [key]: reducer }); this.performDispatch({ type: 'ADD_REDUCER' }); } clear() { this.performDispatch({ type: 'TEST_CLEAR' }, this.initialState); } private performDispatch( action: AnyAction, state = this.state, ) { this.state = this.reducer(state, action); this.setState(this.state); } } ================================================ FILE: src/utils.ts ================================================ import { MD5 } from 'object-hash'; import { capitalize, defaultsDeep, isNil, isObject, keyBy, lowerCase } from 'lodash-es'; import { Observable, OperatorFunction } from 'rxjs'; import { distinctUntilChanged, map, take } from 'rxjs/operators'; import { DeepPartial, Dictionary, Id } from './system-types'; export const capitalizeAll = (str: string | number | symbol): string => lowerCase(str.toString()).split(' ').map((part: string) => capitalize(part)).join(' '); export const hashString = (value: string): string => MD5(value).toString(); export const hashIt = (value?: unknown): string => { if (typeof value === 'string') return hashString(value); return MD5(value ?? {}); }; export const getHeadedActionName = ( header: string, ...rest: string[] ) => { const actionString = capitalizeAll(rest.join(' ')); const typeString = capitalizeAll(header); return `[${typeString}] ${actionString}`; }; export const createActionTypes = ( actionTypeNames: (keyof T)[], entityName: string, ): T => { return actionTypeNames.reduce(( memo: any, actionName: keyof T, ) => { memo[actionName] = getHeadedActionName(entityName, actionName as string); return memo; }, {}); }; export const merge = ( src: T, patch: DeepPartial, ): T => { if (isNil(src)) return { ...patch } as T; const newObject: T = { ...src }; for (const key in patch) { const srcValue = src[key]; const patchValue = patch[key]; if (patchValue === undefined) { delete newObject[key]; } else if (isObject(patchValue) && !Array.isArray(patchValue)) { newObject[key] = merge(srcValue, patchValue as any); } else { (newObject[key] as unknown) = patchValue; } } return newObject; }; export const typedDefaultsDeep = ( obj: Partial | undefined, ...args: Partial[] ): Partial => defaultsDeep({}, obj, ...args) as Partial; export const snapshot = (obs$: Observable): T | undefined => { let value; obs$.pipe(take(1)).subscribe((v) => value = v); return value; }; export const select = ( selector: ( value: T, index: number, ) => R, ): OperatorFunction => ( source: Observable, ): Observable => source.pipe( map(( value: T, index: number, ) => selector(value, index)), distinctUntilChanged(), ); export const toDictionary = ( key: string = 'id', ): OperatorFunction> => ( source: Observable, ): Observable> => source.pipe( map((values: T[] | undefined) => keyBy(values, key)), ); export const removeFromArray = ( array: T[], itemsToRemove: T[], ): T[] => array.filter((item) => !itemsToRemove.includes(item)); export const removeFromObject = ( obj: Record, keysToRemove: Id[], ): Record => keysToRemove.reduce((memo, key: Id) => { delete memo[key]; return memo; }, { ...obj }); ================================================ FILE: src/workspace/action-creators.ts ================================================ import { WorkspaceActionTypes } from './types'; import { createChange, createResolveChange } from './actions'; import { createSetLoadingState } from '../shared/loading-state/actions'; import { createBatch } from '../shared/system/actions'; export const createWorkspaceActionCreators = ( actionTypes: WorkspaceActionTypes, ) => ({ CHANGE: createChange(actionTypes), RESOLVE_CHANGE: createResolveChange(actionTypes), SET_LOADING_STATE: createSetLoadingState(actionTypes), BATCH: createBatch(actionTypes), }); export type WorkspaceActionCreators = ReturnType; ================================================ FILE: src/workspace/actions.ts ================================================ import * as changeActions from '../shared/change/actions'; import { LoadingStateActions } from '../shared/loading-state/actions'; import { ChangeOptions } from '../shared/change/types'; import { WorkspaceActionTypes } from './types'; import { DeepPartial } from '../system-types'; import { SystemActions } from '../shared/system/actions'; export const createChange = (types: WorkspaceActionTypes) => ( patch: DeepPartial, label: string, changeId?: string, options?: ChangeOptions, ) => { const actionCreator = changeActions.createChange(types); const action = actionCreator(patch, changeId, options); return { ...action, label } as const; }; export const createResolveChange = (types: WorkspaceActionTypes) => ( label: string, changeId: string, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ) => { const actionCreator = changeActions.createResolveChange(types); const action = actionCreator(changeId, success, remotePatch, options); return { ...action, label } as const; }; type SelfActions = ReturnType | ReturnType; export type WorkspaceChangeAction = ReturnType export type WorkspaceActions = ReturnType | LoadingStateActions | SystemActions ================================================ FILE: src/workspace/constants.ts ================================================ import { WorkspaceActionTypes } from './types'; export const WORKSPACE_TYPE_NAMES: (keyof WorkspaceActionTypes)[] = [ 'CHANGE', 'SET_LOADING_STATE', 'RESOLVE_CHANGE', 'BATCH', ]; ================================================ FILE: src/workspace/methods/dispatch/dispatch.ts ================================================ import { Dispatcher } from '../../../dispatcher/types'; import { WorkspaceActions } from '../../actions'; import { WorkspaceActionTypes } from '../../types'; import { DispatchWorkspaceMethods } from './types'; import { DeepPartial, Id, LoadingState } from '../../../system-types'; import { SelectWorkspaceMethods } from '../select/types'; import { getHeadedActionName } from '../../../utils'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { LoadingStateSetOptions } from '../../../shared/loading-state/types'; import { normalizePatch } from '../../../shared/change/utils'; export const createDispatchWorkspaceMethods = ( dispatcher: Dispatcher, selectMethods: SelectWorkspaceMethods, name: string, ): DispatchWorkspaceMethods => { const changeWithId = ( patch: PatchRequest, label: string, changeId?: string, options?: ChangeOptions, ) => { const normalizedPatch = normalizePatch(patch, selectMethods.get$()); label = getHeadedActionName(name, label); return dispatcher.createAndDispatch('CHANGE', [normalizedPatch, label, changeId, options]); }; const resolveChange = ( label: string, changeId: Id, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ) => { return dispatcher.createAndDispatch('RESOLVE_CHANGE', [label, changeId, success, remotePatch], options); }; const change = ( patch: PatchRequest, label: string, options?: ChangeOptions, ) => { return changeWithId(patch, label, undefined, options); }; const setLoadingState = ( state: LoadingState, key?: string, options?: LoadingStateSetOptions, ) => { return dispatcher.createAndDispatch('SET_LOADING_STATE', [state, key], options); }; const batch = ( actions: WorkspaceActions[], ) => { return dispatcher.createAndDispatch('BATCH', [actions]); }; return { changeWithId, setLoadingState: setLoadingState, change, resolveChange, batch }; }; ================================================ FILE: src/workspace/methods/dispatch/types.ts ================================================ import { DeepPartial, Id, LoadingState } from '../../../system-types'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { WorkspaceActions } from '../../actions'; import { LoadingStateSetOptions } from '../../../shared/loading-state/types'; export interface DispatchWorkspaceMethods { change: ( patch: PatchRequest, label: string, options?: ChangeOptions, ) => WorkspaceActions; changeWithId: ( patch: PatchRequest, label: string, changeId?: string, options?: ChangeOptions, ) => WorkspaceActions; resolveChange: ( label: string, changeId: Id, success: boolean, remotePatch?: DeepPartial, options?: ChangeOptions, ) => WorkspaceActions; setLoadingState: ( state: LoadingState, key?: string, options?: LoadingStateSetOptions, ) => WorkspaceActions; batch: ( actions: WorkspaceActions[], ) => WorkspaceActions; } ================================================ FILE: src/workspace/methods/mixed/mixed.ts ================================================ import { DeepPartial, LoadingState } from '../../../system-types'; import { Observable } from 'rxjs'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { v4 } from 'uuid'; import { getPatchByOptions, getResolvePatchByOptions, normalizePatch } from '../../../shared/change/utils'; import { PainlessReduxSchema } from '../../../painless-redux/types'; import { getRemotePipe, guardIfLoading } from '../../../shared/utils'; import { SelectWorkspaceMethods } from '../select/types'; import { DispatchWorkspaceMethods } from '../dispatch/types'; import { MixedWorkspaceMethods } from './types'; import { typedDefaultsDeep } from '../../../utils'; export const createWorkspaceMixedMethods = ( prSchema: PainlessReduxSchema, selectMethods: SelectWorkspaceMethods, dispatchMethods: DispatchWorkspaceMethods, ): MixedWorkspaceMethods => { const changeRemote$ = ( patch: PatchRequest, dataSource$: Observable | undefined>, label: string, options?: ChangeOptions, ): Observable> => { options = typedDefaultsDeep(options, { rethrow: true }); const changeId = v4(); const { changeWithId, resolveChange, setLoadingState } = dispatchMethods; const { getLoadingState$, get$ } = selectMethods; const normalizedPatch = normalizePatch(patch, get$()); const sourcePipe = getRemotePipe | undefined, DeepPartial>({ options, remoteObsOrFactory: dataSource$, success: ( response?: DeepPartial, ) => { const patchToApply = getPatchByOptions(normalizedPatch, response, options) ?? {}; return changeWithId(patchToApply, label, changeId, options); }, emitOnSuccess: true, optimistic: options?.optimistic, optimisticResolve: ( success: boolean, response?: DeepPartial, ) => { const patchToApply = getResolvePatchByOptions(normalizedPatch, response, options); return resolveChange(label, changeId, success, patchToApply, options); }, setLoadingState: (state) => setLoadingState(state), }); const loadingState$ = getLoadingState$(prSchema.useAsapSchedulerInLoadingGuards); return guardIfLoading(loadingState$).pipe(sourcePipe); }; return { changeRemote$ }; }; ================================================ FILE: src/workspace/methods/mixed/types.ts ================================================ import { DeepPartial } from '../../../system-types'; import { ChangeOptions, PatchRequest } from '../../../shared/change/types'; import { Observable } from 'rxjs'; export interface MixedWorkspaceMethods { changeRemote$: ( patch: PatchRequest, dataSource$: Observable | undefined>, label: string, options?: ChangeOptions, ) => Observable>; } ================================================ FILE: src/workspace/methods/select/select.ts ================================================ import { SelectManager } from '../../../select-manager/types'; import { WorkspaceSelectors } from '../../types'; import { Observable } from 'rxjs'; import { SelectWorkspaceMethods } from './types'; import { LoadingState } from '../../../system-types'; export const createSelectWorkspaceMethods = ( { select$ }: SelectManager, selectors: WorkspaceSelectors, ): SelectWorkspaceMethods => { const get$ = (): Observable => { const selector = selectors.actual; return select$(selector); }; const getLoadingState$ = (isAsap?: boolean): Observable => { const selector = selectors.loadingState; return select$(selector, isAsap); }; // const getByMap$ = >>( // selectMap?: M, // ): Observable, M>> | Observable> => { // const value$ = get$(); // if (!selectMap) return value$; // return value$.pipe( // map((value: Partial) => maskObject(value, selectMap)), // ); // }; return { get$, getLoadingState$, // getByMap$, }; }; ================================================ FILE: src/workspace/methods/select/types.ts ================================================ import { Observable } from 'rxjs'; import { LoadingState } from '../../../system-types'; export interface SelectWorkspaceMethods { get$: () => Observable; getLoadingState$: (isAsap?: boolean) => Observable; // getByMap$: >>( // selectMap?: M // ) => Observable, M>> | Observable>; } ================================================ FILE: src/workspace/reducer.ts ================================================ import { WorkspaceActionTypes, WorkspaceState } from './types'; import { Reducer } from '../system-types'; import { combineReducers } from '../shared/utils'; import { WorkspaceActions } from './actions'; import { createLoadingStateReducer } from '../shared/loading-state/reducers'; import { createChangeReducer } from '../shared/change/reducer'; import { batchActionsReducerFactory } from '../shared/system/reducers'; export const createBaseReducer = ( actionTypes: WorkspaceActionTypes, initialValue?: Partial, ): Reducer, WorkspaceActions> => combineReducers, WorkspaceActions>({ value: createChangeReducer(actionTypes, initialValue as T), loadingState: createLoadingStateReducer(actionTypes), }); export const createWorkspaceReducer = ( actionTypes: WorkspaceActionTypes, initialValue?: Partial, ): Reducer, WorkspaceActions> => { const baseReducer = createBaseReducer(actionTypes, initialValue); return batchActionsReducerFactory(actionTypes, baseReducer); }; ================================================ FILE: src/workspace/selectors.ts ================================================ import { createSelector, Selector } from 'reselect'; import { PainlessReduxState } from '../painless-redux/types'; import { WorkspaceSelectors, WorkspaceState } from './types'; import { createLoadingStateSelector } from '../shared/loading-state/selectors'; import { getChangeableActual } from '../shared/change/selectors'; export const createWorkspaceValueSelector = ( selector: Selector>, ) => createSelector( selector, (workspace: WorkspaceState) => getChangeableActual(workspace.value), ); export const createWorkspaceSelectors = ( selector: Selector>, ): WorkspaceSelectors => { const actual = createWorkspaceValueSelector(selector); const loadingState = createLoadingStateSelector>(selector); return { actual, loadingState, }; }; ================================================ FILE: src/workspace/types.ts ================================================ import { ChangeableState } from '../shared/change/types'; import { LoadingStateActionTypes, LoadingStateSelectors, LoadingStateState } from '../shared/loading-state/types'; import { Selector } from 'reselect'; import { SelectWorkspaceMethods } from './methods/select/types'; import { DispatchWorkspaceMethods } from './methods/dispatch/types'; import { WorkspaceActionCreators } from './action-creators'; import { MixedWorkspaceMethods } from './methods/mixed/types'; import { SystemActionTypes } from '../shared/system/types'; export type PublicDispatchWorkspaceMethods = Omit, 'changeWithId' | 'resolveChange'> export interface Workspace extends PublicDispatchWorkspaceMethods, SelectWorkspaceMethods, MixedWorkspaceMethods { actionCreators: WorkspaceActionCreators; } export interface WorkspaceState extends LoadingStateState { value: ChangeableState | undefined; } export interface WorkspaceSchema { name: string; initialValue?: T; } export interface WorkspaceActionTypes extends SystemActionTypes { SET_LOADING_STATE: LoadingStateActionTypes['SET_LOADING_STATE']; CHANGE: 'CHANGE'; RESOLVE_CHANGE: 'RESOLVE_CHANGE'; } export type ValueSelector = Selector, T | undefined>; export interface WorkspaceSelectors extends LoadingStateSelectors> { actual: ValueSelector; } export type BooleanMap = { [K in keyof T]?: T[K] extends object ? boolean | BooleanMap : boolean }; export type SelectValue = M extends boolean ? T : SelectResult; export type SelectResult> = { [K in (keyof M & keyof T)]: SelectValue }; ================================================ FILE: src/workspace/utils.ts ================================================ import { createActionTypes, typedDefaultsDeep } from '../utils'; import { WORKSPACE_TYPE_NAMES } from './constants'; import { WorkspaceActionTypes, WorkspaceSchema } from './types'; import { WorkspaceChangeAction } from './actions'; export const getFullWorkspaceSchema = ( schema?: Partial>, ): WorkspaceSchema => typedDefaultsDeep(schema, { initialValue: undefined, }) as WorkspaceSchema; export const createWorkspaceActionTypes = (workspaceName: string): WorkspaceActionTypes => { return createActionTypes(WORKSPACE_TYPE_NAMES, workspaceName); }; // export const maskObject = (obj: T, mask: M): SelectResult => { // if (isNil(obj || isNil(mask))) return obj as unknown as SelectResult; // return Object.keys(obj).reduce((memo: any, key: string) => { // let value = obj[key]; // const maskValue = mask[key]; // if (!maskValue) return memo; // if (typeof maskValue === 'object') { // value = maskObject(value, maskValue); // } // memo[key] = value; // return memo; // }, {}) as SelectResult; // }; export const actionSanitizer = (action: WorkspaceChangeAction) => ({ ...action, _type: action.type, type: action.label || action.type, }); ================================================ FILE: src/workspace/workspace.spec.ts ================================================ import 'jest'; import { cold } from 'jest-marbles'; import { Workspace } from './types'; import { TestStore } from '../testing/store'; import { createPainlessRedux } from '../painless-redux/painless-redux'; import { createWorkspace } from '../workspace/workspace'; import { PainlessRedux } from '../painless-redux/types'; import { initStoreWithPr } from '../testing/helpers'; interface TestWorkspace { fill?: boolean; color?: { red?: number; green?: number; blue?: number; }; values?: number[]; } describe('Workspace', () => { let pr: PainlessRedux; let workspace: Workspace; let store: TestStore; let initialValue: TestWorkspace; let noLabel: string; beforeEach(() => { store = new TestStore(undefined, (state) => state); pr = createPainlessRedux(store, { useAsapSchedulerInLoadingGuards: false }); initialValue = { fill: true, color: { red: 0, green: 0, blue: 0 }, values: [1, 2, 3], }; workspace = createWorkspace(pr, { name: 'test', initialValue }); noLabel = '[Test] '; initStoreWithPr(store, pr); }); describe('#get$', () => { test('should return observable with origin value by empty selector', () => { // arrange const expected$ = cold('a', { a: initialValue }); // act const actual = workspace.get$(); // assert expect(actual).toBeObservable(expected$); }); }); describe('#getLoadingState$', () => { test('should return observable with loadingState', () => { // act const actual = workspace.getLoadingState$(); // assert const expected$ = cold('a', { a: { isLoading: false } }); expect(actual).toBeObservable(expected$); }); }); xdescribe('#getByMap$', () => { test('should return observable to workspace', () => { // arrange // const expected$ = cold('a', { a: initialValue }); // // act // const actual = workspace.getByMap$(); // // assert // expect(actual).toBeObservable(expected$); }); test('should return observable to masked workspace', () => { // arrange // const expected$ = cold('a', { // a: { color: { red: initialValue.color?.red } }, // }); // // act // const actual = workspace.getByMap$({ color: { red: true } }); // // assert // expect(actual).toBeObservable(expected$); }); }); describe('#change', () => { test('should change workspace value', () => { // arrange const patch = { color: { blue: 255 } }; const action = workspace.actionCreators.CHANGE(patch, noLabel); const expected$ = cold('a', { a: action }); // act workspace.change(patch, ''); // assert expect(store.actions$).toBeObservable(expected$); }); test('should change workspace value based on previous', () => { // arrange const action = workspace.actionCreators.CHANGE({ color: { blue: 1 } }, noLabel); const expected$ = cold('a', { a: action, }); // act workspace.change((previous) => ({ color: { blue: (previous?.color?.blue ?? 0) + 1 }, }), ''); // assert expect(store.actions$).toBeObservable(expected$); }); test('should set upper cased label for action', () => { // arrange const patch = {}; const action = workspace.actionCreators.CHANGE(patch, `[Test] Some Label`); const expected$ = cold('a', { a: action }); // act workspace.change(patch, 'some label'); // assert expect(store.actions$).toBeObservable(expected$); }); }); }); ================================================ FILE: src/workspace/workspace.ts ================================================ import { PainlessRedux, SlotTypes } from '../painless-redux/types'; import { Workspace, WorkspaceActionTypes, WorkspaceSchema, WorkspaceState } from './types'; import { WorkspaceActions } from './actions'; import { createWorkspaceActionTypes, getFullWorkspaceSchema } from './utils'; import { createWorkspaceActionCreators } from './action-creators'; import { createWorkspaceReducer } from './reducer'; import { createWorkspaceSelectors } from './selectors'; import { createSelectWorkspaceMethods } from './methods/select/select'; import { createDispatchWorkspaceMethods } from './methods/dispatch/dispatch'; import { createWorkspaceMixedMethods } from './methods/mixed/mixed'; export const createWorkspace = ( pr: PainlessRedux, schema?: Partial>, ): Workspace => { const fullSchema = getFullWorkspaceSchema(schema); const name = fullSchema.name; const actionTypes = createWorkspaceActionTypes(name); const actionCreators = createWorkspaceActionCreators(actionTypes); const reducer = createWorkspaceReducer(actionTypes, fullSchema.initialValue); const { selector, dispatcher, selectManager, } = pr.registerSlot, WorkspaceActionTypes, WorkspaceActions>( SlotTypes.Workspace, name, reducer, actionCreators, ); const selectors = createWorkspaceSelectors(selector); const selectMethods = createSelectWorkspaceMethods(selectManager, selectors); const dispatchMethods = createDispatchWorkspaceMethods(dispatcher, selectMethods, name); const mixedMethods = createWorkspaceMixedMethods(pr.schema, selectMethods, dispatchMethods); const { changeWithId, resolveChange, ...publicDispatchMethods } = dispatchMethods; return { ...publicDispatchMethods, ...selectMethods, ...mixedMethods, actionCreators, }; }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2015", "module": "ES2020", "declaration": true, "outDir": "./dist", "strict": true, "allowSyntheticDefaultImports": true, "moduleResolution": "Node", "esModuleInterop": true, "lib": [ "dom", "es2015", "es2016.array.include" ] }, "exclude": [ "node_modules", "**/*.spec.*" ] }