├── .commitlintrc.json ├── .editorconfig ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .prettierrc.json ├── .travis.yml ├── README.md ├── angular.json ├── integration ├── .browserslistrc ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ └── store │ │ ├── index.ts │ │ └── todo │ │ ├── another-store.ts │ │ ├── index.ts │ │ └── store.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styles.scss ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── tslint.json ├── karma.conf.js ├── package.json ├── renovate.json ├── rollup.config.js ├── src ├── index.ts ├── lib │ ├── action-handlers.ts │ ├── actions │ │ ├── active.ts │ │ ├── add.ts │ │ ├── create-or-replace.ts │ │ ├── error.ts │ │ ├── index.ts │ │ ├── loading.ts │ │ ├── pagination.ts │ │ ├── remove.ts │ │ ├── reset.ts │ │ ├── type-alias.ts │ │ └── update.ts │ ├── entity-state.ts │ ├── errors.ts │ ├── id-strategy.ts │ ├── internal.ts │ ├── models.ts │ └── state-operators │ │ ├── adding.ts │ │ ├── index.ts │ │ ├── removal.ts │ │ ├── timestamp.ts │ │ └── updating.ts ├── package.json ├── public_api.ts ├── test.ts ├── tests │ ├── entity-state │ │ ├── action-handlers.spec.ts │ │ ├── reflection-validation.spec.ts │ │ └── static-selectors.spec.ts │ ├── id-strategy.spec.ts │ └── internal.spec.ts ├── tsconfig.lib.json ├── tsconfig.spec.json └── tslint.json ├── tools ├── build.ts ├── bump.ts ├── copy-readme.ts └── sonar.ts ├── tsconfig.base.json ├── tsconfig.json ├── tsconfig.lib.prod.json ├── tslint.json └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /dist-integration 6 | /tmp 7 | /out-tsc 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,js,html,scss}": ["prettier --write", "git add"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 95, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | 4 | language: node_js 5 | node_js: 6 | - '12' 7 | 8 | dist: trusty 9 | sudo: false 10 | 11 | cache: 12 | yarn: true 13 | directories: 14 | - node_modules 15 | 16 | addons: 17 | chrome: stable 18 | 19 | before_script: 20 | - 'export CHROME_BIN=chromium-browser' 21 | - 'export DISPLAY=:99.0' 22 | - 'sh -e /etc/init.d/xvfb start' 23 | - 'sudo chown root /opt/google/chrome/chrome-sandbox' 24 | - 'sudo chmod 4755 /opt/google/chrome/chrome-sandbox' 25 | 26 | before_install: 27 | - 'curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.22.4' 28 | - 'export PATH="$HOME/.yarn/bin:$PATH"' 29 | 30 | install: 31 | - yarn 32 | 33 | script: 34 | - yarn ci:pipelines 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | --- 6 | 7 | > Easy CRUD actions for your `ngxs` state 8 | 9 | [![Build Status](https://travis-ci.org/ngxs-labs/entity-state.svg?branch=master)](https://travis-ci.org/ngxs-labs/entity-state) 10 | [![NPM](https://badge.fury.io/js/%40ngxs-labs%2Fentity-state.svg)](https://www.npmjs.com/package/@ngxs-labs/entity-state) 11 | [![License](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/ngxs-labs/entity-state/blob/master/LICENSE) 12 | 13 | This package is an entity adapter and simplifies CRUD behaviour with just a few lines of setup per state class! 14 | 15 | ### Setup 16 | 17 | ```bash 18 | npm i @ngxs-labs/entity-state 19 | ``` 20 | 21 | You do not have import any module, just extend your state class, make a `super` call and you are good to go! 22 | The first `super` parameter is always the state class itself. 23 | The second parameter is the key to identify your entities with. 24 | The third is an implementation of an `IdGenerator` (see [below](#IdStrategy)). 25 | 26 | #### Example state 27 | 28 | ```typescript 29 | export interface ToDo { 30 | title: string; 31 | description: string; 32 | done: boolean; 33 | } 34 | 35 | @State>({ 36 | name: 'todo', 37 | defaults: defaultEntityState() 38 | }) 39 | export class TodoState extends EntityState { 40 | constructor() { 41 | super(TodoState, 'title', IdStrategy.EntityIdGenerator); 42 | } 43 | } 44 | ``` 45 | 46 | >[Example in the integration app](https://github.com/ngxs-labs/entity-state/blob/master/integration/app/store/todo/store.ts#L19)! 47 | 48 | 49 | ### Actions 50 | 51 | There are ready to use Actions for entity-states. Just pass in the targeted state class as the first parameter and then your action's payload. 52 | 53 | ```typescript 54 | this.store.dispatch(new SetLoading(TodoState, this.loading)); 55 | this.store.dispatch(new UpdateActive(TodoState, { done: true })); 56 | ``` 57 | 58 | >[Example in the integration app](https://github.com/ngxs-labs/entity-state/blob/master/integration/app/app.component.ts#L101-L107) 59 | 60 | | Action | Short description | 61 | |---|---| 62 | | `Add` | Adds a new entity and cannot replace existing entities | 63 | | `CreateOrReplace` | Gets the entity's ID and will replace entities with same id or else add a new one | 64 | | `Update` | Updates [one or more](#EntitySelector) entities by partial value or function | 65 | | `UpdateAll` | Update all entities by partial value or function | 66 | | `Remove` | Removes entities from the state | 67 | | `RemoveAll` | Removes all entities from the state | 68 | | ---------- | ---------- | 69 | | `SetActive` | Takes an ID and sets it as active | 70 | | `ClearActive` | Clears the active ID | 71 | | `UpdateActive` | Updates the currently active entity | 72 | | `RemoveActive` | Removes the active entity and clears the ID | 73 | | ---------- | ---------- | 74 | | `SetError` | Sets the error (`Error` instance or `undefined`)| 75 | | `SetLoading` | Sets the loading state (`true` or `false`) | 76 | | `Reset` | Resets the state to default | 77 | | ---------- | ---------- | 78 | | `GoToPage` | Goes to specified page, via index, stepwise or first/last | 79 | | `SetPageSize` | Sets the page size | 80 | 81 | Actions that change the entities will update the internal timestamp `lastUpdated`. 82 | You can use one of the existing selectors to see the age of your data. 83 | 84 | ### Selectors 85 | 86 | Use predefined Selectors just like you would normally! 87 | 88 | ```typescript 89 | @Select(TodoState.entities) toDos$: Observable; 90 | @Select(TodoState.active) active$: Observable; 91 | ``` 92 | 93 | >[Example in the integration app](https://github.com/ngxs-labs/entity-state/blob/master/integration/app/app.component.ts#L30-L38) 94 | 95 | | Selector | Short description | 96 | |---|---| 97 | | `entities` | All entities in an array | 98 | | `keys` | All entity keys in an array | 99 | | `entitiesMap` | All entities in a map | 100 | | `size` | Entity count | 101 | | `active ` | the active entity | 102 | | `activeId` | the active ID | 103 | | `paginatedEntities` | Entities in an array defined by pagination values | 104 | | `nthEntity` | the nthEntity by insertion order | 105 | | `latestId` | the ID of the latest entity | 106 | | `latest` | the latest entity | 107 | | `loading` | the loading state | 108 | | `error` | the current error | 109 | | `lastUpdated` | the `lastUpdated` timestamp as `Date` | 110 | | `age` | difference between `Date.now()` and `lastUpdated` in ms | 111 | 112 | ### `IdStrategy` 113 | 114 | There are 3 different strategies in the `IdStrategy` namespace available: 115 | 116 | - `IncrementingIdGenerator` -> uses auto-incremeting IDs based on present entities 117 | - `UUIDGenerator` -> generates `UUID`s for new entities 118 | - `EntityIdGenerator` -> takes the id from the provided entity 119 | 120 | The latter will cause errors if you try to `add` an entity with the same ID. 121 | The former two will always generate a new ID. 122 | 123 | You can also implement your own strategy by extending `IdGenerator` and then provide it [in the `super` call](https://github.com/ngxs-labs/entity-state/blob/master/integration/app/store/todo/store.ts#L21). 124 | 125 | ### `EntitySelector` 126 | 127 | The `EntitySelector` type is used in Actions such as `Update` or `Remove`. 128 | ```typescript 129 | export type EntitySelector = string | string[] | ((entity: T) => boolean); 130 | ``` 131 | 132 | - `string` -> one ID; selects one entity 133 | - `string[]` -> array of IDs; selects matching entities 134 | - `(entity: T) => boolean` -> predicate; selects entities that return `true` when applied to this function 135 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "defaultProject": "entity-state", 6 | "cli": { 7 | "packageManager": "yarn" 8 | }, 9 | "projects": { 10 | "integration": { 11 | "root": "integration", 12 | "sourceRoot": ".", 13 | "projectType": "application", 14 | "prefix": "app", 15 | "schematics": { 16 | "@schematics/angular:component": { 17 | "style": "scss" 18 | } 19 | }, 20 | "architect": { 21 | "build": { 22 | "builder": "@angular-devkit/build-angular:browser", 23 | "options": { 24 | "aot": true, 25 | "outputPath": "dist-integration/", 26 | "index": "integration/index.html", 27 | "main": "integration/main.ts", 28 | "polyfills": "integration/polyfills.ts", 29 | "tsConfig": "integration/tsconfig.app.json", 30 | "assets": [ 31 | "integration/favicon.ico", 32 | "integration/assets" 33 | ], 34 | "styles": [ 35 | "integration/styles.scss" 36 | ], 37 | "scripts": [] 38 | }, 39 | "configurations": { 40 | "production": { 41 | "fileReplacements": [ 42 | { 43 | "replace": "integration/environments/environment.ts", 44 | "with": "integration/environments/environment.prod.ts" 45 | } 46 | ], 47 | "optimization": true, 48 | "outputHashing": "all", 49 | "sourceMap": false, 50 | "extractCss": true, 51 | "namedChunks": false, 52 | "aot": true, 53 | "extractLicenses": true, 54 | "vendorChunk": false, 55 | "buildOptimizer": true, 56 | "budgets": [ 57 | { 58 | "type": "initial", 59 | "maximumWarning": "2mb", 60 | "maximumError": "5mb" 61 | }, 62 | { 63 | "type": "anyComponentStyle", 64 | "maximumWarning": "6kb" 65 | } 66 | ] 67 | } 68 | } 69 | }, 70 | "serve": { 71 | "builder": "@angular-devkit/build-angular:dev-server", 72 | "options": { 73 | "browserTarget": "integration:build" 74 | }, 75 | "configurations": { 76 | "production": { 77 | "browserTarget": "integration:build:production" 78 | } 79 | } 80 | }, 81 | "extract-i18n": { 82 | "builder": "@angular-devkit/build-angular:extract-i18n", 83 | "options": { 84 | "browserTarget": "integration:build" 85 | } 86 | }, 87 | "test": { 88 | "builder": "@angular-devkit/build-angular:karma", 89 | "options": { 90 | "main": "integration/test.ts", 91 | "polyfills": "integration/polyfills.ts", 92 | "tsConfig": "integration/tsconfig.spec.json", 93 | "karmaConfig": "karma.conf.js", 94 | "styles": [ 95 | "integration/styles.scss" 96 | ], 97 | "scripts": [], 98 | "assets": [ 99 | "integration/favicon.ico", 100 | "integration/assets" 101 | ] 102 | } 103 | }, 104 | "lint": { 105 | "builder": "@angular-devkit/build-angular:tslint", 106 | "options": { 107 | "tsConfig": [ 108 | "integration/tsconfig.app.json", 109 | "integration/tsconfig.spec.json" 110 | ], 111 | "exclude": [ 112 | "**/node_modules/**" 113 | ] 114 | } 115 | } 116 | } 117 | }, 118 | "entity-state": { 119 | "root": "", 120 | "sourceRoot": "src", 121 | "projectType": "library", 122 | "prefix": "entity", 123 | "architect": { 124 | "build": { 125 | "builder": "@angular-devkit/build-ng-packagr:build", 126 | "options": { 127 | "tsConfig": "src/tsconfig.lib.json" 128 | } 129 | , "configurations": { 130 | "production": { 131 | "tsConfig": "tsconfig.lib.prod.json" 132 | } 133 | } 134 | }, 135 | "test": { 136 | "builder": "@angular-devkit/build-angular:karma", 137 | "options": { 138 | "main": "src/test.ts", 139 | "karmaConfig": "karma.conf.js", 140 | "tsConfig": "src/tsconfig.spec.json" 141 | } 142 | }, 143 | "lint": { 144 | "builder": "@angular-devkit/build-angular:tslint", 145 | "options": { 146 | "tsConfig": [ 147 | "src/tsconfig.lib.json", 148 | "src/tsconfig.spec.json" 149 | ], 150 | "exclude": [ 151 | "**/node_modules/**" 152 | ] 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /integration/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # 5 | # For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed 6 | 7 | > 0.5% 8 | last 2 versions 9 | Firefox ESR 10 | not dead 11 | not IE 9-11 -------------------------------------------------------------------------------- /integration/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Welcome to ngxs-entity-store! 5 | loading ... 6 |

7 |
8 | 9 | 10 | 11 |

Your ToDos (total: {{ count$ | async }})

12 | 13 |
    14 |
  • 15 | {{ toDo.title }} ({{ toDo.done ? '' : 'not ' }}done) 16 | 17 | 18 | 19 |
  • 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 |
33 |

{{ active.title }} ({{ active.done ? '' : 'not ' }}done)

34 |

{{ active.description }}

35 | 36 | 37 |
38 |

39 | Active: {{ active$ | async | json }}
41 | ActiveId: {{ activeId$ | async | json }}
43 | Keys: {{ keys$ | async | json }}

44 |

45 | {{ error$ | async }} 46 | -------------------------------------------------------------------------------- /integration/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngxs-labs/entity-state/fca4283701d67fc1bdb24929beab036250f2f083/integration/app/app.component.scss -------------------------------------------------------------------------------- /integration/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | async, 3 | ComponentFixture, 4 | ComponentFixtureAutoDetect, 5 | TestBed 6 | } from '@angular/core/testing'; 7 | import { Store } from '@ngxs/store'; 8 | import { 9 | defaultEntityState, 10 | EntityActionType, 11 | NoActiveEntityError, 12 | ofEntityAction, 13 | ofEntityActionDispatched, 14 | ofEntityActionErrored, 15 | ofEntityActionSuccessful, 16 | ofEntityActionCompleted 17 | } from '@ngxs-labs/entity-state'; 18 | import { AppComponent } from './app.component'; 19 | import { AppModule } from './app.module'; 20 | import { map } from 'rxjs/operators'; 21 | import { TodoState } from './store/todo'; 22 | 23 | describe('AppComponent', () => { 24 | let fixture: ComponentFixture; 25 | let component: AppComponent; 26 | 27 | beforeEach(() => { 28 | TestBed.configureTestingModule({ 29 | imports: [AppModule], 30 | providers: [{ provide: ComponentFixtureAutoDetect, useValue: true }] 31 | }); 32 | 33 | fixture = TestBed.createComponent(AppComponent); 34 | component = fixture.componentInstance; 35 | 36 | const store = TestBed.get(Store); 37 | store.reset({ todo: defaultEntityState() }); 38 | }); 39 | 40 | it('should add a todo', () => { 41 | component.addToDo(); 42 | 43 | component.toDos$.subscribe(state => { 44 | expect(state.length).toBe(1); 45 | }); 46 | component.latestId$.subscribe(state => { 47 | expect(state).toBe('NGXS Entity Store 1'); 48 | }); 49 | component.latest$.subscribe(state => { 50 | expect(state.description).toBe('Some Descr1'); 51 | }); 52 | }); 53 | 54 | it('should createOrReplace a todo', () => { 55 | component.createOrReplace('1'); 56 | component.createOrReplace('1'); 57 | component.createOrReplace('2'); 58 | component.createOrReplace('1'); 59 | 60 | component.toDos$.subscribe(state => { 61 | expect(state.length).toBe(2); 62 | }); 63 | component.latestId$.subscribe(state => { 64 | expect(state).toBe('2'); 65 | }); 66 | component.latest$.subscribe(state => { 67 | expect(state.description).toBe('Some Descr2'); 68 | }); 69 | }); 70 | 71 | it('should update a todo', () => { 72 | component.addToDo(); 73 | component.setDone({ 74 | title: 'NGXS Entity Store 1', 75 | description: `Doesn't matter. Just need title for ID`, 76 | done: false 77 | }); 78 | 79 | component.toDos$.subscribe(([state]) => { 80 | expect(state.title).toBe('NGXS Entity Store 1'); 81 | expect(state.done).toBeTruthy(); 82 | }); 83 | }); 84 | 85 | it('should remove a todo', () => { 86 | component.addToDo(); 87 | component.addToDo(); 88 | component.open('NGXS Entity Store 1'); 89 | component.removeToDo('NGXS Entity Store 1'); 90 | 91 | component.toDos$.subscribe(state => { 92 | expect(state.length).toBe(1); 93 | expect(state[0].title).toBe('NGXS Entity Store 2'); 94 | }); 95 | 96 | component.activeId$.subscribe(state => { 97 | expect(state).toBeUndefined(); 98 | }); 99 | 100 | component.latestId$.subscribe(state => { 101 | expect(state).toBe('NGXS Entity Store 2'); 102 | }); 103 | }); 104 | 105 | it('should add multiple todos', () => { 106 | component.addMultiple(); // adds 'NGXS Entity Store 1' and 'NGXS Entity Store 2' 107 | 108 | component.toDos$.subscribe(state => { 109 | expect(state.length).toBe(2); 110 | }); 111 | component.latestId$.subscribe(state => { 112 | expect(state).toBe('NGXS Entity Store 2'); 113 | }); 114 | }); 115 | 116 | it('should update multiple todos', () => { 117 | component.addToDo(); 118 | component.addToDo(); 119 | component.addToDo(); 120 | component.updateMultiple(); // updates 1 and 2 121 | 122 | component.toDos$.subscribe(([first, second, third]) => { 123 | expect(first.title).toBe('NGXS Entity Store 1'); 124 | expect(first.done).toBeTruthy(); 125 | expect(second.title).toBe('NGXS Entity Store 2'); 126 | expect(second.done).toBeTruthy(); 127 | expect(third.title).toBe('NGXS Entity Store 3'); 128 | expect(third.done).toBeFalsy(); 129 | }); 130 | }); 131 | 132 | it('should update all todos', () => { 133 | component.addToDo(); 134 | component.addToDo(); 135 | component.addToDo(); 136 | component.doneAll(); 137 | 138 | component.toDos$.subscribe(([first, second, third]) => { 139 | expect(first.title).toBe('NGXS Entity Store 1'); 140 | expect(first.done).toBeTruthy(); 141 | expect(second.title).toBe('NGXS Entity Store 2'); 142 | expect(second.done).toBeTruthy(); 143 | expect(third.title).toBe('NGXS Entity Store 3'); 144 | expect(third.done).toBeTruthy(); 145 | }); 146 | }); 147 | 148 | it('should update multiple todos by selector fn', () => { 149 | component.addToDo(); 150 | component.addToDo(); 151 | component.addToDo(); 152 | 153 | component.setOddDone(); 154 | 155 | component.toDos$.subscribe(([first, second, third]) => { 156 | expect(first.title).toBe('NGXS Entity Store 1'); 157 | expect(first.done).toBeTruthy(); 158 | expect(second.title).toBe('NGXS Entity Store 2'); 159 | expect(second.done).toBeFalsy(); 160 | expect(third.title).toBe('NGXS Entity Store 3'); 161 | expect(third.done).toBeTruthy(); 162 | }); 163 | }); 164 | 165 | it('should update multiple todos with update fn', () => { 166 | component.addToDo(); 167 | component.addToDo(); 168 | component.addToDo(); 169 | 170 | component.open('NGXS Entity Store 2'); 171 | component.setDoneActive(); 172 | component.updateDescription(); 173 | 174 | component.toDos$.subscribe(([first, second, third]) => { 175 | expect(first.title).toBe('NGXS Entity Store 1'); 176 | expect(first.description.includes(' -- This is done!')).toBeFalsy(); 177 | expect(second.title).toBe('NGXS Entity Store 2'); 178 | expect(second.description.includes(' -- This is done!')).toBeTruthy(); 179 | expect(third.title).toBe('NGXS Entity Store 3'); 180 | expect(third.description.includes(' -- This is done!')).toBeFalsy(); 181 | }); 182 | }); 183 | 184 | it('should throw an error if invalid active id', () => { 185 | component.addToDo(); 186 | component.addToDo(); 187 | component.addToDo(); 188 | 189 | component.open('NGXS Entity Store 5'); 190 | 191 | component.updateActiveWithFn().subscribe({ 192 | error: e => expect(e.message).toBe(new NoActiveEntityError().message) 193 | }); 194 | }); 195 | 196 | it('should update active todo with update fn', () => { 197 | component.addToDo(); 198 | component.addToDo(); 199 | component.addToDo(); 200 | 201 | component.open('NGXS Entity Store 2'); 202 | component.updateActiveWithFn(); 203 | 204 | component.toDos$.subscribe(([first, second, third]) => { 205 | expect(first.title).toBe('NGXS Entity Store 1'); 206 | expect(first.description.includes(' -- Updated with Fn')).toBeFalsy(); 207 | expect(second.title).toBe('NGXS Entity Store 2'); 208 | expect(second.description.includes(' -- Updated with Fn')).toBeTruthy(); 209 | expect(third.title).toBe('NGXS Entity Store 3'); 210 | expect(third.description.includes(' -- Updated with Fn')).toBeFalsy(); 211 | }); 212 | }); 213 | 214 | it('should remove active', () => { 215 | component.addToDo(); 216 | component.addToDo(); 217 | component.addToDo(); 218 | 219 | component.open('NGXS Entity Store 2'); 220 | component.removeActive(); 221 | 222 | component.toDos$.subscribe(([first, second]) => { 223 | expect(first.title).toBe('NGXS Entity Store 1'); 224 | expect(second.title).toBe('NGXS Entity Store 3'); 225 | }); 226 | 227 | component.activeId$.subscribe(id => { 228 | expect(id).toBeUndefined(); 229 | }); 230 | }); 231 | 232 | it('should remove multiple todos', () => { 233 | component.addToDo(); // NGXS Entity Store 1 234 | component.open('NGXS Entity Store 1'); 235 | component.addToDo(); // NGXS Entity Store 2 236 | component.addToDo(); // NGXS Entity Store 3 237 | component.addToDo(); // NGXS Entity Store 4 238 | component.addToDo(); // NGXS Entity Store 5 239 | component.removeMultiple([ 240 | 'NGXS Entity Store 1', 241 | 'NGXS Entity Store 2', 242 | 'NGXS Entity Store 3' 243 | ]); 244 | 245 | component.toDos$.subscribe(([first]) => { 246 | expect(first.title).toBe('NGXS Entity Store 4'); 247 | }); 248 | 249 | component.activeId$.subscribe(state => { 250 | expect(state).toBeUndefined(); 251 | }); 252 | component.latestId$.subscribe(state => { 253 | expect(state).toBe('NGXS Entity Store 5'); 254 | }); 255 | }); 256 | 257 | it('should remove multiple todos by function', () => { 258 | component.addToDo(); 259 | component.open('NGXS Entity Store 1'); 260 | component.setDoneActive(); 261 | 262 | component.addToDo(); 263 | 264 | component.addToDo(); 265 | component.open('NGXS Entity Store 3'); 266 | component.setDoneActive(); 267 | 268 | component.addToDo(); 269 | component.addToDo(); 270 | component.removeAllDones(); 271 | 272 | component.toDos$.subscribe(([first]) => { 273 | expect(first.title).toBe('NGXS Entity Store 2'); 274 | }); 275 | 276 | component.count$.subscribe(count => { 277 | expect(count).toBe(3); 278 | }); 279 | }); 280 | 281 | it('should toggle loading', () => { 282 | component.toggleLoading(); 283 | 284 | component.loading$.subscribe(state => { 285 | expect(state).toBeTruthy(); 286 | }); 287 | }); 288 | 289 | it('should toggle error', () => { 290 | component.toggleError(); 291 | 292 | component.error$.subscribe(state => { 293 | expect(state instanceof Error).toBeTruthy(); 294 | }); 295 | }); 296 | 297 | it('should set active entity', () => { 298 | component.addToDo(); 299 | component.open('NGXS Entity Store 1'); 300 | 301 | component.active$.subscribe(state => { 302 | expect(state).toBeTruthy(); 303 | }); 304 | }); 305 | 306 | it('should update active entity', () => { 307 | component.addToDo(); 308 | component.addToDo(); 309 | component.addToDo(); 310 | component.open('NGXS Entity Store 2'); 311 | component.setDoneActive(); 312 | 313 | component.active$.subscribe(state => { 314 | expect(state.title).toBe('NGXS Entity Store 2'); 315 | expect(state.done).toBeTruthy(); 316 | }); 317 | }); 318 | 319 | it('should clear active entity', () => { 320 | component.addToDo(); 321 | component.open('NGXS Entity Store 1'); 322 | component.closeDetails(); 323 | 324 | component.active$.subscribe(state => { 325 | expect(state).toBeUndefined(); 326 | }); 327 | }); 328 | 329 | it('should remove all entities', () => { 330 | component.addToDo(); 331 | component.addToDo(); 332 | component.open('NGXS Entity Store 2'); 333 | component.addToDo(); 334 | component.toggleLoading(); 335 | component.toggleError(); 336 | component.clearEntities(); 337 | 338 | component.toDos$.subscribe(state => { 339 | expect(state.length).toBe(0); 340 | }); 341 | 342 | component.activeId$.subscribe(state => { 343 | expect(state).toBeUndefined(); 344 | }); 345 | 346 | component.error$.subscribe(state => { 347 | expect(state instanceof Error).toBeTruthy(); 348 | }); 349 | 350 | component.loading$.subscribe(state => { 351 | expect(state).toBeTruthy(); 352 | }); 353 | 354 | component.latestId$.subscribe(state => { 355 | expect(state).toBeUndefined(); 356 | }); 357 | }); 358 | 359 | it('should completely reset store', () => { 360 | component.addToDo(); 361 | component.addToDo(); 362 | component.open('NGXS Entity Store 2'); 363 | component.addToDo(); 364 | component.toggleLoading(); 365 | component.toggleError(); 366 | component.resetState(); 367 | 368 | component.toDos$.subscribe(state => { 369 | expect(state.length).toBe(0); 370 | }); 371 | 372 | component.activeId$.subscribe(state => { 373 | expect(state).toBeUndefined(); 374 | }); 375 | 376 | component.error$.subscribe(state => { 377 | expect(state).toBeUndefined(); 378 | }); 379 | 380 | component.loading$.subscribe(state => { 381 | expect(state).toBeFalsy(); 382 | }); 383 | 384 | component.latestId$.subscribe(state => { 385 | expect(state).toBeUndefined(); 386 | }); 387 | }); 388 | 389 | it('should paginate entities', async(() => { 390 | const generateTitles = (from: number, to: number): string[] => { 391 | const arr = []; 392 | for (; from <= to; from++) { 393 | arr.push('NGXS Entity Store ' + from); 394 | } 395 | return arr; 396 | }; 397 | 398 | for (let i = 0; i < 23; i++) { 399 | component.addToDo(); 400 | } 401 | 402 | component 403 | .getPaginatedEntities(10, 0) 404 | .pipe(map(todos => todos.map(t => t.title))) 405 | .subscribe(first => { 406 | expect(first.length).toBe(10); 407 | expect(first).toEqual(generateTitles(1, 10)); 408 | }); 409 | 410 | component 411 | .getPaginatedEntities(10, 1) 412 | .pipe(map(todos => todos.map(t => t.title))) 413 | .subscribe(second => { 414 | expect(second.length).toBe(10); 415 | expect(second).toEqual(generateTitles(11, 20)); 416 | }); 417 | 418 | component 419 | .getPaginatedEntities(10, 2) 420 | .pipe(map(todos => todos.map(t => t.title))) 421 | .subscribe(last => { 422 | expect(last.length).toBe(3); 423 | expect(last).toEqual(generateTitles(21, 23)); 424 | }); 425 | })); 426 | 427 | it('should select nth entities', () => { 428 | const count = 10; 429 | for (let i = 0; i < count; i++) { 430 | component.addToDo(); 431 | } 432 | 433 | for (let i = 0; i < count; i++) { 434 | const toDo = component.getNthEntity(i); 435 | expect(toDo.title).toEqual('NGXS Entity Store ' + (i + 1)); 436 | } 437 | }); 438 | 439 | describe('action handlers', () => { 440 | it('should work with ofEntityAction', done => { 441 | component.actions 442 | .pipe(ofEntityAction(TodoState, EntityActionType.Add)) 443 | .subscribe(action => { 444 | const { type } = Reflect.getPrototypeOf(action as any).constructor as any; 445 | expect(type).toBe('[todo] add'); 446 | done(); 447 | }); 448 | 449 | component.addToDo(); 450 | }); 451 | 452 | it('should work with ofEntityActionCompleted', done => { 453 | component.actions 454 | .pipe(ofEntityActionCompleted(TodoState, EntityActionType.Add)) 455 | .subscribe(action => { 456 | const { type } = Reflect.getPrototypeOf(action.action).constructor as any; 457 | expect(type).toBe('[todo] add'); 458 | done(); 459 | }); 460 | 461 | component.addToDo(); 462 | }); 463 | 464 | it('should work with ofEntityActionDispatched', done => { 465 | component.actions 466 | .pipe(ofEntityActionDispatched(TodoState, EntityActionType.Add)) 467 | .subscribe(action => { 468 | const { type } = Reflect.getPrototypeOf(action).constructor as any; 469 | expect(type).toBe('[todo] add'); 470 | done(); 471 | }); 472 | 473 | component.addToDo(); 474 | }); 475 | 476 | it('should work with ofEntityActionSuccessful', done => { 477 | component.actions 478 | .pipe(ofEntityActionSuccessful(TodoState, EntityActionType.Add)) 479 | .subscribe(action => { 480 | const { type } = Reflect.getPrototypeOf(action).constructor as any; 481 | expect(type).toBe('[todo] add'); 482 | done(); 483 | }); 484 | 485 | component.addToDo(); 486 | }); 487 | 488 | it('should work with ofEntityActionErrored', done => { 489 | component.actions 490 | .pipe(ofEntityActionErrored(TodoState, EntityActionType.Add)) 491 | .subscribe(action => { 492 | const { type } = Reflect.getPrototypeOf(action).constructor as any; 493 | expect(type).toBe('[todo] add'); 494 | done(); 495 | }); 496 | 497 | component.addWithError(); 498 | }); 499 | }); 500 | 501 | describe('working with other states', () => { 502 | it('should emit selects, when the TodoState gets updated', () => { 503 | const spy = jasmine.createSpy('callback'); 504 | component.updateAnother(spy); 505 | expect(spy).toHaveBeenCalledTimes(1); 506 | }); 507 | }); 508 | }); 509 | -------------------------------------------------------------------------------- /integration/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Actions, Select, Store } from '@ngxs/store'; 3 | import { 4 | Add, 5 | ClearActive, 6 | CreateOrReplace, 7 | GoToPage, 8 | Remove, 9 | RemoveActive, 10 | Reset, 11 | SetActive, 12 | SetError, 13 | SetLoading, 14 | SetPageSize, 15 | Update, 16 | UpdateActive, 17 | RemoveAll, 18 | UpdateAll 19 | } from '@ngxs-labs/entity-state'; 20 | import { Observable } from 'rxjs'; 21 | import { ToDo, TodoState, UpdateAnotherState } from './store/todo'; 22 | import { mergeMap } from 'rxjs/operators'; 23 | 24 | @Component({ 25 | selector: 'app-root', 26 | templateUrl: './app.component.html', 27 | styleUrls: ['./app.component.scss'] 28 | }) 29 | export class AppComponent { 30 | @Select(TodoState.size) count$: Observable; 31 | @Select(TodoState.entities) toDos$: Observable; 32 | @Select(TodoState.active) active$: Observable; 33 | @Select(TodoState.activeId) activeId$: Observable; 34 | @Select(TodoState.keys) keys$: Observable; 35 | @Select(TodoState.loading) loading$: Observable; 36 | @Select(TodoState.error) error$: Observable; 37 | @Select(TodoState.latestId) latestId$: Observable; 38 | @Select(TodoState.latest) latest$: Observable; 39 | 40 | private counter = 0; 41 | private loading = false; 42 | private error = false; 43 | 44 | constructor(private store: Store, public actions: Actions) {} 45 | 46 | toggleLoading() { 47 | this.loading = !this.loading; 48 | this.store.dispatch(new SetLoading(TodoState, this.loading)); 49 | } 50 | 51 | removeToDo(title: string) { 52 | this.store.dispatch(new Remove(TodoState, title)); 53 | } 54 | 55 | removeAllDones() { 56 | this.store.dispatch(new Remove(TodoState, e => e.done)); 57 | } 58 | 59 | setDone(toDo: ToDo) { 60 | this.store.dispatch( 61 | new Update(TodoState, toDo.title, { 62 | done: true 63 | }) 64 | ); 65 | } 66 | 67 | setOddDone() { 68 | this.store.dispatch( 69 | new Update( 70 | TodoState, 71 | e => parseInt(e.title.substring(18), 10) % 2 === 1, // select all ToDos with odd suffix 72 | { done: true } // set them done 73 | ) 74 | ); 75 | } 76 | 77 | updateDescription() { 78 | this.store.dispatch( 79 | new Update( 80 | TodoState, 81 | e => e.done, // select all done ToDos 82 | e => { 83 | // custom update function: Update their description 84 | return { ...e, description: e.description + ' -- This is done!' }; 85 | } 86 | ) 87 | ); 88 | } 89 | 90 | closeDetails() { 91 | this.store.dispatch(new ClearActive(TodoState)); 92 | } 93 | 94 | toggleError() { 95 | this.error = !this.error; 96 | this.store.dispatch( 97 | new SetError(TodoState, this.error ? new Error('Example error') : undefined) 98 | ); 99 | } 100 | 101 | setDoneActive() { 102 | this.store.dispatch( 103 | new UpdateActive(TodoState, { 104 | done: true 105 | }) 106 | ); 107 | } 108 | 109 | open(title: string) { 110 | this.store.dispatch(new SetActive(TodoState, title)); 111 | } 112 | 113 | removeFirstThree(toDos: ToDo[]) { 114 | this.removeMultiple(toDos.slice(0, 3).map(t => t.title)); 115 | } 116 | 117 | removeMultiple(titles: string[]) { 118 | this.store.dispatch(new Remove(TodoState, titles)); 119 | } 120 | 121 | clearEntities() { 122 | this.store.dispatch(new RemoveAll(TodoState)); 123 | } 124 | 125 | addToDo() { 126 | this.store.dispatch( 127 | new Add(TodoState, { 128 | title: 'NGXS Entity Store ' + ++this.counter, 129 | description: 'Some Descr' + this.counter, 130 | done: false 131 | }) 132 | ); 133 | } 134 | 135 | doneAll() { 136 | this.store.dispatch(new UpdateAll(TodoState, { done: true })); 137 | } 138 | 139 | // --------- for tests --------- 140 | 141 | createOrReplace(title: string) { 142 | this.store.dispatch( 143 | new CreateOrReplace(TodoState, { 144 | title, 145 | description: 'Some Descr' + title, 146 | done: false 147 | }) 148 | ); 149 | } 150 | 151 | resetState() { 152 | this.store.dispatch(new Reset(TodoState)); 153 | } 154 | 155 | updateMultiple() { 156 | this.store.dispatch( 157 | new Update(TodoState, ['NGXS Entity Store 1', 'NGXS Entity Store 2'], { done: true }) 158 | ); 159 | } 160 | 161 | addMultiple() { 162 | this.store.dispatch( 163 | new Add(TodoState, [ 164 | { 165 | title: 'NGXS Entity Store 1', 166 | description: 'Some Descr 1', 167 | done: false 168 | }, 169 | { 170 | title: 'NGXS Entity Store 2', 171 | description: 'Some Descr 2', 172 | done: false 173 | } 174 | ]) 175 | ); 176 | } 177 | 178 | updateActiveWithFn(): Observable { 179 | return this.store.dispatch( 180 | new UpdateActive(TodoState, e => ({ 181 | ...e, 182 | description: e.description + ' -- Updated with Fn' 183 | })) 184 | ); 185 | } 186 | 187 | removeActive() { 188 | this.store.dispatch(new RemoveActive(TodoState)); 189 | } 190 | 191 | getPaginatedEntities(size: number, page: number): Observable { 192 | return this.store 193 | .dispatch([new SetPageSize(TodoState, size), new GoToPage(TodoState, { page })]) 194 | .pipe(mergeMap(() => this.store.selectOnce(TodoState.paginatedEntities))); 195 | } 196 | 197 | getNthEntity(index: number): ToDo { 198 | return this.store.selectSnapshot(TodoState.nthEntity(index)); 199 | } 200 | 201 | addWithError() { 202 | this.store.dispatch( 203 | new Add(TodoState, [ 204 | { 205 | title: 'NGXS Entity Store 1', 206 | description: 'Some Descr 1', 207 | done: false 208 | }, 209 | { 210 | title: 'NGXS Entity Store 1', 211 | description: 'Some Descr 1', 212 | done: false 213 | } 214 | ]) 215 | ); 216 | } 217 | 218 | updateAnother(callback: (...args: any[]) => void) { 219 | this.store.select(TodoState.entities).subscribe(x => callback(x)); 220 | this.store.dispatch(new UpdateAnotherState()); 221 | this.store.dispatch(new UpdateAnotherState()); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /integration/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { StoreModule } from './store'; 6 | 7 | @NgModule({ 8 | declarations: [AppComponent], 9 | imports: [BrowserModule, StoreModule], 10 | providers: [], 11 | bootstrap: [AppComponent] 12 | }) 13 | export class AppModule {} 14 | -------------------------------------------------------------------------------- /integration/app/store/index.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { NgxsModule } from '@ngxs/store'; 3 | import { TodoState, AnotherState } from './todo'; 4 | import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin'; 5 | 6 | const states = [TodoState, AnotherState]; 7 | 8 | @NgModule({ 9 | imports: [ 10 | NgxsModule.forRoot(states, { developmentMode: true }), 11 | NgxsLoggerPluginModule.forRoot() 12 | ], 13 | exports: [NgxsModule], 14 | declarations: [], 15 | providers: [] 16 | }) 17 | export class StoreModule {} 18 | -------------------------------------------------------------------------------- /integration/app/store/todo/another-store.ts: -------------------------------------------------------------------------------- 1 | import { State, StateContext, Action } from '@ngxs/store'; 2 | 3 | export class UpdateAnotherState { 4 | static type = 'UpdateAnotherState'; 5 | } 6 | 7 | @State({ 8 | name: 'another', 9 | defaults: 0 10 | }) 11 | export class AnotherState { 12 | @Action(UpdateAnotherState) 13 | update(ctx: StateContext) { 14 | ctx.setState(Math.random()) ; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /integration/app/store/todo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './store'; 2 | export * from './another-store'; 3 | -------------------------------------------------------------------------------- /integration/app/store/todo/store.ts: -------------------------------------------------------------------------------- 1 | import { State } from '@ngxs/store'; 2 | import { 3 | defaultEntityState, 4 | EntityStateModel, 5 | EntityState, 6 | IdStrategy 7 | } from '@ngxs-labs/entity-state'; 8 | 9 | export interface ToDo { 10 | title: string; 11 | description: string; 12 | done: boolean; 13 | } 14 | 15 | @State>({ 16 | name: 'todo', 17 | defaults: defaultEntityState() 18 | }) 19 | export class TodoState extends EntityState { 20 | constructor() { 21 | super(TodoState, 'title', IdStrategy.EntityIdGenerator); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /integration/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngxs-labs/entity-state/fca4283701d67fc1bdb24929beab036250f2f083/integration/assets/.gitkeep -------------------------------------------------------------------------------- /integration/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /integration/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /integration/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngxs-labs/entity-state/fca4283701d67fc1bdb24929beab036250f2f083/integration/favicon.ico -------------------------------------------------------------------------------- /integration/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | EntityStateIntegration 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /integration/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /integration/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | // import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | // import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | // import 'core-js/es6/array'; 32 | // import 'core-js/es6/regexp'; 33 | // import 'core-js/es6/map'; 34 | // import 'core-js/es6/weak-map'; 35 | // import 'core-js/es6/set'; 36 | 37 | /** 38 | * If the application will be indexed by Google Search, the following is required. 39 | * Googlebot uses a renderer based on Chrome 41. 40 | * https://developers.google.com/search/docs/guides/rendering 41 | **/ 42 | // import 'core-js/es6/array'; 43 | 44 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 45 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 46 | 47 | /** IE10 and IE11 requires the following for the Reflect API. */ 48 | // import 'core-js/es6/reflect'; 49 | 50 | /** 51 | * Web Animations `@angular/platform-browser/animations` 52 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 53 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 54 | **/ 55 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 56 | 57 | /** 58 | * By default, zone.js will patch all possible macroTask and DomEvents 59 | * user can disable parts of macroTask/DomEvents patch by setting following flags 60 | */ 61 | 62 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 63 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 64 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 65 | 66 | /* 67 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 68 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 69 | */ 70 | // (window as any).__Zone_enable_cross_context_check = true; 71 | 72 | /*************************************************************************************************** 73 | * Zone JS is required by default for Angular itself. 74 | */ 75 | import 'zone.js/dist/zone'; // Included with Angular CLI. 76 | 77 | /*************************************************************************************************** 78 | * APPLICATION IMPORTS 79 | */ 80 | -------------------------------------------------------------------------------- /integration/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /integration/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | import 'zone.js/dist/zone-testing'; 3 | 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); 14 | 15 | // Then we find all the tests. 16 | const context = require.context('./', true, /\.spec\.ts$/); 17 | 18 | // And load the modules. 19 | context.keys().map(context); 20 | -------------------------------------------------------------------------------- /integration/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "files": [ 4 | "main.ts", 5 | "polyfills.ts" 6 | ], 7 | "include": [ 8 | "**/*.d.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /integration/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "jasmine", 6 | "node" 7 | ], 8 | "paths": { 9 | "@ngxs-labs/entity-state": [ 10 | "src" 11 | ], 12 | "@ngxs-labs/entity-state/*": [ 13 | "src/*" 14 | ] 15 | } 16 | }, 17 | "files": [ 18 | "test.ts", 19 | "polyfills.ts" 20 | ], 21 | "include": [ 22 | "**/*.spec.ts", 23 | "**/*.d.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /integration/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | const CI = process.env['CI'] === 'true'; 5 | 6 | module.exports = function(config) { 7 | config.set({ 8 | basePath: '', 9 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 10 | plugins: [ 11 | require('karma-jasmine'), 12 | require('karma-chrome-launcher'), 13 | require('karma-jasmine-html-reporter'), 14 | require('karma-coverage-istanbul-reporter'), 15 | require('@angular-devkit/build-angular/plugins/karma') 16 | ], 17 | client: { 18 | clearContext: false // leave Jasmine Spec Runner output visible in browser 19 | }, 20 | coverageIstanbulReporter: { 21 | dir: require('path').join(__dirname, 'coverage'), 22 | reports: ['html', 'lcovonly'], 23 | fixWebpackSourcePaths: true 24 | }, 25 | angularCli: { 26 | environment: 'production' 27 | }, 28 | reporters: ['progress', 'kjhtml'], 29 | port: 9876, 30 | colors: true, 31 | logLevel: config.LOG_INFO, 32 | browserConsoleLogOptions: { 33 | level: CI ? 'error' : 'debug', 34 | format: '%b %T: %m', 35 | terminal: true 36 | }, 37 | autoWatch: !CI, 38 | browsers: ['Chrome'], 39 | singleRun: CI 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "entity-state", 3 | "version": "0.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/ngxs-labs/entity-state.git" 7 | }, 8 | "license": "MIT", 9 | "homepage": "https://github.com/ngxs-labs/entity-state#readme", 10 | "bugs": { 11 | "url": "https://github.com/ngxs-labs/entity-state/issues" 12 | }, 13 | "keywords": [ 14 | "ngxs", 15 | "redux", 16 | "store", 17 | "entity", 18 | "state" 19 | ], 20 | "scripts": { 21 | "ng": "ng", 22 | "lint": "ng lint", 23 | "serve": "ng serve integration", 24 | "build": "ts-node -O '{\"module\":\"commonjs\"}' ./tools/build", 25 | "build:integration": "ng build integration", 26 | "test": "ng test entity-state", 27 | "test:integration": "ng test integration", 28 | "format": "prettier --write \"{src,integration}/**/*.{ts,js,html,scss}\"", 29 | "// - CI": "CI pipelines", 30 | "entity:sonar": "yarn sonar", 31 | "entity:lint": "yarn lint", 32 | "entity:test": "yarn test", 33 | "entity:integration": "yarn test:integration && yarn build:integration --prod", 34 | "entity:build": "yarn build && yarn readme", 35 | "entity:rollup": "yarn rollup", 36 | "ci:pipelines": "cross-env CI=true npm-run-all entity:*", 37 | "// - Utils": "Utilities", 38 | "sonar": "ts-node ./tools/sonar", 39 | "readme": "ts-node ./tools/copy-readme", 40 | "rollup": "rollup -c rollup.config.js", 41 | "bump": "ts-node ./tools/bump" 42 | }, 43 | "private": true, 44 | "dependencies": { 45 | "@angular/animations": "10.2.3", 46 | "@angular/common": "10.2.3", 47 | "@angular/compiler": "10.2.3", 48 | "@angular/core": "10.2.3", 49 | "@angular/forms": "10.2.3", 50 | "@angular/platform-browser": "10.2.3", 51 | "@angular/platform-browser-dynamic": "10.2.3", 52 | "@angular/router": "10.2.3", 53 | "@ngxs/logger-plugin": "3.7.0", 54 | "@ngxs/store": "3.7.0", 55 | "core-js": "2.6.3", 56 | "rxjs": "6.6.2", 57 | "tslib": "^2.1.0", 58 | "zone.js": "~0.11.3" 59 | }, 60 | "devDependencies": { 61 | "@angular-devkit/build-angular": "~0.1002.0", 62 | "@angular-devkit/build-ng-packagr": "~0.1002.0", 63 | "@angular/cli": "10.2.0", 64 | "@angular/compiler-cli": "10.2.3", 65 | "@angular/language-service": "10.2.3", 66 | "@commitlint/cli": "^11.0.0", 67 | "@commitlint/config-conventional": "^11.0.0", 68 | "@types/jasmine": "3.6.2", 69 | "@types/jasminewd2": "2.0.8", 70 | "@types/node": "^14.11.8", 71 | "codelyzer": "^6.0.1", 72 | "cross-env": "^7.0.3", 73 | "husky": "^4.3.8", 74 | "jasmine-core": "~3.6.0", 75 | "jasmine-spec-reporter": "~5.0.0", 76 | "karma": "~5.2.3", 77 | "karma-chrome-launcher": "~3.1.0", 78 | "karma-coverage-istanbul-reporter": "~3.0.3", 79 | "karma-jasmine": "~3.3.0", 80 | "karma-jasmine-html-reporter": "^1.5.4", 81 | "lint-staged": "^10.5.3", 82 | "ng-packagr": "^10.1.2", 83 | "npm-run-all": "4.1.5", 84 | "prettier": "^2.2.1", 85 | "protractor": "~7.0.0", 86 | "rollup": "^2.38.0", 87 | "rollup-plugin-node-resolve": "^5.2.0", 88 | "rollup-plugin-sourcemaps": "^0.6.3", 89 | "sonarjs": "1.0.0", 90 | "ts-node": "^8.10.2", 91 | "tslint": "~6.1.3", 92 | "typescript": "3.9.7" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "rangeStrategy": "bump", 3 | "separateMajorMinor": false, 4 | "automerge": false, 5 | "timezone": "UTC", 6 | "schedule": [ 7 | "after 10pm every weekday", 8 | "before 4am every weekday", 9 | "every weekend" 10 | ], 11 | "baseBranches": [ 12 | "master" 13 | ], 14 | "ignoreDeps": [ 15 | "@types/node" 16 | ], 17 | "packageFiles": [ 18 | "package.json" 19 | ], 20 | "major": { 21 | "devDependencies": { 22 | "enabled": false 23 | } 24 | }, 25 | "packageRules": [ 26 | { 27 | "depTypeList": ["dependencies"], 28 | "packagePatterns": [ 29 | "^@angular\/.*" 30 | ], 31 | "groupName": "@angular" 32 | }, 33 | { 34 | "depTypeList": ["devDependencies"], 35 | "packagePatterns": [ 36 | "^@angular.*" 37 | ], 38 | "groupName": "@angular-dev" 39 | }, 40 | { 41 | "packageNames": [ 42 | "typescript" 43 | ], 44 | "updateTypes": "patch" 45 | }, 46 | { 47 | "packageNames": [ 48 | "^@ngxs\/.*" 49 | ], 50 | "groupName": "@ngxs" 51 | }, 52 | { 53 | "packageNames": [ 54 | "^karma.*" 55 | ], 56 | "groupName": "karma" 57 | }, 58 | { 59 | "packageNames": [ 60 | "^@commitlint\/.*" 61 | ], 62 | "groupName": "@commitlint" 63 | }, 64 | { 65 | "packageNames": [ 66 | "^@types\/.*" 67 | ], 68 | "groupName": "@types" 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | 3 | const resolve = require('rollup-plugin-node-resolve'); 4 | const sourcemaps = require('rollup-plugin-sourcemaps'); 5 | 6 | const globals = { 7 | '@angular/core': 'ng.core', 8 | rxjs: 'rxjs', 9 | 'rxjs/operators': 'rxjs.operators', 10 | '@ngxs/store': 'ngxs.store' 11 | }; 12 | 13 | const input = join(__dirname, 'dist/entity-state/fesm2015/ngxs-labs-entity-state.js'); 14 | const output = { 15 | file: join(__dirname, 'dist/entity-state/bundles/ngxs-labs-entity-state.umd.js'), 16 | name: 'ngxs-labs.entity-state', 17 | globals, 18 | format: 'umd', 19 | exports: 'named' 20 | }; 21 | 22 | module.exports = { 23 | input, 24 | output, 25 | plugins: [resolve(), sourcemaps()], 26 | external: Object.keys(globals) 27 | }; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './public_api'; 2 | -------------------------------------------------------------------------------- /src/lib/action-handlers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ofAction, 3 | ofActionCompleted, 4 | ofActionDispatched, 5 | ofActionErrored, 6 | ofActionSuccessful 7 | } from '@ngxs/store'; 8 | import { NGXS_META_KEY } from './internal'; 9 | import { EntityState } from './entity-state'; 10 | import { Type } from '@angular/core'; 11 | import { EntityActionType } from './actions/type-alias'; 12 | 13 | export const ofEntityAction = ( 14 | state: Type>, 15 | actionType: EntityActionType 16 | ) => { 17 | const statePath = state[NGXS_META_KEY].path; 18 | const type = `[${statePath}] ${actionType}`; 19 | return ofAction({ 20 | type: type 21 | }); 22 | }; 23 | 24 | export const ofEntityActionDispatched = ( 25 | state: Type>, 26 | actionType: EntityActionType 27 | ) => { 28 | const statePath = state[NGXS_META_KEY].path; 29 | const type = `[${statePath}] ${actionType}`; 30 | return ofActionDispatched({ 31 | type: type 32 | }); 33 | }; 34 | 35 | export const ofEntityActionSuccessful = ( 36 | state: Type>, 37 | actionType: EntityActionType 38 | ) => { 39 | const statePath = state[NGXS_META_KEY].path; 40 | const type = `[${statePath}] ${actionType}`; 41 | return ofActionSuccessful({ 42 | type: type 43 | }); 44 | }; 45 | 46 | export const ofEntityActionErrored = ( 47 | state: Type>, 48 | actionType: EntityActionType 49 | ) => { 50 | const statePath = state[NGXS_META_KEY].path; 51 | const type = `[${statePath}] ${actionType}`; 52 | return ofActionErrored({ 53 | type: type 54 | }); 55 | }; 56 | 57 | export const ofEntityActionCompleted = ( 58 | state: Type>, 59 | actionType: EntityActionType 60 | ) => { 61 | const statePath = state[NGXS_META_KEY].path; 62 | const type = `[${statePath}] ${actionType}`; 63 | return ofActionCompleted({ 64 | type: type 65 | }); 66 | }; 67 | 68 | // there are no cancelable actions, thus there is no need for a ofEntityActionCanceled action handler 69 | -------------------------------------------------------------------------------- /src/lib/actions/active.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityActionType, Payload, Updater } from './type-alias'; 3 | import { EntityState } from '../entity-state'; 4 | import { Type } from '@angular/core'; 5 | 6 | export type EntitySetActiveAction = Payload; 7 | export type EntityUpdateActiveAction = Payload>; 8 | 9 | export class SetActive { 10 | /** 11 | * Generates an action that sets an ID that identifies the active entity 12 | * @param target The targeted state class 13 | * @param id The ID that identifies the active entity 14 | */ 15 | constructor(target: Type>, id: string) { 16 | return generateActionObject(EntityActionType.SetActive, target, id); 17 | } 18 | } 19 | 20 | export class ClearActive { 21 | /** 22 | * Generates an action that clears the active entity in the given state 23 | * @param target The targeted state class 24 | */ 25 | constructor(target: Type>) { 26 | return generateActionObject(EntityActionType.ClearActive, target); 27 | } 28 | } 29 | 30 | export class RemoveActive { 31 | /** 32 | * Generates an action that removes the active entity from the state and clears the active ID. 33 | * @param target The targeted state class 34 | */ 35 | constructor(target: Type>) { 36 | return generateActionObject(EntityActionType.RemoveActive, target); 37 | } 38 | } 39 | 40 | export class UpdateActive { 41 | /** 42 | * Generates an action that will update the current active entity. 43 | * If no entity is active a runtime error will be thrown. 44 | * @param target The targeted state class 45 | * @param payload An Updater payload 46 | * @see Updater 47 | */ 48 | constructor(target: Type>, payload: Updater) { 49 | return generateActionObject(EntityActionType.UpdateActive, target, payload); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/actions/add.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityActionType, Payload } from './type-alias'; 3 | import { EntityState } from '../entity-state'; 4 | import { Type } from '@angular/core'; 5 | 6 | export type EntityAddAction = Payload; 7 | 8 | export class Add { 9 | /** 10 | * Generates an action that will add the given entities to the state. 11 | * The entities given by the payload will be added. 12 | * For certain ID strategies this might fail, if it provides an existing ID. 13 | * In all other cases it will overwrite the ID value in the entity with the calculated ID. 14 | * @param target The targeted state class 15 | * @param payload An entity or an array of entities to be added 16 | * @see CreateOrReplace#constructor 17 | */ 18 | constructor(target: Type>, payload: T | T[]) { 19 | return generateActionObject(EntityActionType.Add, target, payload); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/actions/create-or-replace.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { EntityState } from '../entity-state'; 3 | import { generateActionObject } from '../internal'; 4 | import { EntityActionType, Payload } from './type-alias'; 5 | 6 | export type EntityCreateOrReplaceAction = Payload; 7 | 8 | export class CreateOrReplace { 9 | /** 10 | * Generates an action that will add the given entities to the state. 11 | * If an entity with the ID already exists, it will be overridden. 12 | * In all cases it will overwrite the ID value in the entity with the calculated ID. 13 | * @param target The targeted state class 14 | * @param payload An entity or an array of entities to be added 15 | * @see Add#constructor 16 | */ 17 | constructor(target: Type>, payload: T | T[]) { 18 | return generateActionObject(EntityActionType.CreateOrReplace, target, payload); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/actions/error.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityState } from '../entity-state'; 3 | import { Type } from '@angular/core'; 4 | import { EntityActionType } from './type-alias'; 5 | 6 | export interface EntitySetErrorAction { 7 | payload: Error; 8 | } 9 | 10 | export class SetError { 11 | /** 12 | * Generates an action that will set the error state for the given state. 13 | * Put undefined to clear the error state. 14 | * @param target The targeted state class 15 | * @param error The error that describes the error state 16 | */ 17 | constructor(target: Type>, error: Error | undefined) { 18 | return generateActionObject(EntityActionType.SetError, target, error); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './add'; 2 | export * from './create-or-replace'; 3 | export * from './remove'; 4 | export * from './update'; 5 | export * from './loading'; 6 | export * from './active'; 7 | export * from './error'; 8 | export * from './reset'; 9 | export * from './type-alias'; 10 | export * from './pagination'; 11 | -------------------------------------------------------------------------------- /src/lib/actions/loading.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityState } from '../entity-state'; 3 | import { Type } from '@angular/core'; 4 | import { EntityActionType } from './type-alias'; 5 | 6 | export interface EntitySetLoadingAction { 7 | payload: boolean; 8 | } 9 | 10 | export class SetLoading { 11 | /** 12 | * Generates an action that will set the loading state for the given state. 13 | * @param target The targeted state class 14 | * @param loading The loading state 15 | */ 16 | constructor(target: Type>, loading: boolean) { 17 | return generateActionObject(EntityActionType.SetLoading, target, loading); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/actions/pagination.ts: -------------------------------------------------------------------------------- 1 | import { EntityActionType, Payload } from './type-alias'; 2 | import { Type } from '@angular/core'; 3 | import { EntityState } from '../entity-state'; 4 | import { generateActionObject } from '../internal'; 5 | 6 | export type GoToPagePayload = 7 | | { page: number } 8 | | { next: true; wrap?: boolean } 9 | | { prev: true; wrap?: boolean } 10 | | { last: true } 11 | | { first: true }; 12 | export type EntityGoToPageAction = Payload; 13 | 14 | export class GoToPage { 15 | /** 16 | * Generates an action that changes the page index for pagination. 17 | * Page index starts at 0. 18 | * @param target The targeted state class 19 | * @param payload Payload to change the page index 20 | */ 21 | constructor(target: Type>, payload: GoToPagePayload) { 22 | return generateActionObject(EntityActionType.GoToPage, target, { 23 | wrap: false, 24 | ...payload 25 | }); 26 | } 27 | } 28 | 29 | export type EntitySetPageSizeAction = Payload; 30 | 31 | export class SetPageSize { 32 | /** 33 | * Generates an action that changes the page size 34 | * @param target The targeted state class 35 | * @param payload The page size 36 | */ 37 | constructor(target: Type>, payload: number) { 38 | return generateActionObject(EntityActionType.SetPageSize, target, payload); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/actions/remove.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityActionType, EntitySelector, Payload } from './type-alias'; 3 | import { EntityState } from '../entity-state'; 4 | import { Type } from '@angular/core'; 5 | 6 | export type EntityRemoveAction = Payload>; 7 | 8 | export class Remove { 9 | /** 10 | * Generates an action that will remove the given entities from the state. 11 | * @param target The targeted state class 12 | * @param payload An EntitySelector payload 13 | * @see EntitySelector 14 | * @see RemoveAll 15 | */ 16 | constructor(target: Type>, payload: EntitySelector) { 17 | return generateActionObject(EntityActionType.Remove, target, payload); 18 | } 19 | } 20 | 21 | export class RemoveAll { 22 | /** 23 | * Generates an action that will remove all entities from the state. 24 | * @param target The targeted state class 25 | */ 26 | constructor(target: Type>) { 27 | return generateActionObject(EntityActionType.RemoveAll, target); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/actions/reset.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityState } from '../entity-state'; 3 | import { Type } from '@angular/core'; 4 | import { EntityActionType } from './type-alias'; 5 | 6 | export class Reset { 7 | /** 8 | * Resets the targeted store to the default state: no entities, loading is false, error is undefined, active is undefined. 9 | * @param target The targeted state class 10 | * @see defaultEntityState 11 | */ 12 | constructor(target: Type>) { 13 | return generateActionObject(EntityActionType.Reset, target); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/actions/type-alias.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An EntitySelector determines which entities will be affected. 3 | * Can be one of the following: 4 | * - a single ID in form of a string 5 | * - multiple IDs in form of an array of strings 6 | * - a predicate function that returns `true` for entities to be selected 7 | */ 8 | export type EntitySelector = string | string[] | ((entity: T) => boolean); 9 | 10 | /** 11 | * An Updater will be applied to the current entity, before onUpdate is run with its result. 12 | * Can be one of the following: 13 | * - a partial object of an entity 14 | * - a function that takes the current entity and returns a partially updated entity 15 | * @see EntityState#onUpdate 16 | */ 17 | export type Updater = Partial | ((entity: Readonly) => Partial); 18 | 19 | /** 20 | * Interface for an object that has a payload field of type T 21 | */ 22 | export interface Payload { 23 | payload: T; 24 | } 25 | 26 | /** 27 | * Enum that contains all existing Actions for the Entity State adapter. 28 | */ 29 | export enum EntityActionType { 30 | Add = 'add', 31 | CreateOrReplace = 'createOrReplace', 32 | Update = 'update', 33 | UpdateAll = 'updateAll', 34 | UpdateActive = 'updateActive', 35 | Remove = 'remove', 36 | RemoveAll = 'removeAll', 37 | RemoveActive = 'removeActive', 38 | SetLoading = 'setLoading', 39 | SetError = 'setError', 40 | SetActive = 'setActive', 41 | ClearActive = 'clearActive', 42 | Reset = 'reset', 43 | GoToPage = 'goToPage', 44 | SetPageSize = 'setPageSize' 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/actions/update.ts: -------------------------------------------------------------------------------- 1 | import { generateActionObject } from '../internal'; 2 | import { EntityActionType, EntitySelector, Updater } from './type-alias'; 3 | import { EntityState } from '../entity-state'; 4 | import { Type } from '@angular/core'; 5 | 6 | export interface EntityUpdate { 7 | selector?: EntitySelector; 8 | data: Updater; 9 | } 10 | 11 | export interface EntityUpdateAction { 12 | payload: EntityUpdate; 13 | } 14 | 15 | export class Update { 16 | /** 17 | * Generates an action that will update all entities, specified by the given selector. 18 | * @param target The targeted state class 19 | * @param selector An EntitySelector that determines the entities to update 20 | * @param data An Updater that will be applied to the selected entities 21 | * @see EntitySelector 22 | * @see Updater 23 | */ 24 | constructor(target: Type>, selector: EntitySelector, data: Updater) { 25 | return generateActionObject(EntityActionType.Update, target, { 26 | selector, 27 | data 28 | } as EntityUpdate); 29 | } 30 | } 31 | 32 | export class UpdateAll { 33 | /** 34 | * Generates an action that will update all entities. 35 | * If no entity is active a runtime error will be thrown. 36 | * @param target The targeted state class 37 | * @param data An Updater that will be applied to all entities 38 | * @see EntitySelector 39 | * @see Updater 40 | */ 41 | constructor(target: Type>, data: Updater) { 42 | return generateActionObject(EntityActionType.UpdateAll, target, { 43 | data 44 | } as EntityUpdate); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/entity-state.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { StateContext, createSelector } from '@ngxs/store'; 3 | import { 4 | EntityActionType, 5 | EntityAddAction, 6 | EntityCreateOrReplaceAction, 7 | EntityGoToPageAction, 8 | EntityRemoveAction, 9 | EntitySetActiveAction, 10 | EntitySetErrorAction, 11 | EntitySetLoadingAction, 12 | EntitySetPageSizeAction, 13 | EntityUpdateAction, 14 | EntityUpdateActiveAction 15 | } from './actions'; 16 | import { InvalidEntitySelectorError } from './errors'; 17 | import { IdStrategy } from './id-strategy'; 18 | import { asArray, Dictionary, elvis, getActive, NGXS_META_KEY, wrapOrClamp } from './internal'; 19 | import { EntityStateModel, StateSelector } from './models'; 20 | import { 21 | addOrReplace, 22 | removeAllEntities, 23 | removeEntities, 24 | update, 25 | updateActive 26 | } from './state-operators'; 27 | import IdGenerator = IdStrategy.IdGenerator; 28 | 29 | /** 30 | * Returns a new object which serves as the default state. 31 | * No entities, loading is false, error is undefined, active is undefined. 32 | * pageSize is 10 and pageIndex is 0. 33 | */ 34 | export function defaultEntityState( 35 | defaults: Partial> = {} 36 | ): EntityStateModel { 37 | return { 38 | entities: {}, 39 | ids: [], 40 | loading: false, 41 | error: undefined, 42 | active: undefined, 43 | pageSize: 10, 44 | pageIndex: 0, 45 | lastUpdated: Date.now(), 46 | ...defaults 47 | }; 48 | } 49 | 50 | // tslint:disable:member-ordering 51 | 52 | // @dynamic 53 | export abstract class EntityState { 54 | private readonly idKey: string; 55 | private readonly storePath: string; 56 | protected readonly idGenerator: IdGenerator; 57 | 58 | protected constructor( 59 | storeClass: Type>, 60 | _idKey: keyof T, 61 | idStrategy: Type> 62 | ) { 63 | this.idKey = _idKey as string; 64 | this.storePath = storeClass[NGXS_META_KEY].path; 65 | this.idGenerator = new idStrategy(_idKey); 66 | 67 | this.setup(storeClass, Object.values(EntityActionType)); 68 | } 69 | 70 | /** 71 | * This function is called every time an entity is updated. 72 | * It receives the current entity and a partial entity that was either passed directly or generated with a function. 73 | * The default implementation uses the spread operator to create a new entity. 74 | * You must override this method if your entity type does not support the spread operator. 75 | * @see Updater 76 | * @param current The current entity, readonly 77 | * @param updated The new data as a partial entity 78 | * @example 79 | * // default behavior 80 | * onUpdate(current: Readonly): T { 81 | return {...current, ...updated}; 82 | } 83 | */ 84 | onUpdate(current: Readonly, updated: Partial): T { 85 | return { ...current, ...updated } as T; 86 | } 87 | 88 | // ------------------- SELECTORS ------------------- 89 | 90 | /** 91 | * Returns a selector for the activeId 92 | */ 93 | static get activeId(): StateSelector { 94 | return createSelector([this], state => state.active); 95 | } 96 | 97 | /** 98 | * Returns a selector for the active entity 99 | */ 100 | static get active(): StateSelector { 101 | return createSelector([this], state => getActive(state)); 102 | } 103 | 104 | /** 105 | * Returns a selector for the keys of all entities 106 | */ 107 | static get keys(): StateSelector { 108 | return createSelector([this], state => { 109 | return Object.keys(state.entities); 110 | }); 111 | } 112 | 113 | /** 114 | * Returns a selector for all entities, sorted by insertion order 115 | */ 116 | static get entities(): StateSelector { 117 | return createSelector([this], state => { 118 | return state.ids.map(id => state.entities[id]); 119 | }); 120 | } 121 | 122 | /** 123 | * Returns a selector for the nth entity, sorted by insertion order 124 | */ 125 | static nthEntity(index: number): StateSelector { 126 | return createSelector([this], state => { 127 | const id = state.ids[index]; 128 | return state.entities[id]; 129 | }); 130 | } 131 | 132 | /** 133 | * Returns a selector for paginated entities, sorted by insertion order 134 | */ 135 | static get paginatedEntities(): StateSelector { 136 | return createSelector([this], state => { 137 | const { ids, pageIndex, pageSize } = state; 138 | return ids 139 | .slice(pageIndex * pageSize, (pageIndex + 1) * pageSize) 140 | .map(id => state.entities[id]); 141 | }); 142 | } 143 | 144 | /** 145 | * Returns a selector for the map of entities 146 | */ 147 | static get entitiesMap(): StateSelector> { 148 | return createSelector([this], state => { 149 | return state.entities; 150 | }); 151 | } 152 | 153 | /** 154 | * Returns a selector for the size of the entity map 155 | */ 156 | static get size(): StateSelector { 157 | return createSelector([this], state => { 158 | return Object.keys(state.entities).length; 159 | }); 160 | } 161 | 162 | /** 163 | * Returns a selector for the error 164 | */ 165 | static get error(): StateSelector { 166 | return createSelector([this], state => { 167 | return state.error; 168 | }); 169 | } 170 | 171 | /** 172 | * Returns a selector for the loading state 173 | */ 174 | static get loading(): StateSelector { 175 | return createSelector([this], state => { 176 | return state.loading; 177 | }); 178 | } 179 | 180 | /** 181 | * Returns a selector for the latest added entity 182 | */ 183 | static get latest(): StateSelector { 184 | return createSelector([this], state => { 185 | const latestId = state.ids[state.ids.length - 1]; 186 | return state.entities[latestId]; 187 | }); 188 | } 189 | 190 | /** 191 | * Returns a selector for the latest added entity id 192 | */ 193 | static get latestId(): StateSelector { 194 | return createSelector([this], state => { 195 | return state.ids[state.ids.length - 1]; 196 | }); 197 | } 198 | 199 | /** 200 | * Returns a selector for the update timestamp 201 | */ 202 | static get lastUpdated(): StateSelector { 203 | return createSelector([this], state => { 204 | return new Date(state.lastUpdated); 205 | }); 206 | } 207 | 208 | /** 209 | * Returns a selector for age, based on the update timestamp 210 | */ 211 | static get age(): StateSelector { 212 | return createSelector([this], state => { 213 | return Date.now() - state.lastUpdated; 214 | }); 215 | } 216 | 217 | // ------------------- ACTION HANDLERS ------------------- 218 | 219 | /** 220 | * The entities given by the payload will be added. 221 | * For certain ID strategies this might fail, if it provides an existing ID. 222 | * In all cases it will overwrite the ID value in the entity with the calculated ID. 223 | */ 224 | add({ setState }: StateContext>, { payload }: EntityAddAction) { 225 | setState( 226 | addOrReplace(payload, this.idKey, (entity, state) => 227 | this.idGenerator.generateId(entity, state) 228 | ) 229 | ); 230 | } 231 | 232 | /** 233 | * The entities given by the payload will be added. 234 | * It first checks if the ID provided by each entity does exist. 235 | * If it does the current entity will be replaced. 236 | * In all cases it will overwrite the ID value in the entity with the calculated ID. 237 | */ 238 | createOrReplace( 239 | { setState }: StateContext>, 240 | { payload }: EntityCreateOrReplaceAction 241 | ) { 242 | setState( 243 | addOrReplace(payload, this.idKey, (entity, state) => 244 | this.idGenerator.getPresentIdOrGenerate(entity, state) 245 | ) 246 | ); 247 | } 248 | 249 | update({ setState }: StateContext>, { payload }: EntityUpdateAction) { 250 | if (payload.selector == null) { 251 | throw new InvalidEntitySelectorError(payload); 252 | } 253 | setState( 254 | update(payload, this.idKey, (current, updated) => this.onUpdate(current, updated)) 255 | ); 256 | } 257 | 258 | updateAll( 259 | { setState }: StateContext>, 260 | { payload }: EntityUpdateAction 261 | ) { 262 | setState( 263 | update({ ...payload, selector: null }, this.idKey, (current, updated) => 264 | this.onUpdate(current, updated) 265 | ) 266 | ); 267 | } 268 | 269 | updateActive( 270 | { setState }: StateContext>, 271 | { payload }: EntityUpdateActiveAction 272 | ) { 273 | setState( 274 | updateActive(payload, this.idKey, (current, updated) => this.onUpdate(current, updated)) 275 | ); 276 | } 277 | 278 | removeActive({ getState, setState }: StateContext>) { 279 | const { active } = getState(); 280 | setState(removeEntities([active])); 281 | } 282 | 283 | remove( 284 | { getState, setState, patchState }: StateContext>, 285 | { payload }: EntityRemoveAction 286 | ) { 287 | if (payload === null) { 288 | throw new InvalidEntitySelectorError(payload); 289 | } else { 290 | const deleteIds: string[] = 291 | typeof payload === 'function' 292 | ? Object.values(getState().entities) 293 | .filter(entity => payload(entity)) 294 | .map(entity => this.idOf(entity)) 295 | : asArray(payload); 296 | // can't pass in predicate as you need IDs and thus EntityState#idOf 297 | setState(removeEntities(deleteIds)); 298 | } 299 | } 300 | 301 | removeAll({ setState }: StateContext>) { 302 | setState(removeAllEntities()); 303 | } 304 | 305 | reset({ setState }: StateContext>) { 306 | setState(defaultEntityState()); 307 | } 308 | 309 | setLoading( 310 | { patchState }: StateContext>, 311 | { payload }: EntitySetLoadingAction 312 | ) { 313 | patchState({ loading: payload }); 314 | } 315 | 316 | setActive( 317 | { patchState }: StateContext>, 318 | { payload }: EntitySetActiveAction 319 | ) { 320 | patchState({ active: payload }); 321 | } 322 | 323 | clearActive({ patchState }: StateContext>) { 324 | patchState({ active: undefined }); 325 | } 326 | 327 | setError( 328 | { patchState }: StateContext>, 329 | { payload }: EntitySetErrorAction 330 | ) { 331 | patchState({ error: payload }); 332 | } 333 | 334 | goToPage( 335 | { getState, patchState }: StateContext>, 336 | { payload }: EntityGoToPageAction 337 | ) { 338 | if ('page' in payload) { 339 | patchState({ pageIndex: payload.page }); 340 | return; 341 | } else if (payload['first']) { 342 | patchState({ pageIndex: 0 }); 343 | return; 344 | } 345 | 346 | const { pageSize, pageIndex, ids } = getState(); 347 | const totalSize = ids.length; 348 | const maxIndex = Math.floor(totalSize / pageSize); 349 | 350 | if ('last' in payload) { 351 | patchState({ pageIndex: maxIndex }); 352 | } else { 353 | const step = payload['prev'] ? -1 : 1; 354 | let index = pageIndex + step; 355 | index = wrapOrClamp(payload.wrap, index, 0, maxIndex); 356 | patchState({ pageIndex: index }); 357 | } 358 | } 359 | 360 | setPageSize( 361 | { patchState }: StateContext>, 362 | { payload }: EntitySetPageSizeAction 363 | ) { 364 | patchState({ pageSize: payload }); 365 | } 366 | 367 | // ------------------- UTILITY ------------------- 368 | 369 | private setup(storeClass: Type>, actions: string[]) { 370 | // validation if a matching action handler exists has moved to reflection-validation tests 371 | actions.forEach(fn => { 372 | const actionName = `[${this.storePath}] ${fn}`; 373 | storeClass[NGXS_META_KEY].actions[actionName] = [ 374 | { 375 | fn: fn, 376 | options: {}, 377 | type: actionName 378 | } 379 | ]; 380 | }); 381 | } 382 | 383 | /** 384 | * Returns the id of the given entity, based on the defined idKey. 385 | * This methods allows Partial entities and thus might return undefined. 386 | * Other methods calling this one have to handle this case themselves. 387 | * @param data a partial entity 388 | */ 389 | protected idOf(data: Partial): string | undefined { 390 | return data[this.idKey]; 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | export class EntityStateError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | } 5 | } 6 | 7 | export class NoActiveEntityError extends EntityStateError { 8 | constructor(additionalInformation: string = '') { 9 | super(('No active entity to affect. ' + additionalInformation).trim()); 10 | } 11 | } 12 | 13 | export class NoSuchEntityError extends EntityStateError { 14 | constructor(id: string) { 15 | super(`No entity for ID ${id}`); 16 | } 17 | } 18 | 19 | export class InvalidIdError extends EntityStateError { 20 | constructor(id: string | undefined) { 21 | super(`Invalid ID: ${id}`); 22 | } 23 | } 24 | 25 | export class InvalidIdOfError extends EntityStateError { 26 | constructor() { 27 | super(`idOf returned undefined`); 28 | } 29 | } 30 | 31 | export class UpdateFailedError extends EntityStateError { 32 | constructor(cause: Error) { 33 | super(`Updating entity failed.\n\tCause: ${cause}`); 34 | } 35 | } 36 | 37 | export class UnableToGenerateIdError extends EntityStateError { 38 | constructor(cause: string | Error) { 39 | super(`Unable to generate an ID.\n\tCause: ${cause}`); 40 | } 41 | } 42 | 43 | export class InvalidEntitySelectorError extends EntityStateError { 44 | constructor(invalidSelector: any) { 45 | super(`Cannot use ${invalidSelector} as EntitySelector`); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/id-strategy.ts: -------------------------------------------------------------------------------- 1 | import { EntityStateModel } from './models'; 2 | import { InvalidIdOfError, UnableToGenerateIdError } from './errors'; 3 | 4 | export namespace IdStrategy { 5 | export abstract class IdGenerator { 6 | protected constructor(protected readonly idKey: keyof T) {} 7 | 8 | /** 9 | * Generates a completely new ID. 10 | * The IdGenerator's implementation has to ensure that the generated ID does not exist in the current state. 11 | * It can throw an UnableToGenerateIdError if it's unable to do so. 12 | * @param entity The entity to generate an ID for 13 | * @param state The current state 14 | * @see getPresentIdOrGenerate 15 | * @see UnableToGenerateIdError 16 | */ 17 | abstract generateId(entity: Partial, state: EntityStateModel): string; 18 | 19 | /** 20 | * Checks if the given id is in the state's ID array 21 | * @param id the ID to check 22 | * @param state the current state 23 | */ 24 | isIdInState(id: string, state: EntityStateModel): boolean { 25 | return state.ids.includes(id); 26 | } 27 | 28 | /** 29 | * This function tries to get the present ID of the given entity with #getIdOf. 30 | * If it's undefined the #generateId function will be used. 31 | * @param entity The entity to get the ID from 32 | * @param state The current state 33 | * @see getIdOf 34 | * @see generateId 35 | */ 36 | getPresentIdOrGenerate(entity: Partial, state: EntityStateModel): string { 37 | const presentId = this.getIdOf(entity); 38 | return presentId === undefined ? this.generateId(entity, state) : presentId; 39 | } 40 | 41 | /** 42 | * A wrapper for #getIdOf. If the function returns undefined an error will be thrown. 43 | * @param entity The entity to get the ID from 44 | * @see getIdOf 45 | * @see InvalidIdOfError 46 | */ 47 | mustGetIdOf(entity: any): string { 48 | const id = this.getIdOf(entity); 49 | if (id === undefined) { 50 | throw new InvalidIdOfError(); 51 | } 52 | return id; 53 | } 54 | 55 | /** 56 | * Returns the ID for the given entity. Can return undefined. 57 | * @param entity The entity to get the ID from 58 | */ 59 | getIdOf(entity: any): string | undefined { 60 | return entity[this.idKey]; 61 | } 62 | } 63 | 64 | export class IncrementingIdGenerator extends IdGenerator { 65 | constructor(idKey: keyof T) { 66 | super(idKey); 67 | } 68 | 69 | generateId(entity: Partial, state: EntityStateModel): string { 70 | const max = Math.max(-1, ...state.ids.map(id => parseInt(id, 10))); 71 | return (max + 1).toString(10); 72 | } 73 | } 74 | 75 | export class UUIDGenerator extends IdGenerator { 76 | constructor(idKey: keyof T) { 77 | super(idKey); 78 | } 79 | 80 | generateId(entity: Partial, state: EntityStateModel): string { 81 | let nextId; 82 | do { 83 | nextId = this.uuidv4(); 84 | } while (this.isIdInState(nextId, state)); 85 | return nextId; 86 | } 87 | 88 | private uuidv4(): string { 89 | // https://stackoverflow.com/a/2117523 90 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 91 | const r = (Math.random() * 16) | 0; // tslint:disable-line 92 | const v = c === 'x' ? r : (r & 0x3) | 0x8; // tslint:disable-line 93 | return v.toString(16); 94 | }); 95 | } 96 | } 97 | 98 | export class EntityIdGenerator extends IdGenerator { 99 | constructor(idKey: keyof T) { 100 | super(idKey); 101 | } 102 | 103 | generateId(entity: Partial, state: EntityStateModel): string { 104 | const id = this.mustGetIdOf(entity); 105 | if (this.isIdInState(id, state)) { 106 | throw new UnableToGenerateIdError(`The provided ID already exists: ${id}`); 107 | } 108 | return id; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/lib/internal.ts: -------------------------------------------------------------------------------- 1 | import { EntityState } from './entity-state'; 2 | import { Type } from '@angular/core'; 3 | import { NoActiveEntityError } from './errors'; 4 | import { EntityStateModel } from './models'; 5 | 6 | /** 7 | * Type alias for an object literal. 8 | * Only allows strings as keys. 9 | */ 10 | export interface Dictionary { 11 | [key: string]: T; 12 | } 13 | 14 | export const NGXS_META_KEY = 'NGXS_META'; 15 | 16 | /** 17 | * This function generates a new object for the ngxs Action with the given fn name 18 | * @param fn The name of the Action to simulate, e.g. "Remove" or "Update" 19 | * @param store The class of the targeted entity state, e.g. ZooState 20 | * @param payload The payload for the created action object 21 | */ 22 | export function generateActionObject( 23 | fn: string, 24 | store: Type>, 25 | payload?: any 26 | ) { 27 | const name = store[NGXS_META_KEY].path; 28 | const ReflectedAction = function(data: T) { 29 | this.payload = data; 30 | }; 31 | const obj = new ReflectedAction(payload); 32 | Reflect.getPrototypeOf(obj).constructor['type'] = `[${name}] ${fn}`; 33 | return obj; 34 | } 35 | 36 | /** 37 | * Utility function that returns the active entity of the given state 38 | * @param state the state of an entity state 39 | */ 40 | export function getActive(state: EntityStateModel): T { 41 | return state.entities[state.active]; 42 | } 43 | 44 | /** 45 | * Returns the active entity. If none is present an error will be thrown. 46 | * @param state The state to act on 47 | */ 48 | export function mustGetActive(state: EntityStateModel): { id: string; active: T } { 49 | const active = getActive(state); 50 | if (active === undefined) { 51 | throw new NoActiveEntityError(); 52 | } 53 | return { id: state.active, active }; 54 | } 55 | 56 | /** 57 | * Undefined-safe function to access the property given by path parameter 58 | * @param object The object to read from 59 | * @param path The path to the property 60 | */ 61 | export function elvis(object: any, path: string): any | undefined { 62 | return path ? path.split('.').reduce((value, key) => value && value[key], object) : object; 63 | } 64 | 65 | /** 66 | * Returns input as an array if it isn't one already 67 | * @param input The input to make an array if necessary 68 | */ 69 | export function asArray(input: T | T[]): T[] { 70 | return Array.isArray(input) ? input : [input]; 71 | } 72 | 73 | /** 74 | * Limits a number to the given boundaries 75 | * @param value The input value 76 | * @param min The minimum value 77 | * @param max The maximum value 78 | */ 79 | function clamp(value: number, min: number, max: number): number { 80 | return Math.min(max, Math.max(min, value)); 81 | } 82 | 83 | /** 84 | * Uses the clamp function is wrap is false. 85 | * Else it wrap to the max or min value respectively. 86 | * @param wrap Flag to indicate if value should be wrapped 87 | * @param value The input value 88 | * @param min The minimum value 89 | * @param max The maximum value 90 | */ 91 | export function wrapOrClamp(wrap: boolean, value: number, min: number, max: number): number { 92 | if (!wrap) { 93 | return clamp(value, min, max); 94 | } else if (value < min) { 95 | return max; 96 | } else if (value > max) { 97 | return min; 98 | } else { 99 | return value; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/lib/models.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from './internal'; 2 | 3 | /** 4 | * Interface for an EntityState. 5 | * Includes the entities in an object literal, the loading and error state and the ID of the active selected entity. 6 | */ 7 | export interface EntityStateModel { 8 | entities: Dictionary; 9 | loading: boolean; 10 | error: Error | undefined; 11 | active: string | undefined; 12 | ids: string[]; 13 | pageSize: number; 14 | pageIndex: number; 15 | lastUpdated: number; 16 | } 17 | 18 | export type StateSelector = (state: EntityStateModel) => T; 19 | 20 | export type DeepReadonly = T extends (infer R)[] 21 | ? DeepReadonlyArray 22 | : T extends Function 23 | ? T 24 | : T extends object 25 | ? DeepReadonlyObject 26 | : T; 27 | 28 | // This should be ReadonlyArray but it has implications. 29 | export interface DeepReadonlyArray extends Array> {} 30 | 31 | export type DeepReadonlyObject = { readonly [P in keyof T]: DeepReadonly }; 32 | 33 | /** 34 | * Function that provides an ID for the given entity 35 | */ 36 | export type IdProvider = (entity: Partial, state: EntityStateModel) => string; 37 | -------------------------------------------------------------------------------- /src/lib/state-operators/adding.ts: -------------------------------------------------------------------------------- 1 | import { StateOperator } from '@ngxs/store'; 2 | import { asArray } from '../internal'; 3 | import { EntityStateModel, IdProvider } from '../models'; 4 | 5 | /** 6 | * Adds or replaces the given entities to the state. 7 | * For each entity an ID will be calculated, based on the given provider. 8 | * This operator ensures that the calculated ID is added to the entity, at the specified id-field. 9 | * The `lastUpdated` timestamp will be updated. 10 | * @param entities the new entities to add or replace 11 | * @param idKey key of the id-field of an entity 12 | * @param idProvider function to provide an ID for the given entity 13 | */ 14 | export function addOrReplace( 15 | entities: T | T[], 16 | idKey: string, 17 | idProvider: IdProvider 18 | ): StateOperator> { 19 | return (state: EntityStateModel) => { 20 | const nextEntities = { ...state.entities }; 21 | const nextIds = [...state.ids]; 22 | let nextState = state; // will be reassigned while looping over new entities 23 | 24 | asArray(entities).forEach(entity => { 25 | const id = idProvider(entity, nextState); 26 | let updatedEntity = entity; 27 | if (entity[idKey] !== id) { 28 | // ensure ID is in the entity 29 | updatedEntity = { ...entity, [idKey]: id }; 30 | } 31 | nextEntities[id] = updatedEntity; 32 | if (!nextIds.includes(id)) { 33 | nextIds.push(id); 34 | } 35 | nextState = { 36 | ...nextState, 37 | entities: nextEntities, 38 | ids: nextIds 39 | }; 40 | }); 41 | 42 | return { 43 | ...nextState, 44 | entities: nextEntities, 45 | ids: nextIds, 46 | lastUpdated: Date.now() 47 | }; 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/state-operators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './adding'; 2 | export * from './removal'; 3 | export * from './timestamp'; 4 | export * from './updating'; 5 | -------------------------------------------------------------------------------- /src/lib/state-operators/removal.ts: -------------------------------------------------------------------------------- 1 | import { StateOperator } from '@ngxs/store'; 2 | import { compose, patch } from '@ngxs/store/operators'; 3 | import { Predicate } from '@ngxs/store/operators/internals'; 4 | import { Dictionary } from '../internal'; 5 | import { EntityStateModel } from '../models'; 6 | import { updateTimestamp } from './timestamp'; 7 | 8 | /** 9 | * Removes all entities, clears the active entity and updates the `lastUpdated` timestamp. 10 | */ 11 | export function removeAllEntities(): StateOperator> { 12 | return (state: EntityStateModel) => { 13 | return { 14 | ...state, 15 | entities: {}, 16 | ids: [], 17 | active: undefined, 18 | lastUpdated: Date.now() 19 | }; 20 | }; 21 | } 22 | 23 | /** 24 | * Removes the entities specified by the given IDs. 25 | * The active entity will be cleared if included in the given IDs. 26 | * Updates the `lastUpdated` timestamp. 27 | * @param ids IDs to remove 28 | */ 29 | export function removeEntities(ids: string[]): StateOperator> { 30 | const entityRemoval = patch>({ 31 | entities: removeEntitiesFromDictionary(ids), 32 | ids: removeEntitiesFromArray(ids) 33 | }); 34 | return compose( 35 | entityRemoval, 36 | clearActiveIfRemoved(ids), 37 | updateTimestamp() 38 | ); 39 | } 40 | 41 | /** 42 | * Only clears the `active` entity, if it's included in the given array. 43 | * All other fields will remain untouched in any case. 44 | * @param idsForRemoval the IDs to be removed 45 | */ 46 | export function clearActiveIfRemoved( 47 | idsForRemoval: string[] 48 | ): StateOperator> { 49 | return (state: EntityStateModel) => { 50 | return { 51 | ...state, 52 | active: idsForRemoval.includes(state.active) ? undefined : state.active 53 | }; 54 | }; 55 | } 56 | 57 | /** 58 | * Removes the given items from the existing items, based on equality. 59 | * @param forRemoval items to remove 60 | */ 61 | export function removeEntitiesFromArray(forRemoval: T[]): StateOperator> { 62 | return (existing: ReadonlyArray) => { 63 | return existing.filter(value => !forRemoval.includes(value)); 64 | }; 65 | } 66 | 67 | /** 68 | * Removes items from the dictionary, based on the given keys. 69 | * @param keysForRemoval the keys to be removed 70 | */ 71 | export function removeEntitiesFromDictionary( 72 | keysForRemoval: string[] 73 | ): StateOperator> { 74 | return (existing: Readonly>): Dictionary => { 75 | const clone = { ...existing }; 76 | keysForRemoval.forEach(key => delete clone[key]); 77 | return clone; 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/state-operators/timestamp.ts: -------------------------------------------------------------------------------- 1 | import { StateOperator } from '@ngxs/store'; 2 | import { EntityStateModel } from '../models'; 3 | import { compose, patch } from '@ngxs/store/operators'; 4 | 5 | export function updateTimestamp(): StateOperator> { 6 | return patch>({ 7 | lastUpdated: Date.now() 8 | }); 9 | } 10 | 11 | export function alsoUpdateTimestamp( 12 | operator: StateOperator> 13 | ): StateOperator> { 14 | return compose( 15 | updateTimestamp(), 16 | operator 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/state-operators/updating.ts: -------------------------------------------------------------------------------- 1 | import { StateOperator } from '@ngxs/store'; 2 | import { asArray, Dictionary, mustGetActive } from '../internal'; 3 | import { EntityStateModel } from '../models'; 4 | import { EntitySelector, EntityUpdate, Updater } from '../actions'; 5 | import { InvalidIdError, NoSuchEntityError, UpdateFailedError } from '../errors'; 6 | 7 | export type OnUpdate = (current: Readonly, updated: Partial) => T; 8 | 9 | /** 10 | * Updates the given entities in the state. 11 | * Entities will be merged with the given `onUpdate` function. 12 | * @param payload the updated entities 13 | * @param idKey key of the id-field of an entity 14 | * @param onUpdate update function to call on each entity 15 | */ 16 | export function update( 17 | payload: EntityUpdate, 18 | idKey: string, 19 | onUpdate: OnUpdate 20 | ): StateOperator> { 21 | return (state: EntityStateModel) => { 22 | let entities = { ...state.entities }; // create copy 23 | 24 | const affected = getAffectedValues(Object.values(entities), payload.selector, idKey); 25 | 26 | if (typeof payload.data === 'function') { 27 | affected.forEach(entity => { 28 | entities = updateDictionary( 29 | entities, 30 | (payload.data)(entity), 31 | entity[idKey], 32 | onUpdate 33 | ); 34 | }); 35 | } else { 36 | affected.forEach(entity => { 37 | entities = updateDictionary( 38 | entities, 39 | payload.data as Partial, 40 | entity[idKey], 41 | onUpdate 42 | ); 43 | }); 44 | } 45 | 46 | return { 47 | ...state, 48 | entities, 49 | lastUpdated: Date.now() 50 | }; 51 | }; 52 | } 53 | 54 | export function updateActive(payload: Updater, idKey: string, onUpdate: OnUpdate) { 55 | return (state: EntityStateModel) => { 56 | const { id: activeId, active } = mustGetActive(state); 57 | const { entities } = state; 58 | 59 | if (typeof payload === 'function') { 60 | return { 61 | ...state, 62 | entities: updateDictionary(entities, payload(active), activeId, onUpdate), 63 | lastUpdated: Date.now() 64 | }; 65 | } else { 66 | return { 67 | ...state, 68 | entities: updateDictionary(entities, payload, activeId, onUpdate), 69 | lastUpdated: Date.now() 70 | }; 71 | } 72 | }; 73 | } 74 | 75 | export function updateDictionary( 76 | entities: Dictionary, 77 | entity: Partial, 78 | id: string, 79 | onUpdate: OnUpdate 80 | ): Dictionary { 81 | if (id === undefined) { 82 | throw new UpdateFailedError(new InvalidIdError(id)); 83 | } 84 | const current = entities[id]; 85 | if (current === undefined) { 86 | throw new UpdateFailedError(new NoSuchEntityError(id)); 87 | } 88 | const updated = onUpdate(current, entity); 89 | return { ...entities, [id]: updated }; 90 | } 91 | 92 | function getAffectedValues(entities: T[], selector: EntitySelector, idKey: string): T[] { 93 | if (selector === null) { 94 | return entities; 95 | } else if (typeof selector === 'function') { 96 | return entities.filter(entity => (selector)(entity)); 97 | } else { 98 | const ids = asArray(selector); 99 | return entities.filter(entity => ids.includes(entity[idKey])); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/ng-packagr/package.schema.json", 3 | "name": "@ngxs-labs/entity-state", 4 | "version": "0.1.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/ngxs-labs/entity-state.git" 8 | }, 9 | "license": "MIT", 10 | "homepage": "https://github.com/ngxs-labs/entity-state#readme", 11 | "bugs": { 12 | "url": "https://github.com/ngxs-labs/entity-state/issues" 13 | }, 14 | "keywords": [ 15 | "ngxs", 16 | "redux", 17 | "store", 18 | "entity", 19 | "state" 20 | ], 21 | "sideEffects": true, 22 | "peerDependencies": { 23 | "@angular/core": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", 24 | "@ngxs/store": ">=3.3.4" 25 | }, 26 | "ngPackage": { 27 | "lib": { 28 | "flatModuleFile": "ngxs-labs-entity-state", 29 | "entryFile": "index.ts", 30 | "umdModuleIds": { 31 | "@ngxs/store": "ngxs-store" 32 | } 33 | }, 34 | "dest": "../dist/entity-state" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/actions'; 2 | export * from './lib/entity-state'; 3 | export * from './lib/errors'; 4 | export * from './lib/id-strategy'; 5 | export * from './lib/models'; 6 | export * from './lib/action-handlers'; 7 | export * from './lib/state-operators'; 8 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone'; 4 | import 'zone.js/dist/zone-testing'; 5 | 6 | import { getTestBed } from '@angular/core/testing'; 7 | import { 8 | BrowserDynamicTestingModule, 9 | platformBrowserDynamicTesting 10 | } from '@angular/platform-browser-dynamic/testing'; 11 | 12 | declare const require: any; 13 | 14 | // First, initialize the Angular testing environment. 15 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); 16 | 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | 20 | // And load the modules. 21 | context.keys().map(context); 22 | -------------------------------------------------------------------------------- /src/tests/entity-state/action-handlers.spec.ts: -------------------------------------------------------------------------------- 1 | import { State, StateContext } from '@ngxs/store'; 2 | import { defaultEntityState, EntityState } from '../../lib/entity-state'; 3 | import { EntityStateModel } from '../../lib/models'; 4 | import { IdStrategy } from '../../lib/id-strategy'; 5 | import { NGXS_META_KEY } from '../../lib/internal'; 6 | import { UnableToGenerateIdError /* InvalidEntitySelectorError */ } from '../../lib/errors'; 7 | 8 | interface ToDo { 9 | title: string; 10 | test?: number; 11 | } 12 | 13 | @State>({ 14 | name: 'todo', 15 | defaults: defaultEntityState() 16 | }) 17 | class TestState extends EntityState { 18 | constructor() { 19 | super(TestState, 'title', IdStrategy.EntityIdGenerator); 20 | } 21 | 22 | onUpdate(current: Readonly, updated: Readonly>): ToDo { 23 | return { ...current, ...updated }; 24 | } 25 | } 26 | 27 | describe('EntityState action handlers', () => { 28 | let state: { todo: EntityStateModel }; 29 | let stateInstance: EntityState; 30 | 31 | function mockStateContext( 32 | patchState?: (val: Partial>) => any, 33 | setState?: (val: EntityStateModel) => any 34 | ): StateContext> { 35 | return { 36 | getState: () => state.todo, 37 | patchState: patchState, 38 | setState: setState, 39 | dispatch: () => undefined 40 | }; 41 | } 42 | 43 | beforeAll(() => { 44 | TestState[NGXS_META_KEY].path = 'todo'; 45 | }); 46 | 47 | beforeEach(() => { 48 | stateInstance = new TestState(); 49 | state = { 50 | todo: defaultEntityState({ 51 | entities: { 52 | a: { title: 'a' }, 53 | b: { title: 'b' }, 54 | c: { title: 'c' } 55 | }, 56 | ids: ['a', 'b', 'c'], 57 | pageSize: 2, 58 | active: 'a', 59 | error: new Error('Test Error') 60 | }) 61 | }; 62 | }); 63 | 64 | describe('add', () => { 65 | it('should add an entity', () => { 66 | const context = mockStateContext(undefined, (op: any) => { 67 | const val = op(state.todo); 68 | expect(val.entities).toEqual({ 69 | a: { title: 'a' }, 70 | b: { title: 'b' }, 71 | c: { title: 'c' }, 72 | d: { title: 'd' } 73 | }); 74 | expect(val.ids).toEqual(['a', 'b', 'c', 'd']); 75 | }); 76 | stateInstance.add(context, { payload: { title: 'd' } }); 77 | }); 78 | 79 | it('should throw an error for existing IDs', () => { 80 | const context = mockStateContext(undefined, (op: any) => { 81 | const val = op(state.todo); 82 | }); 83 | try { 84 | stateInstance.add(context, { payload: { title: 'a' } }); 85 | fail('Action should throw an error'); 86 | } catch (e) { 87 | expect(e.message).toBe( 88 | new UnableToGenerateIdError('The provided ID already exists: a').message 89 | ); 90 | } 91 | }); 92 | 93 | it('should update lastUpdated', () => { 94 | const context = mockStateContext(undefined, (op: any) => { 95 | const val = op(state.todo); 96 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 97 | }); 98 | stateInstance.add(context, { payload: { title: 'd' } }); 99 | }); 100 | }); 101 | 102 | describe('createOrReplace', () => { 103 | it('should add a new entity for a new ID', () => { 104 | const context = mockStateContext(undefined, (op: any) => { 105 | const val = op(state.todo); 106 | expect(val.entities).toEqual({ 107 | a: { title: 'a' }, 108 | b: { title: 'b' }, 109 | c: { title: 'c' }, 110 | d: { title: 'd' } 111 | }); 112 | expect(val.ids).toEqual(['a', 'b', 'c', 'd']); 113 | }); 114 | stateInstance.createOrReplace(context, { payload: { title: 'd' } }); 115 | }); 116 | 117 | it('should replace an entity for an existing ID', () => { 118 | const context = mockStateContext(undefined, (op: any) => { 119 | const val = op(state.todo); 120 | expect(val.entities).toEqual({ 121 | a: { title: 'a' }, 122 | b: { title: 'b' }, 123 | c: { title: 'c' } 124 | }); 125 | expect(val.ids).toEqual(['a', 'b', 'c']); 126 | }); 127 | 128 | stateInstance.createOrReplace(context, { payload: { title: 'a' } }); 129 | }); 130 | 131 | it('should update lastUpdated', () => { 132 | const context = mockStateContext(undefined, (op: any) => { 133 | const val = op(state.todo); 134 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 135 | }); 136 | stateInstance.createOrReplace(context, { payload: { title: 'd' } }); 137 | }); 138 | }); 139 | 140 | describe('updateAll', () => { 141 | it('should update all entities', () => { 142 | const context = mockStateContext(undefined, (op: any) => { 143 | const val = op(state.todo); 144 | expect(val.entities).toEqual({ 145 | a: { title: 'a', test: 42 }, 146 | b: { title: 'b', test: 42 }, 147 | c: { title: 'c', test: 42 } 148 | }); 149 | }); 150 | 151 | stateInstance.updateAll(context, { 152 | payload: { 153 | data: { test: 42 } 154 | } 155 | }); 156 | }); 157 | }); 158 | 159 | describe('update', () => { 160 | // update works with EntitySelector = string | string[] | ((T) => boolean); 161 | // update works with Updater = Partial | ((entity: Readonly) => Partial); 162 | 163 | describe('partial entity updates', () => { 164 | it('should update by single ID', () => { 165 | const context = mockStateContext(undefined, (op: any) => { 166 | const val = op(state.todo); 167 | expect(val.entities).toEqual({ 168 | a: { title: 'a', test: 42 }, 169 | b: { title: 'b' }, 170 | c: { title: 'c' } 171 | }); 172 | }); 173 | 174 | stateInstance.update(context, { 175 | payload: { 176 | selector: 'a', 177 | data: { test: 42 } 178 | } 179 | }); 180 | }); 181 | 182 | it('should update by multiple IDs', () => { 183 | const context = mockStateContext(undefined, (op: any) => { 184 | const val = op(state.todo); 185 | expect(val.entities).toEqual({ 186 | a: { title: 'a', test: 42 }, 187 | b: { title: 'b', test: 42 }, 188 | c: { title: 'c' } 189 | }); 190 | }); 191 | 192 | stateInstance.update(context, { 193 | payload: { 194 | selector: ['a', 'b'], 195 | data: { test: 42 } 196 | } 197 | }); 198 | }); 199 | 200 | it('should update by predicate', () => { 201 | const context = mockStateContext(undefined, (op: any) => { 202 | const val = op(state.todo); 203 | expect(val.entities).toEqual({ 204 | a: { title: 'a', test: 42 }, 205 | b: { title: 'b', test: 42 }, 206 | c: { title: 'c' } 207 | }); 208 | }); 209 | 210 | stateInstance.update(context, { 211 | payload: { 212 | selector: entity => entity.title !== 'c', 213 | data: { test: 42 } 214 | } 215 | }); 216 | }); 217 | 218 | it('should throw an error when calling with null', () => { 219 | const context = mockStateContext(undefined); 220 | expect(() => 221 | stateInstance.update(context, { 222 | payload: { 223 | selector: null, 224 | data: { test: 42 } 225 | } 226 | }) 227 | ).toThrowError(/* InvalidEntitySelectorError */); 228 | }); 229 | 230 | it('should update lastUpdated', () => { 231 | const context = mockStateContext(undefined, (op: any) => { 232 | const val = op(state.todo); 233 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 234 | }); 235 | 236 | stateInstance.update(context, { 237 | payload: { 238 | selector: entity => entity.title !== 'c', 239 | data: { test: 42 } 240 | } 241 | }); 242 | }); 243 | }); 244 | 245 | describe('function updates', () => { 246 | it('should update by single ID', () => { 247 | const context = mockStateContext(undefined, (op: any) => { 248 | const val = op(state.todo); 249 | expect(val.entities).toEqual({ 250 | a: { title: 'a', test: 42 }, 251 | b: { title: 'b' }, 252 | c: { title: 'c' } 253 | }); 254 | }); 255 | 256 | stateInstance.update(context, { 257 | payload: { 258 | selector: 'a', 259 | data: () => ({ test: 42 }) 260 | } 261 | }); 262 | }); 263 | 264 | it('should update by multiple IDs', () => { 265 | const context = mockStateContext(undefined, (op: any) => { 266 | const val = op(state.todo); 267 | expect(val.entities).toEqual({ 268 | a: { title: 'a', test: 42 }, 269 | b: { title: 'b', test: 42 }, 270 | c: { title: 'c' } 271 | }); 272 | }); 273 | 274 | stateInstance.update(context, { 275 | payload: { 276 | selector: ['a', 'b'], 277 | data: () => ({ test: 42 }) 278 | } 279 | }); 280 | }); 281 | 282 | it('should update by predicate', () => { 283 | const context = mockStateContext(undefined, (op: any) => { 284 | const val = op(state.todo); 285 | expect(val.entities).toEqual({ 286 | a: { title: 'a', test: 42 }, 287 | b: { title: 'b', test: 42 }, 288 | c: { title: 'c' } 289 | }); 290 | }); 291 | 292 | stateInstance.update(context, { 293 | payload: { 294 | selector: entity => entity.title !== 'c', 295 | data: () => ({ test: 42 }) 296 | } 297 | }); 298 | }); 299 | 300 | it('should throw an error when called with null', () => { 301 | const context = mockStateContext(undefined); 302 | 303 | expect(() => 304 | stateInstance.update(context, { 305 | payload: { 306 | selector: null, 307 | data: () => ({ test: 42 }) 308 | } 309 | }) 310 | ).toThrowError(/* InvalidEntitySelectorError */); 311 | }); 312 | }); 313 | }); 314 | 315 | describe('updateActive', () => { 316 | // updateActive works with Updater = Partial | ((entity: Readonly) => Partial); 317 | 318 | it('should update the active entity by partial entity', () => { 319 | const context = mockStateContext(undefined, (op: any) => { 320 | const val = op(state.todo); 321 | expect(val.entities).toEqual({ 322 | a: { title: 'a', test: 42 }, 323 | b: { title: 'b' }, 324 | c: { title: 'c' } 325 | }); 326 | }); 327 | stateInstance.updateActive(context, { payload: { test: 42 } as any }); 328 | }); 329 | 330 | it('should update the active entity by update fn', () => { 331 | const context = mockStateContext(undefined, (op: any) => { 332 | const val = op(state.todo); 333 | expect(val.entities).toEqual({ 334 | a: { title: 'a', test: 42 }, 335 | b: { title: 'b' }, 336 | c: { title: 'c' } 337 | }); 338 | }); 339 | stateInstance.updateActive(context, { payload: () => ({ test: 42 } as any) }); 340 | }); 341 | 342 | it('should update lastUpdated', () => { 343 | const context = mockStateContext(undefined, (op: any) => { 344 | const val = op(state.todo); 345 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 346 | }); 347 | 348 | stateInstance.updateActive(context, { payload: () => ({ test: 42 } as any) }); 349 | }); 350 | }); 351 | 352 | describe('removeActive', () => { 353 | it('should remove the active entity', () => { 354 | expect(state.todo.active).toBe('a'); // verify test data 355 | const context = mockStateContext(undefined, (op: any) => { 356 | const val = op(state.todo); 357 | expect(val.active).toBeUndefined(); 358 | expect(val.entities).toEqual({ 359 | b: { title: 'b' }, 360 | c: { title: 'c' } 361 | }); 362 | expect(val.ids).toEqual(['b', 'c']); 363 | }); 364 | stateInstance.removeActive(context); 365 | }); 366 | 367 | it('should update lastUpdated', () => { 368 | const context = mockStateContext(undefined, (op: any) => { 369 | const val = op(state.todo); 370 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 371 | }); 372 | 373 | stateInstance.removeActive(context); 374 | }); 375 | }); 376 | 377 | describe('removeAll', () => { 378 | it('should remove all entities', () => { 379 | const context = mockStateContext(undefined, (op: any) => { 380 | const val = op(state.todo); 381 | expect(val.active).toBeUndefined(); 382 | expect(val.entities).toEqual({}); 383 | expect(val.ids).toEqual([]); 384 | }); 385 | stateInstance.removeAll(context); 386 | }); 387 | }); 388 | 389 | describe('remove', () => { 390 | // remove works with EntitySelector = string | string[] | ((T) => boolean); 391 | 392 | it('should remove by single ID', () => { 393 | const context = mockStateContext(undefined, (op: any) => { 394 | const val = op(state.todo); 395 | expect(val.active).toBeUndefined(); 396 | expect(val.entities).toEqual({ 397 | b: { title: 'b' }, 398 | c: { title: 'c' } 399 | }); 400 | expect(val.ids).toEqual(['b', 'c']); 401 | }); 402 | stateInstance.remove(context, { payload: 'a' }); 403 | }); 404 | 405 | it('should remove by multiple IDs', () => { 406 | const context = mockStateContext(undefined, (op: any) => { 407 | const val = op(state.todo); 408 | expect(val.active).toBeUndefined(); 409 | expect(val.entities).toEqual({ 410 | b: { title: 'b' } 411 | }); 412 | expect(val.ids).toEqual(['b']); 413 | }); 414 | stateInstance.remove(context, { payload: ['a', 'c'] }); 415 | }); 416 | 417 | it('should remove by predicate', () => { 418 | const context = mockStateContext(undefined, (op: any) => { 419 | const val = op(state.todo); 420 | expect(val.active).toBeUndefined(); 421 | expect(val.entities).toEqual({ 422 | b: { title: 'b' } 423 | }); 424 | expect(val.ids).toEqual(['b']); 425 | }); 426 | stateInstance.remove(context, { payload: entity => entity.title !== 'b' }); 427 | }); 428 | 429 | it('should throw an error when called with null', () => { 430 | const context = mockStateContext(undefined); 431 | expect(() => stateInstance.remove(context, { payload: null })) 432 | .toThrowError 433 | /* InvalidEntitySelectorError */ 434 | (); 435 | }); 436 | 437 | it('should update lastUpdated', () => { 438 | const context = mockStateContext(undefined, (op: any) => { 439 | const val = op(state.todo); 440 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 441 | }); 442 | 443 | stateInstance.remove(context, { payload: ['a', 'c'] }); 444 | }); 445 | }); 446 | 447 | describe('reset', () => { 448 | it('should reset the state to default', () => { 449 | const context = mockStateContext(undefined, val => { 450 | const _val = { ...val }; 451 | delete _val.lastUpdated; 452 | 453 | const _default = { ...defaultEntityState() } as any; 454 | delete _default.lastUpdated; 455 | 456 | expect(_val).toEqual(_default); 457 | }); 458 | stateInstance.reset(context); 459 | }); 460 | 461 | it('should update lastUpdated', () => { 462 | const context = mockStateContext(undefined, val => { 463 | expect(val.lastUpdated).toBeCloseTo(Date.now(), -100); // within 100ms 464 | }); 465 | 466 | stateInstance.reset(context); 467 | }); 468 | }); 469 | 470 | describe('setLoading', () => { 471 | it('should set the loading state', () => { 472 | expect(state.todo.loading).toBe(false); // verify test data 473 | const context = mockStateContext(val => { 474 | expect(val.loading).toBe(true); 475 | }); 476 | stateInstance.setLoading(context, { payload: true }); 477 | }); 478 | }); 479 | 480 | describe('setActive', () => { 481 | it('should set the active entity ID', () => { 482 | const context = mockStateContext(val => { 483 | expect(val.active).toBe('b'); 484 | }); 485 | stateInstance.setActive(context, { payload: 'b' }); 486 | }); 487 | }); 488 | 489 | describe('clearActive', () => { 490 | it('should set the active entity ID', () => { 491 | const context = mockStateContext(val => { 492 | expect(val.active).toBeUndefined(); 493 | }); 494 | stateInstance.clearActive(context); 495 | }); 496 | }); 497 | 498 | describe('setError', () => { 499 | it('should set an error', () => { 500 | const context = mockStateContext(val => { 501 | expect(val.error).toEqual(new Error('Example Error')); 502 | }); 503 | stateInstance.setError(context, { payload: new Error('Example Error') }); 504 | }); 505 | }); 506 | 507 | describe('goToPage', () => { 508 | // the wrap flag is optional for creating certain actions, but required by the state 509 | // the library takes care of this in usage, but not for these tests. 510 | 511 | it('should set with page', () => { 512 | const context = mockStateContext(val => { 513 | expect(val.pageIndex).toBe(50); 514 | }); 515 | stateInstance.goToPage(context, { payload: { page: 50, wrap: false } }); 516 | }); 517 | 518 | it('should set with first', () => { 519 | const context = mockStateContext(val => { 520 | expect(val.pageIndex).toBe(0); 521 | }); 522 | stateInstance.goToPage(context, { payload: { first: true, wrap: false } }); 523 | }); 524 | 525 | it('should set with last', () => { 526 | const context = mockStateContext(val => { 527 | expect(val.pageIndex).toBe(1); 528 | }); 529 | stateInstance.goToPage(context, { payload: { last: true, wrap: false } }); 530 | }); 531 | 532 | it('should set with prev', () => { 533 | const context = mockStateContext(val => { 534 | expect(val.pageIndex).toBe(0); 535 | }); 536 | stateInstance.goToPage(context, { payload: { prev: true, wrap: false } }); 537 | }); 538 | 539 | it('should set with prev and wrap', () => { 540 | const context = mockStateContext(val => { 541 | expect(val.pageIndex).toBe(1); 542 | }); 543 | stateInstance.goToPage(context, { payload: { prev: true, wrap: true } }); 544 | }); 545 | 546 | it('should set with next', () => { 547 | const context = mockStateContext(val => { 548 | expect(val.pageIndex).toBe(1); 549 | }); 550 | stateInstance.goToPage(context, { payload: { next: true, wrap: false } }); 551 | }); 552 | 553 | it('should set with next and wrap', () => { 554 | state.todo.pageIndex = 1; 555 | const context = mockStateContext(val => { 556 | expect(val.pageIndex).toBe(0); 557 | }); 558 | stateInstance.goToPage(context, { payload: { next: true, wrap: true } }); 559 | }); 560 | }); 561 | 562 | describe('setPageSize', () => { 563 | it('should set the page size', () => { 564 | const context = mockStateContext(val => { 565 | expect(val.pageSize).toBe(100); 566 | }); 567 | stateInstance.setPageSize(context, { payload: 100 }); 568 | }); 569 | }); 570 | }); 571 | -------------------------------------------------------------------------------- /src/tests/entity-state/reflection-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { State } from '@ngxs/store'; 2 | import { defaultEntityState, EntityState } from '../../lib/entity-state'; 3 | import { EntityStateModel } from '../../lib/models'; 4 | import { IdStrategy } from '../../lib/id-strategy'; 5 | import { NGXS_META_KEY } from '../../lib/internal'; 6 | import { EntityActionType } from '../../lib/actions/type-alias'; 7 | 8 | interface ToDo { 9 | title: string; 10 | } 11 | 12 | @State>({ 13 | name: 'todo', 14 | defaults: defaultEntityState() 15 | }) 16 | class TestState extends EntityState { 17 | constructor() { 18 | super(TestState, 'title', IdStrategy.EntityIdGenerator); 19 | } 20 | 21 | onUpdate(current: Readonly, updated: Readonly>): ToDo { 22 | return { ...current, ...updated }; 23 | } 24 | } 25 | 26 | describe('EntityState reflection validation', () => { 27 | beforeAll(() => { 28 | TestState[NGXS_META_KEY].path = 'todo'; 29 | }); 30 | 31 | it('should find all actions in state class', () => { 32 | // replaces validation in EntityState#setup 33 | const actions = Object.values(EntityActionType); 34 | const baseProto = Reflect.getPrototypeOf(TestState.prototype); 35 | 36 | // uses find to see which one is missing in the error message 37 | const missing = actions.find(action => !(action in baseProto)); 38 | expect(missing).toBeUndefined(); 39 | }); 40 | 41 | it('should match the methods with the action names', () => { 42 | // replaces @EntityActionHandler validation 43 | const instance = new TestState(); 44 | const protoKeys = Object.keys( 45 | Reflect.getPrototypeOf(Reflect.getPrototypeOf(instance)) 46 | ) as EntityActionType[]; 47 | // you have to manually exclude certain methods, which are not action handlers 48 | // TODO: Add Reflect Meta-data with @EntityActionHandler annotation and query it here? 49 | const exclude = ['idOf', 'setup', 'onUpdate', '_update', '_addOrReplace']; 50 | const actionHandlers = protoKeys.filter(key => !exclude.includes(key)); 51 | 52 | // actual test 53 | const entityActionTypeValues = Object.values(EntityActionType); 54 | // uses find to see which one is missing in the error message 55 | const missing = actionHandlers.find(fn => !entityActionTypeValues.includes(fn)); 56 | expect(missing).toBeUndefined(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/tests/entity-state/static-selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import { State, Store, NgxsModule } from '@ngxs/store'; 2 | import { defaultEntityState, EntityState } from '../../lib/entity-state'; 3 | import { EntityStateModel } from '../../lib/models'; 4 | import { IdStrategy } from '../../lib/id-strategy'; 5 | import { NGXS_META_KEY } from '../../lib/internal'; 6 | import { TestBed } from '@angular/core/testing'; 7 | import { SetPageSize, GoToPage, CreateOrReplace } from 'src/lib/actions'; 8 | 9 | interface ToDo { 10 | title: string; 11 | } 12 | 13 | @State>({ 14 | name: 'todo', 15 | defaults: defaultEntityState({ 16 | entities: { 17 | a: { title: 'a' }, 18 | b: { title: 'b' }, 19 | c: { title: 'c' }, 20 | d: { title: 'd' }, 21 | e: { title: 'e' }, 22 | f: { title: 'f' }, 23 | g: { title: 'g' } 24 | }, 25 | ids: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], 26 | pageSize: 2, 27 | active: 'a', 28 | error: new Error('Test Error') 29 | }) 30 | }) 31 | class TestState extends EntityState { 32 | constructor() { 33 | super(TestState, 'title', IdStrategy.EntityIdGenerator); 34 | } 35 | 36 | onUpdate(current: Readonly, updated: Readonly>): ToDo { 37 | return { ...current, ...updated }; 38 | } 39 | } 40 | 41 | describe('EntityState selectors', () => { 42 | let store: Store; 43 | 44 | beforeEach(() => { 45 | TestBed.configureTestingModule({ 46 | imports: [NgxsModule.forRoot([TestState])] 47 | }); 48 | 49 | store = TestBed.get(Store); 50 | }); 51 | 52 | it('should select activeId', () => { 53 | const selector = TestState.activeId as any; 54 | const activeId = store.selectSnapshot(selector); 55 | expect(activeId).toBe('a'); 56 | }); 57 | 58 | it('should select active', () => { 59 | const selector = TestState.active as any; 60 | const active = store.selectSnapshot(selector); 61 | expect(active).toEqual({ title: 'a' }); 62 | }); 63 | 64 | it('should select keys', () => { 65 | const selector = TestState.keys as any; 66 | const keys = store.selectSnapshot(selector); 67 | expect(keys).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']); 68 | }); 69 | 70 | it('should select entities', () => { 71 | const selector = TestState.entities as any; 72 | const entities = store.selectSnapshot(selector); 73 | expect(entities).toEqual([ 74 | { title: 'a' }, 75 | { title: 'b' }, 76 | { title: 'c' }, 77 | { title: 'd' }, 78 | { title: 'e' }, 79 | { title: 'f' }, 80 | { title: 'g' } 81 | ]); 82 | }); 83 | 84 | it('should select nth entities', () => { 85 | const selector = TestState.nthEntity(2) as any; 86 | const entities = store.selectSnapshot(selector); 87 | expect(entities).toEqual({ title: 'c' }); 88 | }); 89 | 90 | it('should select paginated entities', () => { 91 | const selector = TestState.paginatedEntities as any; 92 | let entities = store.selectSnapshot(selector); 93 | expect(entities).toEqual([{ title: 'a' }, { title: 'b' }]); 94 | 95 | store.dispatch(new GoToPage(TestState, { page: 1 })); 96 | entities = store.selectSnapshot(selector); 97 | expect(entities).toEqual([{ title: 'c' }, { title: 'd' }]); 98 | 99 | store.dispatch(new GoToPage(TestState, { page: 3 })); 100 | entities = store.selectSnapshot(selector); 101 | expect(entities).toEqual([{ title: 'g' }]); 102 | }); 103 | 104 | it('should select entitiesMap', () => { 105 | const selector = TestState.entitiesMap as any; 106 | const entitiesMap = store.selectSnapshot(selector); 107 | expect(entitiesMap).toEqual({ 108 | a: { title: 'a' }, 109 | b: { title: 'b' }, 110 | c: { title: 'c' }, 111 | d: { title: 'd' }, 112 | e: { title: 'e' }, 113 | f: { title: 'f' }, 114 | g: { title: 'g' } 115 | }); 116 | }); 117 | 118 | it('should select size', () => { 119 | const selector = TestState.size as any; 120 | const size = store.selectSnapshot(selector); 121 | expect(size).toBe(7); 122 | }); 123 | 124 | it('should select error', () => { 125 | const selector = TestState.error as any; 126 | const error = store.selectSnapshot(selector); 127 | expect((error as any).message).toBe('Test Error'); 128 | }); 129 | 130 | it('should select loading', () => { 131 | const selector = TestState.loading as any; 132 | const loading = store.selectSnapshot(selector); 133 | expect(loading).toBe(false); 134 | }); 135 | 136 | it('should select latest', () => { 137 | const selector = TestState.latest as any; 138 | const latest = store.selectSnapshot(selector); 139 | expect(latest).toEqual({ title: 'g' }); 140 | }); 141 | 142 | it('should select latestId', () => { 143 | const selector = TestState.latestId as any; 144 | const latestId = store.selectSnapshot(selector); 145 | expect(latestId).toBe('g'); 146 | }); 147 | 148 | it('should select lastUpdated', () => { 149 | const now = Date.now(); 150 | store.dispatch(new CreateOrReplace(TestState, { title: 'h' })); 151 | const selector = TestState.lastUpdated as any; 152 | const lastUpdated: Date = store.selectSnapshot(selector); 153 | expect(lastUpdated.getTime()).toBeCloseTo(now, -100); // within 100ms 154 | }); 155 | 156 | it('should select age', () => { 157 | const now = Date.now(); 158 | store.dispatch(new CreateOrReplace(TestState, { title: 'h' })); 159 | const selector = TestState.age as any; 160 | const age = store.selectSnapshot(selector); 161 | expect(age).toBeCloseTo(now, -100); // within 100ms 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /src/tests/id-strategy.spec.ts: -------------------------------------------------------------------------------- 1 | import { EntityStateModel } from '../lib/models'; 2 | import { InvalidIdOfError } from '../lib/errors'; 3 | import { IdStrategy } from '../lib/id-strategy'; 4 | import IdGenerator = IdStrategy.IdGenerator; 5 | import IncrementingIdGenerator = IdStrategy.IncrementingIdGenerator; 6 | import UUIDGenerator = IdStrategy.UUIDGenerator; 7 | import EntityIdGenerator = IdStrategy.EntityIdGenerator; 8 | 9 | describe('ID generator', () => { 10 | function getImplementations(): IdGenerator[] { 11 | return [ 12 | new IncrementingIdGenerator('id'), 13 | new UUIDGenerator('id'), 14 | new EntityIdGenerator('id') 15 | ]; 16 | } 17 | 18 | function getState(): EntityStateModel { 19 | return { 20 | active: undefined, 21 | error: undefined, 22 | loading: false, 23 | pageIndex: 0, 24 | pageSize: 10, 25 | lastUpdated: Date.now(), 26 | ids: ['0', '1', '2', '3'], 27 | entities: { 28 | '0': { 29 | id: '0', 30 | title: 'Todo 0' 31 | }, 32 | '1': { 33 | id: '1', 34 | title: 'Todo 1' 35 | }, 36 | '2': { 37 | id: '2', 38 | title: 'Todo 2' 39 | }, 40 | '3': { 41 | id: '3', 42 | title: 'Todo 3' 43 | } 44 | } 45 | }; 46 | } 47 | 48 | it('should check if ID is present in state', () => { 49 | getImplementations().forEach(generator => { 50 | expect(generator.isIdInState('0', getState())).toBe(true); 51 | expect(generator.isIdInState('5', getState())).toBe(false); 52 | }); 53 | }); 54 | 55 | it('should get the ID of given entity', () => { 56 | getImplementations().forEach(generator => { 57 | expect(generator.getIdOf({ id: '0', title: 'Todo 0' })).toBe('0'); 58 | expect(generator.getIdOf({ title: 'Todo 0' })).toBe(undefined); 59 | }); 60 | }); 61 | 62 | it('should throw an error if ID is required but undefined', () => { 63 | getImplementations().forEach(generator => { 64 | try { 65 | generator.mustGetIdOf({ title: 'Todo 0' }); 66 | } catch (e) { 67 | expect(e.message).toBe(new InvalidIdOfError().message); 68 | } 69 | // expect(generator.mustGetIdOf({ title: "Todo 0" })).toThrow(new InvalidIdOfError()); 70 | }); 71 | }); 72 | 73 | it('should get ID from given entity if present or generate new one', () => { 74 | [new IncrementingIdGenerator('id'), new UUIDGenerator('id')].forEach( 75 | generator => { 76 | expect( 77 | generator.getPresentIdOrGenerate({ id: '0', title: 'Todo 0' }, getState()) 78 | ).toBe('0'); 79 | expect(generator.getPresentIdOrGenerate({ title: 'Todo 0' }, getState())).toBeTruthy(); 80 | } 81 | ); 82 | 83 | const entityGenerator = new EntityIdGenerator('id'); 84 | expect( 85 | entityGenerator.getPresentIdOrGenerate({ id: '0', title: 'Todo 0' }, getState()) 86 | ).toBe('0'); 87 | try { 88 | entityGenerator.getPresentIdOrGenerate({ title: 'Todo 0' }, getState()); 89 | } catch (e) { 90 | expect(e.message).toBe(new InvalidIdOfError().message); 91 | } 92 | }); 93 | 94 | describe('IncrementingIdGenerator', () => { 95 | it('should generate correct IDs from beginning', () => { 96 | const generator = new IncrementingIdGenerator('id'); 97 | const state = getState(); 98 | expect(generator.isIdInState('3', state)).toBe(true); // current highest ID 99 | expect(generator.generateId(undefined, state)).toBe('4'); 100 | state.ids.push('4'); // use the generated ID 101 | expect(generator.generateId(undefined, state)).toBe('5'); 102 | }); 103 | }); 104 | 105 | describe('UUIDGenerator', () => { 106 | it('should generate correct UUIDs', () => { 107 | const generator = new UUIDGenerator('id'); 108 | const state = getState(); // while this state doesn't have valid UUIDs, it's not possible to provoke a collision anyways 109 | const firstId = generator.generateId(undefined, state); 110 | const secondId = generator.generateId(undefined, state); 111 | expect(firstId.length).toBe(36); 112 | expect(secondId.length).toBe(36); 113 | expect(firstId).not.toEqual(secondId); 114 | }); 115 | }); 116 | 117 | describe('EntityIdGenerator', () => { 118 | it('should take correct IDs from entities', () => { 119 | const generator = new EntityIdGenerator('id'); 120 | const state = getState(); 121 | expect( 122 | generator.generateId( 123 | { 124 | id: '4', 125 | title: 'Todo 4' 126 | }, 127 | state 128 | ) 129 | ).toBe('4'); 130 | 131 | try { 132 | generator.generateId({ title: 'Todo 0' }, state); 133 | } catch (e) { 134 | expect(e.message).toBe(new InvalidIdOfError().message); 135 | } 136 | /*expect(generator.generateId({ 137 | title: "Todo 0" 138 | }, undefined)).toThrow(new InvalidIdOfError());*/ 139 | }); 140 | }); 141 | }); 142 | 143 | interface Todo { 144 | id: string; 145 | title: string; 146 | } 147 | -------------------------------------------------------------------------------- /src/tests/internal.spec.ts: -------------------------------------------------------------------------------- 1 | import { State } from '@ngxs/store'; 2 | import { defaultEntityState, EntityState } from '../lib/entity-state'; 3 | import { EntityStateModel } from '../lib/models'; 4 | import { IdStrategy } from '../lib/id-strategy'; 5 | import { 6 | elvis, 7 | generateActionObject, 8 | getActive, 9 | mustGetActive, 10 | NGXS_META_KEY, 11 | wrapOrClamp 12 | } from '../lib/internal'; 13 | import { NoActiveEntityError } from '../lib/errors'; 14 | 15 | interface ToDo { 16 | title: string; 17 | } 18 | 19 | @State>({ 20 | name: 'todo', 21 | defaults: defaultEntityState() 22 | }) 23 | class TestState extends EntityState { 24 | constructor() { 25 | super(TestState, 'title', IdStrategy.EntityIdGenerator); 26 | } 27 | 28 | onUpdate(current: Readonly, updated: Readonly>): ToDo { 29 | return { ...current, ...updated }; 30 | } 31 | } 32 | 33 | describe('internal', () => { 34 | beforeAll(() => { 35 | TestState[NGXS_META_KEY].path = 'todo'; 36 | }); 37 | 38 | describe('generateActionObject', () => { 39 | it('should generate an action object with reflected data', () => { 40 | const obj = generateActionObject('add', TestState, 42); 41 | const constructor = Reflect.getPrototypeOf(obj).constructor; 42 | expect('type' in constructor).toBe(true); 43 | expect(constructor['type']).toBe('[todo] add'); 44 | }); 45 | 46 | it('should generate an action object with payload', () => { 47 | const obj = generateActionObject('add', TestState, 42); 48 | expect(obj.payload).toBe(42); 49 | }); 50 | }); 51 | 52 | describe('getActive', () => { 53 | it('should get the active entity', () => { 54 | const state = defaultEntityState({ active: '0', entities: { '0': { title: '0' } } }); 55 | const active = getActive(state); 56 | expect(active).toEqual({ title: '0' }); 57 | }); 58 | }); 59 | 60 | describe('wrapOrClamp', () => { 61 | it('should clamp between two values, if wrap is false', () => { 62 | expect(wrapOrClamp(false, 5, 0, 10)).toBe(5); 63 | expect(wrapOrClamp(false, 15, 0, 10)).toBe(10); 64 | expect(wrapOrClamp(false, -5, 0, 10)).toBe(0); 65 | }); 66 | 67 | it('should wrap around, if wrap is true', () => { 68 | expect(wrapOrClamp(true, 5, 0, 10)).toBe(5); 69 | expect(wrapOrClamp(true, 15, 0, 10)).toBe(0); 70 | expect(wrapOrClamp(true, -5, 0, 10)).toBe(10); 71 | }); 72 | }); 73 | 74 | describe('elvis', () => { 75 | it('should find nested properties', () => { 76 | const input = { a: { b: { c: 'Test' } } }; 77 | const result = elvis(input, 'a.b.c'); 78 | expect(result).toBe('Test'); 79 | }); 80 | 81 | it('should be undefined safe', () => { 82 | const input = { a: { b: { c: 'Test' } } }; 83 | const result = elvis(input, 'a.bc'); 84 | expect(result).toBeUndefined(); 85 | 86 | expect(elvis(undefined, 'a.b.c')).toBeUndefined(); 87 | }); 88 | }); 89 | 90 | describe('mustGetActive', () => { 91 | it('should return present ID without error', () => { 92 | const state = defaultEntityState({ active: '0', entities: { '0': { title: '0' } } }); 93 | const active = mustGetActive(state); 94 | expect(active).toEqual({ 95 | id: '0', 96 | active: { title: '0' } 97 | }); 98 | }); 99 | 100 | it('should throw an error on undefined ID', () => { 101 | const state = defaultEntityState(); 102 | try { 103 | mustGetActive(state); 104 | } catch (e) { 105 | expect(e.message).toBe(new NoActiveEntityError().message); 106 | } 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "es2015" 5 | }, 6 | "angularCompilerOptions": { 7 | "skipTemplateCodegen": true, 8 | "strictMetadataEmit": true, 9 | "fullTemplateTypeCheck": true, 10 | "strictInjectionParameters": true, 11 | "enableResourceInlining": true, 12 | "flatModuleId": "AUTOGENERATED", 13 | "flatModuleOutFile": "AUTOGENERATED", 14 | "enableIvy": false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "jasmine", 6 | "node" 7 | ] 8 | }, 9 | "files": [ 10 | "test.ts" 11 | ], 12 | "include": [ 13 | "**/*.spec.ts", 14 | "**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "entity", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "entity", 14 | "kebab-case" 15 | ], 16 | "import-blacklist": [ 17 | true, 18 | "entity-state", 19 | "@ngxs-labs/entity-state" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tools/build.ts: -------------------------------------------------------------------------------- 1 | import { ngPackagr } from 'ng-packagr'; 2 | import { join } from 'path'; 3 | 4 | function buildPackage(): Promise { 5 | return ngPackagr() 6 | .forProject(join(__dirname, '../src/package.json')) 7 | .withTsConfig(join(__dirname, '../src/tsconfig.lib.json')) 8 | .build(); 9 | } 10 | 11 | buildPackage().catch(e => { 12 | console.error(e); 13 | process.exit(1); 14 | }); 15 | -------------------------------------------------------------------------------- /tools/bump.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { argv } from 'yargs'; 3 | import { join } from 'path'; 4 | import { promisify } from 'util'; 5 | import { ReleaseType, inc } from 'semver'; 6 | 7 | const readFile = promisify(fs.readFile); 8 | const writeFile = promisify(fs.writeFile); 9 | 10 | function getPackage(path: string) { 11 | return readFile(path, { 12 | encoding: 'utf-8' 13 | }).then(JSON.parse); 14 | } 15 | 16 | function writePackage(path: string, json: any) { 17 | return writeFile(path, `${JSON.stringify(json, null, 4)}\n`); 18 | } 19 | 20 | async function bump(): Promise { 21 | const release: ReleaseType = argv.release; 22 | 23 | if (!release) { 24 | return console.warn('Specify `--release` argument!'); 25 | } 26 | 27 | const path = join(__dirname, '../src/package.json'); 28 | const json = await getPackage(path); 29 | json.version = inc(json.version, release); 30 | await writePackage(path, json); 31 | } 32 | 33 | bump(); 34 | -------------------------------------------------------------------------------- /tools/copy-readme.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from 'path'; 2 | import { existsSync, createReadStream, createWriteStream, readFileSync } from 'fs'; 3 | 4 | const pkgPath = resolve(__dirname, '..', 'package.json'); 5 | const { name } = JSON.parse(readFileSync(pkgPath, 'utf8')); 6 | 7 | function copyReadmeAfterSuccessfulBuild(): void { 8 | const path = join(__dirname, '../README.md'); 9 | const readmeDoesNotExist = !existsSync(path); 10 | 11 | if (readmeDoesNotExist) { 12 | return console.warn(`README.md doesn't exist on the root level!`); 13 | } 14 | 15 | createReadStream(path) 16 | .pipe(createWriteStream(join(__dirname, `../dist/${name}/README.md`))) 17 | .on('finish', () => { 18 | console.log(`Successfully copied README.md into dist/${name} folder!`); 19 | }); 20 | } 21 | 22 | copyReadmeAfterSuccessfulBuild(); 23 | -------------------------------------------------------------------------------- /tools/sonar.ts: -------------------------------------------------------------------------------- 1 | import { analyze, Issue } from 'sonarjs'; 2 | import { join } from 'path'; 3 | 4 | const path = join(__dirname, '..', 'src'); 5 | 6 | const enum Codes { 7 | Success = 0, 8 | Error = 1 9 | } 10 | 11 | async function run(): Promise { 12 | const issues: Issue[] = await analyze(path, { 13 | log(message: string) { 14 | console.log(`Log => message => ${message}`); 15 | } 16 | }); 17 | 18 | if (!issues.length) { 19 | console.log('No issues found!'); 20 | return Codes.Success; 21 | } 22 | 23 | issues.forEach(issue => { 24 | console.log('Issue => ', issue); 25 | }); 26 | 27 | return Codes.Error; 28 | } 29 | 30 | (async () => { 31 | process.exit(await run()); 32 | })(); 33 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "downlevelIteration": true, 6 | "importHelpers": true, 7 | "outDir": "./dist/out-tsc", 8 | "sourceMap": true, 9 | "declaration": false, 10 | "module": "es2020", 11 | "target": "es2015", 12 | "moduleResolution": "node", 13 | "emitDecoratorMetadata": true, 14 | "experimentalDecorators": true, 15 | "resolveJsonModule": true, 16 | "typeRoots": [ 17 | "node_modules/@types" 18 | ], 19 | "lib": [ 20 | "es2018", 21 | "dom" 22 | ], 23 | "paths": { 24 | "@ngxs-labs/entity-state": [ 25 | "src" 26 | ] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s language server to improve development experience. 3 | It is not intended to be used to perform a compilation. 4 | 5 | To learn more about this file see: https://angular.io/config/solution-tsconfig. 6 | */ 7 | { 8 | "files": [], 9 | "references": [ 10 | { 11 | "path": "./integration/tsconfig.app.json" 12 | }, 13 | { 14 | "path": "./integration/tsconfig.spec.json" 15 | }, 16 | { 17 | "path": "./src/tsconfig.lib.json" 18 | }, 19 | { 20 | "path": "./src/tsconfig.spec.json" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "angularCompilerOptions": { 4 | "enableIvy": false 5 | } 6 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-parens": false, 7 | "arrow-return-shorthand": true, 8 | "callable-types": true, 9 | "class-name": true, 10 | "comment-format": [ 11 | true, 12 | "check-space" 13 | ], 14 | "curly": true, 15 | "deprecation": { 16 | "severity": "warn" 17 | }, 18 | "eofline": true, 19 | "forin": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": [ 26 | true, 27 | "spaces" 28 | ], 29 | "interface-over-type-literal": true, 30 | "label-position": true, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-access": false, 36 | "member-ordering": [ 37 | true, 38 | { 39 | "order": [ 40 | "static-field", 41 | "instance-field", 42 | "static-method", 43 | "instance-method" 44 | ] 45 | } 46 | ], 47 | "no-arg": true, 48 | "no-bitwise": true, 49 | "no-console": [ 50 | true, 51 | "debug", 52 | "info", 53 | "time", 54 | "timeEnd", 55 | "trace" 56 | ], 57 | "no-construct": true, 58 | "no-debugger": true, 59 | "no-duplicate-super": true, 60 | "no-empty": false, 61 | "no-empty-interface": true, 62 | "no-eval": true, 63 | "no-inferrable-types": [ 64 | true, 65 | "ignore-params" 66 | ], 67 | "no-misused-new": true, 68 | "no-non-null-assertion": true, 69 | "no-redundant-jsdoc": true, 70 | "no-shadowed-variable": true, 71 | "no-string-literal": false, 72 | "no-string-throw": true, 73 | "no-switch-case-fall-through": true, 74 | "no-trailing-whitespace": true, 75 | "no-unnecessary-initializer": true, 76 | "no-unused-expression": true, 77 | "no-var-keyword": true, 78 | "object-literal-sort-keys": false, 79 | "one-line": [ 80 | true, 81 | "check-open-brace", 82 | "check-catch", 83 | "check-else", 84 | "check-whitespace" 85 | ], 86 | "prefer-const": true, 87 | "quotemark": [ 88 | true, 89 | "single" 90 | ], 91 | "radix": true, 92 | "semicolon": [ 93 | true, 94 | "always" 95 | ], 96 | "triple-equals": [ 97 | true, 98 | "allow-null-check" 99 | ], 100 | "typedef-whitespace": [ 101 | true, 102 | { 103 | "call-signature": "nospace", 104 | "index-signature": "nospace", 105 | "parameter": "nospace", 106 | "property-declaration": "nospace", 107 | "variable-declaration": "nospace" 108 | } 109 | ], 110 | "unified-signatures": true, 111 | "variable-name": false, 112 | "whitespace": [ 113 | true, 114 | "check-branch", 115 | "check-decl", 116 | "check-operator", 117 | "check-separator", 118 | "check-type" 119 | ], 120 | "no-output-on-prefix": true, 121 | "no-inputs-metadata-property": true, 122 | "no-outputs-metadata-property": true, 123 | "no-host-metadata-property": true, 124 | "no-input-rename": true, 125 | "no-output-rename": true, 126 | "use-lifecycle-interface": true, 127 | "use-pipe-transform-interface": true, 128 | "component-class-suffix": true, 129 | "directive-class-suffix": true 130 | } 131 | } 132 | --------------------------------------------------------------------------------