├── .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 | [](https://travis-ci.org/ngxs-labs/entity-state)
10 | [](https://www.npmjs.com/package/@ngxs-labs/entity-state)
11 | [](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