├── .commitlintrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-after-install.cjs └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── apps ├── demos-e2e │ ├── .eslintrc.json │ ├── cypress.config.ts │ ├── project.json │ ├── src │ │ ├── e2e │ │ │ └── ssr.cy.ts │ │ └── plugins │ │ │ └── index.js │ ├── tsconfig.e2e.json │ └── tsconfig.json └── demos │ ├── .browserslistrc │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── src │ ├── app │ │ ├── app.component.html │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── app.server.module.ts │ │ ├── counter.state.ts │ │ └── counter │ │ │ ├── counter.component.html │ │ │ ├── counter.component.ts │ │ │ └── counter.facade.ts │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── index.html │ ├── main.server.ts │ ├── main.ts │ ├── polyfills.ts │ ├── server.ts │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.editor.json │ ├── tsconfig.json │ ├── tsconfig.server.json │ └── tsconfig.spec.json ├── decorate-angular-cli.js ├── jest.config.ts ├── jest.preset.js ├── libs └── dispatch-decorator │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── ng-package.json │ ├── package.json │ ├── project.json │ ├── src │ ├── index.ts │ ├── lib │ │ ├── decorators │ │ │ └── dispatch.ts │ │ ├── dispatch.module.ts │ │ ├── internals │ │ │ ├── action-completer.ts │ │ │ ├── decorator-injector-adapter.ts │ │ │ ├── internals.ts │ │ │ ├── static-injector.ts │ │ │ └── unwrap.ts │ │ └── tests │ │ │ ├── dispatch.spec.ts │ │ │ ├── fresh-platform.ts │ │ │ ├── parallel-modules.spec.ts │ │ │ └── skip-console-logging.ts │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ └── tsconfig.spec.json ├── nx.json ├── package.json ├── tsconfig.base.json └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | max_line_length = off 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nrwl/nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nrwl/nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nrwl/nx/typescript"], 27 | "parserOptions": { 28 | "project": "./tsconfig.*?.json" 29 | }, 30 | "rules": {} 31 | }, 32 | { 33 | "files": ["*.js", "*.jsx"], 34 | "extends": ["plugin:@nrwl/nx/javascript"], 35 | "rules": {} 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## I'm submitting a... 8 | 9 |

10 | [ ] Regression (a behavior that used to work and stopped working in a new release)
11 | [ ] Bug report  
12 | [ ] Performance issue
13 | [ ] Feature request
14 | [ ] Documentation issue or request
15 | [ ] Support request => https://github.com/ngxs/store/blob/master/CONTRIBUTING.md
16 | [ ] Other... Please describe:
17 | 
18 | 19 | ## Current behavior 20 | 21 | 22 | 23 | ## Expected behavior 24 | 25 | 26 | 27 | ## Minimal reproduction of the problem with instructions 28 | 29 | 38 | 39 | ## What is the motivation / use case for changing the behavior? 40 | 41 | 42 | 43 | ## Environment 44 | 45 |

46 | Libs:
47 | - @angular/core version: X.Y.Z
48 | - @ngxs/store version: X.Y.Z
49 | 
50 | 
51 | Browser:
52 | - [ ] Chrome (desktop) version XX
53 | - [ ] Chrome (Android) version XX
54 | - [ ] Chrome (iOS) version XX
55 | - [ ] Firefox version XX
56 | - [ ] Safari (desktop) version XX
57 | - [ ] Safari (iOS) version XX
58 | - [ ] IE version XX
59 | - [ ] Edge version XX
60 |  
61 | For Tooling issues:
62 | - Node version: XX  
63 | - Platform:  
64 | 
65 | Others:
66 | 
67 | 
68 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | Please check if your PR fulfills the following requirements: 3 | 4 | - [ ] The commit message follows our guidelines: https://github.com/ngxs/store/blob/master/CONTRIBUTING.md#commit 5 | - [ ] Tests for the changes have been added (for bug fixes / features) 6 | - [ ] Docs have been added / updated (for bug fixes / features) 7 | 8 | ## PR Type 9 | What kind of change does this PR introduce? 10 | 11 | 12 | ``` 13 | [ ] Bugfix 14 | [ ] Feature 15 | [ ] Code style update (formatting, local variables) 16 | [ ] Refactoring (no functional changes, no api changes) 17 | [ ] Build related changes 18 | [ ] CI related changes 19 | [ ] Documentation content changes 20 | [ ] Other... Please describe: 21 | ``` 22 | 23 | ## What is the current behavior? 24 | 25 | 26 | Issue Number: N/A 27 | 28 | 29 | ## What is the new behavior? 30 | 31 | 32 | ## Does this PR introduce a breaking change? 33 | ``` 34 | [ ] Yes 35 | [ ] No 36 | ``` 37 | 38 | 39 | 40 | 41 | ## Other information 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: true 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - run: git fetch --no-tags --prune --depth 2 origin master 21 | 22 | - uses: actions/cache@v3 23 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 24 | with: 25 | path: ~/.cache # Default cache directory for both Yarn and Cypress 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: 16.13.0 33 | registry-url: 'https://registry.npmjs.org' 34 | 35 | - name: Install dependencies 36 | run: yarn --immutable 37 | 38 | - run: yarn nx affected:lint --parallel --base=origin/master 39 | - run: yarn nx affected:test --parallel --base=origin/master 40 | - run: yarn nx affected:build --base=origin/master 41 | - run: yarn nx affected:e2e --base=origin/master 42 | env: 43 | ELECTRON_EXTRA_LAUNCH_ARGS: '--disable-gpu' 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.angular/cache 2 | dist 3 | node_modules 4 | .idea 5 | .vscode 6 | coverage 7 | yarn-error.log 8 | .cache 9 | 10 | migrations.json 11 | 12 | .pnp.* 13 | .yarn/* 14 | !.yarn/patches 15 | !.yarn/plugins 16 | !.yarn/releases 17 | !.yarn/sdks 18 | !.yarn/versions 19 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | .gitignore 4 | .prettierignore 5 | .gitkeep 6 | .npmignore 7 | .editorconfig 8 | *.template 9 | .yarn 10 | yarn.lock 11 | yarn-error.log 12 | LICENSE 13 | browserslist 14 | *.ico 15 | dist 16 | .cache 17 | *.snap 18 | assets 19 | .husky 20 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-after-install.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-after-install", 5 | factory: function (require) { 6 | var plugin=(()=>{var g=Object.create,r=Object.defineProperty;var x=Object.getOwnPropertyDescriptor;var C=Object.getOwnPropertyNames;var k=Object.getPrototypeOf,y=Object.prototype.hasOwnProperty;var I=t=>r(t,"__esModule",{value:!0});var i=t=>{if(typeof require!="undefined")return require(t);throw new Error('Dynamic require of "'+t+'" is not supported')};var h=(t,o)=>{for(var e in o)r(t,e,{get:o[e],enumerable:!0})},w=(t,o,e)=>{if(o&&typeof o=="object"||typeof o=="function")for(let n of C(o))!y.call(t,n)&&n!=="default"&&r(t,n,{get:()=>o[n],enumerable:!(e=x(o,n))||e.enumerable});return t},a=t=>w(I(r(t!=null?g(k(t)):{},"default",t&&t.__esModule&&"default"in t?{get:()=>t.default,enumerable:!0}:{value:t,enumerable:!0})),t);var j={};h(j,{default:()=>b});var c=a(i("@yarnpkg/core")),m={afterInstall:{description:"Hook that will always run after install",type:c.SettingsType.STRING,default:""}};var u=a(i("clipanion")),d=a(i("@yarnpkg/core"));var p=a(i("@yarnpkg/shell")),l=async(t,o)=>{var f;let e=t.get("afterInstall"),n=!!((f=t.projectCwd)==null?void 0:f.endsWith(`dlx-${process.pid}`));return e&&!n?(o&&console.log("Running `afterInstall` hook..."),(0,p.execute)(e,[],{cwd:t.projectCwd||void 0})):0};var s=class extends u.Command{async execute(){let o=await d.Configuration.find(this.context.cwd,this.context.plugins);return l(o,!1)}};s.paths=[["after-install"]];var P={configuration:m,commands:[s],hooks:{afterAllInstalled:async t=>{if(await l(t.configuration,!0))throw new Error("The `afterInstall` hook failed, see output above.")}}},b=P;return j;})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | afterInstall: yarn ngcc && node ./decorate-angular-cli.js && yarn husky install 2 | 3 | nodeLinker: node-modules 4 | 5 | npmRegistryServer: "https://registry.npmjs.org" 6 | 7 | plugins: 8 | - path: .yarn/plugins/@yarnpkg/plugin-after-install.cjs 9 | spec: "https://raw.githubusercontent.com/mhassan1/yarn-plugin-after-install/v0.3.1/bundles/@yarnpkg/plugin-after-install.js" 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 NGXS 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | --- 6 | 7 | > The distribution for separation of concern between the state management and the view 8 | 9 | [![NPM](https://badge.fury.io/js/%40ngxs-labs%2Fdispatch-decorator.svg)](https://www.npmjs.com/package/@ngxs-labs/dispatch-decorator) 10 | [![License](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/ngxs-labs/dispatch-decorator/blob/master/LICENSE) 11 | 12 | This package simplifies the dispatching process. It would be best if you didn't care about the `Store` service injection, as we provide a more declarative way to dispatch events out of the box. 13 | 14 | ## Compatibility with Angular Versions 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 31 | 32 | 33 | 36 | 39 | 40 | 41 |
@ngxs-labs/dispatch-decoratorAngular
26 | 4.x 27 | 29 | >= 13 < 15 30 |
34 | 5.x 35 | 37 | >= 15 38 |
42 | 43 | ## 📦 Install 44 | 45 | To install the `@ngxs-labs/dispatch-decorator`, run the following command: 46 | 47 | ```sh 48 | $ npm install @ngxs-labs/dispatch-decorator 49 | # Or if you're using yarn 50 | $ yarn add @ngxs-labs/dispatch-decorator 51 | # Or if you're using pnpm 52 | $ pnpm install @ngxs-labs/dispatch-decorator 53 | ``` 54 | 55 | ## 🔨 Usage 56 | 57 | Import the module into your root application module: 58 | 59 | ```ts 60 | import { NgModule } from '@angular/core'; 61 | import { NgxsModule } from '@ngxs/store'; 62 | import { NgxsDispatchPluginModule } from '@ngxs-labs/dispatch-decorator'; 63 | 64 | @NgModule({ 65 | imports: [NgxsModule.forRoot(states), NgxsDispatchPluginModule.forRoot()] 66 | }) 67 | export class AppModule {} 68 | ``` 69 | 70 | ### Dispatch Decorator 71 | 72 | `@Dispatch()` can be used to decorate methods and properties of your classes. Firstly let's create our state for demonstrating purposes: 73 | 74 | ```ts 75 | import { State, Action, StateContext } from '@ngxs/store'; 76 | 77 | export class Increment { 78 | static readonly type = '[Counter] Increment'; 79 | } 80 | 81 | export class Decrement { 82 | static readonly type = '[Counter] Decrement'; 83 | } 84 | 85 | @State({ 86 | name: 'counter', 87 | defaults: 0 88 | }) 89 | export class CounterState { 90 | @Action(Increment) 91 | increment(ctx: StateContext) { 92 | ctx.setState(ctx.getState() + 1); 93 | } 94 | 95 | @Action(Decrement) 96 | decrement(ctx: StateContext) { 97 | ctx.setState(ctx.getState() - 1); 98 | } 99 | } 100 | ``` 101 | 102 | We are ready to try the plugin after registering our state in the `NgxsModule`, given the following component: 103 | 104 | ```ts 105 | import { Component } from '@angular/core'; 106 | import { Select } from '@ngxs/store'; 107 | import { Dispatch } from '@ngxs-labs/dispatch-decorator'; 108 | 109 | import { Observable } from 'rxjs'; 110 | 111 | import { CounterState, Increment, Decrement } from './counter.state'; 112 | 113 | @Component({ 114 | selector: 'app-root', 115 | template: ` 116 | 117 |

{{ counter }}

118 |
119 | 120 | 121 | 122 | ` 123 | }) 124 | export class AppComponent { 125 | @Select(CounterState) counter$: Observable; 126 | 127 | @Dispatch() increment = () => new Increment(); 128 | 129 | @Dispatch() decrement = () => new Decrement(); 130 | } 131 | ``` 132 | 133 | You may mention that we don't have to inject the `Store` class to dispatch actions. The `@Dispatch` decorator takes care of delivering actions internally. It unwraps the result of function calls and calls `store.dispatch(...)`. 134 | 135 | Dispatch function can be both synchronous and asynchronous, meaning that the `@Dispatch` decorator can unwrap `Promise` and `Observable`. Dispatch functions are called outside of the Angular zone, which means asynchronous tasks won't notify Angular about change detection forced to be run: 136 | 137 | ```ts 138 | export class AppComponent { 139 | // `ApiService` is defined somewhere 140 | constructor(private api: ApiService) {} 141 | 142 | @Dispatch() 143 | async setAppSchema() { 144 | const version = await this.api.getApiVersion(); 145 | const schema = await this.api.getSchemaForVersion(version); 146 | return new SetAppSchema(schema); 147 | } 148 | 149 | // OR using lambda 150 | 151 | @Dispatch() setAppSchema = () => 152 | this.api.getApiVersion().pipe( 153 | mergeMap(version => this.api.getSchemaForVersion(version)), 154 | map(schema => new SetAppSchema(schema)) 155 | ); 156 | } 157 | ``` 158 | 159 | Note it doesn't if an arrow function or a regular class method is used. 160 | 161 | ### Dispatching Multiple Actions 162 | 163 | `@Dispatch` function can return arrays of actions: 164 | 165 | ```ts 166 | export class AppComponent { 167 | @Dispatch() setLanguageAndNavigateHome = (language: string) => [ 168 | new SetLanguage(language), 169 | new Navigate('/') 170 | ]; 171 | } 172 | ``` 173 | 174 | ### Canceling 175 | 176 | `@Dispatch` functions can cancel currently running actions if they're called again in the middle of running actions. This is useful for canceling previous requests like in a typeahead. Given the following example: 177 | 178 | ```ts 179 | @Component({ ... }) 180 | export class NovelsComponent { 181 | @Dispatch() searchNovels = (query: string) => 182 | this.novelsService.getNovels(query).pipe(map(novels => new SetNovels(novels))); 183 | 184 | constructor(private novelsService: NovelsService) {} 185 | } 186 | ``` 187 | 188 | We have to provide the `cancelUncompleted` option if we'd want to cancel previously uncompleted `getNovels` action: 189 | 190 | ```ts 191 | @Component({ ... }) 192 | export class NovelsComponent { 193 | @Dispatch({ cancelUncompleted: true }) searchNovels = (query: string) => 194 | this.novelsService.getNovels(query).pipe(map(novels => new SetNovels(novels))); 195 | 196 | constructor(private novelsService: NovelsService) {} 197 | } 198 | ``` 199 | -------------------------------------------------------------------------------- /apps/demos-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:cypress/recommended", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "rules": {}, 5 | "overrides": [ 6 | { 7 | "files": ["src/plugins/index.js"], 8 | "rules": { 9 | "@typescript-eslint/no-var-requires": "off", 10 | "no-undef": "off" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /apps/demos-e2e/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | video: false, 5 | chromeWebSecurity: false, 6 | fileServerFolder: '.', 7 | screenshotOnRunFailure: false, 8 | e2e: { 9 | supportFile: false, 10 | fixturesFolder: false, 11 | specPattern: './src/e2e/**/*.cy.ts' 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /apps/demos-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demos-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "sourceRoot": "apps/demos-e2e/src", 6 | "targets": { 7 | "e2e": { 8 | "executor": "@nrwl/cypress:cypress", 9 | "options": { 10 | "cypressConfig": "apps/demos-e2e/cypress.config.ts", 11 | "tsConfig": "apps/demos-e2e/tsconfig.e2e.json", 12 | "devServerTarget": "demos:serve-ssr:production" 13 | } 14 | }, 15 | "lint": { 16 | "executor": "@nrwl/linter:eslint", 17 | "options": { 18 | "lintFilePatterns": ["apps/demos-e2e/**/*.{js,ts}"] 19 | } 20 | } 21 | }, 22 | "implicitDependencies": ["demos"] 23 | } 24 | -------------------------------------------------------------------------------- /apps/demos-e2e/src/e2e/ssr.cy.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Server side rendering', () => { 4 | const indexUrl = '/'; 5 | 6 | it('should make concurrent requests and app should render correctly for each request', async () => { 7 | const promises: Promise[] = Array.from({ length: 50 }).map(() => 8 | fetch('/').then(res => res.text()) 9 | ); 10 | 11 | const bodies = await Promise.all(promises); 12 | 13 | bodies.forEach(body => { 14 | expect(body).to.contain('Toggle counter component'); 15 | }); 16 | }); 17 | 18 | it('successfully render index page', () => { 19 | // Arrange & act & assert 20 | cy.request(indexUrl).its('body').should('contain', 'Counter is 0'); 21 | }); 22 | 23 | it('should increment and decrement after button clicks', () => { 24 | // Arrange & act & assert 25 | cy.visit(indexUrl) 26 | .get('button.increment') 27 | // Increment 5 times 28 | .click() 29 | .click() 30 | .click() 31 | .click() 32 | .click() 33 | .get('button.decrement') 34 | // Decrement 3 times 35 | .click() 36 | .click() 37 | .click() 38 | .get('h1.counter') 39 | .should('contain', 'Counter is 2'); 40 | }); 41 | 42 | it('should increment but cancel previously uncompleted async job', () => { 43 | // Arrange & act & assert 44 | // eslint-disable-next-line cypress/no-unnecessary-waiting 45 | cy.visit(indexUrl) 46 | .get('button.increment-async') 47 | // Try to increment 3 times 48 | .click() 49 | .click() 50 | .click() 51 | .wait(600) 52 | .get('h1.counter') 53 | .should('contain', 'Counter is 1'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /apps/demos-e2e/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | const { preprocessTypescript } = require('@nrwl/cypress/plugins/preprocessor'); 2 | 3 | module.exports = (on, config) => { 4 | on('file:preprocessor', preprocessTypescript(config)); 5 | }; 6 | -------------------------------------------------------------------------------- /apps/demos-e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "sourceMap": false, 6 | "skipLibCheck": true, 7 | "types": ["cypress", "node"] 8 | }, 9 | "include": ["src/**/*.ts", "src/**/*.js"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/demos-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.e2e.json" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /apps/demos/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | last 1 Chrome version 9 | -------------------------------------------------------------------------------- /apps/demos/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nrwl/nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/directive-selector": [ 13 | "error", 14 | { 15 | "type": "attribute", 16 | "prefix": "app", 17 | "style": "camelCase" 18 | } 19 | ], 20 | "@angular-eslint/component-selector": [ 21 | "error", 22 | { 23 | "type": "element", 24 | "prefix": "app", 25 | "style": "kebab-case" 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "files": ["*.html"], 32 | "extends": ["plugin:@nrwl/nx/angular-template"], 33 | "rules": {} 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /apps/demos/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'demos', 4 | preset: '../../jest.preset.js', 5 | setupFilesAfterEnv: ['/src/test-setup.ts'], 6 | transform: { 7 | '^.+\\.(ts|mjs|js|html)$': [ 8 | 'jest-preset-angular', 9 | { 10 | isolatedModules: true, 11 | tsconfig: '/tsconfig.spec.json', 12 | stringifyContentPathRegex: '\\.(html|svg)$' 13 | } 14 | ] 15 | }, 16 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 17 | snapshotSerializers: [ 18 | 'jest-preset-angular/build/serializers/no-ng-attributes', 19 | 'jest-preset-angular/build/serializers/ng-snapshot', 20 | 'jest-preset-angular/build/serializers/html-comment' 21 | ] 22 | }; 23 | -------------------------------------------------------------------------------- /apps/demos/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demos", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "sourceRoot": "apps/demos/src", 6 | "prefix": "app", 7 | "targets": { 8 | "build": { 9 | "executor": "@angular-devkit/build-angular:browser", 10 | "options": { 11 | "outputPath": "dist/apps/demos/browser", 12 | "index": "apps/demos/src/index.html", 13 | "main": "apps/demos/src/main.ts", 14 | "polyfills": "apps/demos/src/polyfills.ts", 15 | "tsConfig": "apps/demos/tsconfig.app.json", 16 | "styles": [], 17 | "scripts": [], 18 | "vendorChunk": true, 19 | "extractLicenses": false, 20 | "buildOptimizer": false, 21 | "sourceMap": true, 22 | "optimization": false, 23 | "namedChunks": true, 24 | "assets": ["apps/demos/src/favicon.ico", "apps/demos/src/assets"] 25 | }, 26 | "configurations": { 27 | "production": { 28 | "fileReplacements": [ 29 | { 30 | "replace": "apps/demos/src/environments/environment.ts", 31 | "with": "apps/demos/src/environments/environment.prod.ts" 32 | } 33 | ], 34 | "optimization": true, 35 | "outputHashing": "all", 36 | "sourceMap": false, 37 | "namedChunks": false, 38 | "extractLicenses": true, 39 | "vendorChunk": false, 40 | "buildOptimizer": true, 41 | "budgets": [ 42 | { 43 | "type": "initial", 44 | "maximumWarning": "2mb", 45 | "maximumError": "5mb" 46 | }, 47 | { 48 | "type": "anyComponentStyle", 49 | "maximumWarning": "6kb", 50 | "maximumError": "10kb" 51 | } 52 | ] 53 | } 54 | } 55 | }, 56 | "serve": { 57 | "executor": "@angular-devkit/build-angular:dev-server", 58 | "options": { 59 | "browserTarget": "demos:build" 60 | }, 61 | "configurations": { 62 | "production": { 63 | "browserTarget": "demos:build:production" 64 | } 65 | } 66 | }, 67 | "lint": { 68 | "executor": "@nrwl/linter:eslint", 69 | "options": { 70 | "lintFilePatterns": ["apps/demos/src/**/*.ts", "apps/demos/src/**/*.html"] 71 | }, 72 | "outputs": ["{options.outputFile}"] 73 | }, 74 | "test": { 75 | "executor": "@nrwl/jest:jest", 76 | "outputs": ["{workspaceRoot}/coverage/apps/demos"], 77 | "options": { 78 | "jestConfig": "apps/demos/jest.config.ts", 79 | "passWithNoTests": true 80 | } 81 | }, 82 | "server": { 83 | "executor": "@angular-devkit/build-angular:server", 84 | "options": { 85 | "outputPath": "dist/apps/demos/server", 86 | "main": "apps/demos/src/server.ts", 87 | "tsConfig": "apps/demos/tsconfig.server.json", 88 | "sourceMap": true, 89 | "optimization": false 90 | }, 91 | "configurations": { 92 | "production": { 93 | "sourceMap": false, 94 | "optimization": true, 95 | "outputHashing": "media", 96 | "fileReplacements": [ 97 | { 98 | "replace": "apps/demos/src/environments/environment.ts", 99 | "with": "apps/demos/src/environments/environment.prod.ts" 100 | } 101 | ] 102 | } 103 | } 104 | }, 105 | "serve-ssr": { 106 | "executor": "@nguniversal/builders:ssr-dev-server", 107 | "options": { 108 | "browserTarget": "demos:build", 109 | "serverTarget": "demos:server" 110 | }, 111 | "configurations": { 112 | "production": { 113 | "browserTarget": "demos:build:production", 114 | "serverTarget": "demos:server:production" 115 | } 116 | } 117 | } 118 | }, 119 | "type": ["app"] 120 | } 121 | -------------------------------------------------------------------------------- /apps/demos/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/demos/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | changeDetection: ChangeDetectionStrategy.OnPush 7 | }) 8 | export class AppComponent { 9 | counterComponentShown = true; 10 | } 11 | -------------------------------------------------------------------------------- /apps/demos/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { HttpClientModule } from '@angular/common/http'; 4 | import { NgxsModule } from '@ngxs/store'; 5 | import { NgxsDispatchPluginModule } from '@ngxs-labs/dispatch-decorator'; 6 | 7 | import { CounterState } from './counter.state'; 8 | 9 | import { AppComponent } from './app.component'; 10 | import { CounterComponent } from './counter/counter.component'; 11 | 12 | import { environment } from '../environments/environment'; 13 | 14 | @NgModule({ 15 | imports: [ 16 | BrowserModule.withServerTransition({ appId: 'dispatch-decorator' }), 17 | HttpClientModule, 18 | NgxsModule.forRoot([CounterState], { developmentMode: !environment.production }), 19 | NgxsDispatchPluginModule.forRoot() 20 | ], 21 | declarations: [AppComponent, CounterComponent], 22 | bootstrap: [AppComponent] 23 | }) 24 | export class AppModule {} 25 | -------------------------------------------------------------------------------- /apps/demos/src/app/app.server.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { ServerModule } from '@angular/platform-server'; 3 | 4 | import { AppModule } from './app.module'; 5 | import { AppComponent } from './app.component'; 6 | 7 | @NgModule({ 8 | imports: [AppModule, ServerModule], 9 | bootstrap: [AppComponent] 10 | }) 11 | export class AppServerModule {} 12 | -------------------------------------------------------------------------------- /apps/demos/src/app/counter.state.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { State, Action, StateContext } from '@ngxs/store'; 3 | 4 | export interface CounterStateModel { 5 | counter: number; 6 | } 7 | 8 | export class Increment { 9 | static readonly type = '[Counter] Increment'; 10 | } 11 | 12 | export class Decrement { 13 | static readonly type = '[Counter] Decrement'; 14 | } 15 | 16 | @State({ 17 | name: 'counter', 18 | defaults: { 19 | counter: 0 20 | } 21 | }) 22 | @Injectable() 23 | export class CounterState { 24 | @Action(Increment) 25 | increment(ctx: StateContext) { 26 | const counter = ctx.getState().counter + 1; 27 | ctx.setState({ counter }); 28 | } 29 | 30 | @Action(Decrement) 31 | decrement(ctx: StateContext) { 32 | const counter = ctx.getState().counter - 1; 33 | ctx.setState({ counter }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/demos/src/app/counter/counter.component.html: -------------------------------------------------------------------------------- 1 | 2 |
Counter state is {{ state | json }}
3 |

Counter is {{ state.counter }}

4 |
5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /apps/demos/src/app/counter/counter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | import { Select } from '@ngxs/store'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | import { CounterFacade } from './counter.facade'; 7 | import { CounterState, CounterStateModel } from '../counter.state'; 8 | 9 | @Component({ 10 | selector: 'app-counter', 11 | templateUrl: './counter.component.html', 12 | changeDetection: ChangeDetectionStrategy.OnPush 13 | }) 14 | export class CounterComponent { 15 | @Select(CounterState) counter$!: Observable; 16 | 17 | constructor(private counterFacade: CounterFacade) {} 18 | 19 | increment(): void { 20 | this.counterFacade.increment(); 21 | } 22 | 23 | decrement(): void { 24 | this.counterFacade.decrement(); 25 | } 26 | 27 | incrementAsync(): void { 28 | this.counterFacade.incrementAsync(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/demos/src/app/counter/counter.facade.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Dispatch } from '@ngxs-labs/dispatch-decorator'; 3 | 4 | import { timer } from 'rxjs'; 5 | import { mapTo } from 'rxjs/operators'; 6 | 7 | import { Increment, Decrement } from '../counter.state'; 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class CounterFacade { 11 | @Dispatch() increment = () => new Increment(); 12 | 13 | @Dispatch() decrement = () => new Decrement(); 14 | 15 | @Dispatch({ cancelUncompleted: true }) incrementAsync = () => 16 | timer(500).pipe(mapTo(new Increment())); 17 | } 18 | -------------------------------------------------------------------------------- /apps/demos/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /apps/demos/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false 3 | }; 4 | -------------------------------------------------------------------------------- /apps/demos/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Angular Universal @ngxs-labs/package integration 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /apps/demos/src/main.server.ts: -------------------------------------------------------------------------------- 1 | import '@angular/platform-server/init'; 2 | 3 | import { enableProdMode } from '@angular/core'; 4 | 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | export { AppServerModule } from './app/app.server.module'; 12 | export { renderModule, renderModuleFactory } from '@angular/platform-server'; 13 | -------------------------------------------------------------------------------- /apps/demos/src/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 | document.addEventListener('DOMContentLoaded', () => { 12 | platformBrowserDynamic().bootstrapModule(AppModule); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/demos/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js'; 2 | -------------------------------------------------------------------------------- /apps/demos/src/server.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/node'; 2 | 3 | import { join } from 'path'; 4 | import * as express from 'express'; 5 | 6 | import { APP_BASE_HREF } from '@angular/common'; 7 | import { ngExpressEngine } from '@nguniversal/express-engine'; 8 | 9 | import { AppServerModule } from './main.server'; 10 | 11 | export function app(): express.Express { 12 | const server = express(); 13 | const distFolder = join(process.cwd(), 'dist/apps/demos/browser'); 14 | 15 | server.engine( 16 | 'html', 17 | ngExpressEngine({ 18 | bootstrap: AppServerModule 19 | }) 20 | ); 21 | 22 | server.set('view engine', 'html'); 23 | server.set('views', distFolder); 24 | 25 | server.get( 26 | '*.*', 27 | express.static(distFolder, { 28 | maxAge: '1y' 29 | }) 30 | ); 31 | 32 | server.get('*', (req, res) => { 33 | res.render('index', { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] }); 34 | }); 35 | 36 | return server; 37 | } 38 | 39 | function run(): void { 40 | const port = process.env.PORT || 4200; 41 | 42 | const server = app(); 43 | server.listen(port, () => { 44 | console.log(`Node Express server listening on http://localhost:${port}`); 45 | }); 46 | } 47 | 48 | // Webpack will replace 'require' with '__webpack_require__' 49 | // '__non_webpack_require__' is a proxy to Node 'require' 50 | // The below code is to ensure that the server is run only when not requiring the bundle. 51 | declare const __non_webpack_require__: NodeRequire; 52 | const mainModule = __non_webpack_require__.main; 53 | const moduleFilename = (mainModule && mainModule.filename) || ''; 54 | if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { 55 | run(); 56 | } 57 | 58 | export * from './main.server'; 59 | -------------------------------------------------------------------------------- /apps/demos/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | -------------------------------------------------------------------------------- /apps/demos/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "target": "es2022", 6 | "useDefineForClassFields": false 7 | }, 8 | "files": ["src/main.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.d.ts"], 10 | "exclude": ["**/*.test.ts", "**/*.spec.ts", "jest.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /apps/demos/tsconfig.editor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "types": ["jest", "node"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/demos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | }, 12 | { 13 | "path": "./tsconfig.editor.json" 14 | }, 15 | { 16 | "path": "./tsconfig.server.json" 17 | } 18 | ], 19 | "compilerOptions": { 20 | "forceConsistentCasingInFileNames": true, 21 | "strict": true, 22 | "noImplicitOverride": true, 23 | "noPropertyAccessFromIndexSignature": false, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "target": "es2020" 27 | }, 28 | "angularCompilerOptions": { 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/demos/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "types": ["node"] 6 | }, 7 | "files": ["src/main.server.ts", "src/server.ts"], 8 | "angularCompilerOptions": { 9 | "entryModule": "./src/app/app.server.module#AppServerModule" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apps/demos/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "types": ["jest", "node"] 6 | }, 7 | "files": ["src/test-setup.ts"], 8 | "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /decorate-angular-cli.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file decorates the Angular CLI with the Nx CLI to enable features such as computation caching 3 | * and faster execution of tasks. 4 | * 5 | * It does this by: 6 | * 7 | * - Patching the Angular CLI to warn you in case you accidentally use the undecorated ng command. 8 | * - Symlinking the ng to nx command, so all commands run through the Nx CLI 9 | * - Updating the package.json postinstall script to give you control over this script 10 | * 11 | * The Nx CLI decorates the Angular CLI, so the Nx CLI is fully compatible with it. 12 | * Every command you run should work the same when using the Nx CLI, except faster. 13 | * 14 | * Because of symlinking you can still type `ng build/test/lint` in the terminal. The ng command, in this case, 15 | * will point to nx, which will perform optimizations before invoking ng. So the Angular CLI is always invoked. 16 | * The Nx CLI simply does some optimizations before invoking the Angular CLI. 17 | * 18 | * To opt out of this patch: 19 | * - Replace occurrences of nx with ng in your package.json 20 | * - Remove the script from your postinstall script in your package.json 21 | * - Delete and reinstall your node_modules 22 | */ 23 | 24 | const fs = require('fs'); 25 | const os = require('os'); 26 | const cp = require('child_process'); 27 | const isWindows = os.platform() === 'win32'; 28 | let output; 29 | try { 30 | output = require('@nrwl/workspace').output; 31 | } catch (e) { 32 | console.warn( 33 | 'Angular CLI could not be decorated to enable computation caching. Please ensure @nrwl/workspace is installed.' 34 | ); 35 | process.exit(0); 36 | } 37 | 38 | /** 39 | * Paths to files being patched 40 | */ 41 | const angularCLIInitPath = 'node_modules/@angular/cli/lib/cli/index.js'; 42 | 43 | /** 44 | * Patch index.js to warn you if you invoke the undecorated Angular CLI. 45 | */ 46 | function patchAngularCLI(initPath) { 47 | const angularCLIInit = fs.readFileSync(initPath, 'utf-8').toString(); 48 | 49 | if (!angularCLIInit.includes('NX_CLI_SET')) { 50 | fs.writeFileSync( 51 | initPath, 52 | ` 53 | if (!process.env['NX_CLI_SET']) { 54 | const { output } = require('@nrwl/workspace'); 55 | output.warn({ title: 'The Angular CLI was invoked instead of the Nx CLI. Use "npx ng [command]" or "nx [command]" instead.' }); 56 | } 57 | ${angularCLIInit} 58 | ` 59 | ); 60 | } 61 | } 62 | 63 | /** 64 | * Symlink of ng to nx, so you can keep using `ng build/test/lint` and still 65 | * invoke the Nx CLI and get the benefits of computation caching. 66 | */ 67 | function symlinkNgCLItoNxCLI() { 68 | try { 69 | const ngPath = './node_modules/.bin/ng'; 70 | const nxPath = './node_modules/.bin/nx'; 71 | if (isWindows) { 72 | /** 73 | * This is the most reliable way to create symlink-like behavior on Windows. 74 | * Such that it works in all shells and works with npx. 75 | */ 76 | ['', '.cmd', '.ps1'].forEach(ext => { 77 | if (fs.existsSync(nxPath + ext)) 78 | fs.writeFileSync(ngPath + ext, fs.readFileSync(nxPath + ext)); 79 | }); 80 | } else { 81 | // If unix-based, symlink 82 | cp.execSync(`ln -sf ./nx ${ngPath}`); 83 | } 84 | } catch (e) { 85 | output.error({ 86 | title: 'Unable to create a symlink from the Angular CLI to the Nx CLI:' + e.message 87 | }); 88 | throw e; 89 | } 90 | } 91 | 92 | try { 93 | symlinkNgCLItoNxCLI(); 94 | patchAngularCLI(angularCLIInitPath); 95 | output.log({ 96 | title: 'Angular CLI has been decorated to enable computation caching.' 97 | }); 98 | } catch (e) { 99 | output.error({ 100 | title: 'Decoration of the Angular CLI did not complete successfully' 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | const { getJestProjects } = require('@nrwl/jest'); 2 | 3 | export default { 4 | projects: getJestProjects() 5 | }; 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nrwl/jest/preset').default; 2 | 3 | module.exports = { 4 | ...nxPreset, 5 | testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'], 6 | transform: { 7 | '^.+\\.(ts|js|html)$': 'ts-jest' 8 | }, 9 | resolver: '@nrwl/jest/plugins/resolver', 10 | moduleFileExtensions: ['ts', 'js', 'html'], 11 | coverageReporters: ['html', 'lcov'], 12 | /* TODO: Update to latest Jest snapshotFormat 13 | * By default Nx has kept the older style of Jest Snapshot formats 14 | * to prevent breaking of any existing tests with snapshots. 15 | * It's recommend you update to the latest format. 16 | * You can do this by removing snapshotFormat property 17 | * and running tests with --update-snapshot flag. 18 | * Example: "nx affected --targets=test --update-snapshot" 19 | * More info: https://jestjs.io/docs/upgrading-to-jest29#snapshot-format 20 | */ 21 | snapshotFormat: { escapeString: true, printBasicPrototype: true } 22 | }; 23 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nrwl/nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/no-output-native": ["off"], 13 | "@typescript-eslint/no-non-null-assertion": ["off"], 14 | "@nrwl/nx/enforce-module-boundaries": [ 15 | "error", 16 | { 17 | "allow": [] 18 | } 19 | ] 20 | } 21 | }, 22 | { 23 | "files": ["*.html"], 24 | "extends": ["plugin:@nrwl/nx/angular-template"], 25 | "rules": {} 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'dispatch-decorator', 4 | preset: '../../jest.preset.js', 5 | setupFilesAfterEnv: ['/src/test-setup.ts'], 6 | transform: { 7 | '^.+\\.(ts|mjs|js|html)$': [ 8 | 'jest-preset-angular', 9 | { 10 | isolatedModules: true, 11 | tsconfig: '/tsconfig.spec.json', 12 | stringifyContentPathRegex: '\\.(html|svg)$' 13 | } 14 | ] 15 | }, 16 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 17 | snapshotSerializers: [ 18 | 'jest-preset-angular/build/serializers/no-ng-attributes', 19 | 'jest-preset-angular/build/serializers/ng-snapshot', 20 | 'jest-preset-angular/build/serializers/html-comment' 21 | ] 22 | }; 23 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/libs/dispatch-decorator", 4 | "lib": { 5 | "entryFile": "src/index.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ngxs-labs/dispatch-decorator", 3 | "version": "5.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/ngxs-labs/dispatch-decorator.git" 7 | }, 8 | "license": "MIT", 9 | "homepage": "https://github.com/ngxs-labs/dispatch-decorator#readme", 10 | "bugs": { 11 | "url": "https://github.com/ngxs-labs/dispatch-decorator/issues" 12 | }, 13 | "keywords": [ 14 | "ngxs", 15 | "redux", 16 | "store" 17 | ], 18 | "sideEffects": false, 19 | "peerDependencies": { 20 | "@angular/core": ">=15.0.0", 21 | "@ngxs/store": ">=3.7.2", 22 | "rxjs": ">=6.1.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dispatch-decorator", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "library", 5 | "sourceRoot": "libs/dispatch-decorator/src", 6 | "targets": { 7 | "build": { 8 | "executor": "@nrwl/angular:package", 9 | "options": { 10 | "tsConfig": "libs/dispatch-decorator/tsconfig.lib.json", 11 | "project": "libs/dispatch-decorator/ng-package.json" 12 | } 13 | }, 14 | "lint": { 15 | "executor": "@nrwl/linter:eslint", 16 | "options": { 17 | "lintFilePatterns": [ 18 | "libs/dispatch-decorator/src/**/*.ts", 19 | "libs/dispatch-decorator/src/**/*.html" 20 | ] 21 | }, 22 | "outputs": ["{options.outputFile}"] 23 | }, 24 | "test": { 25 | "executor": "@nrwl/jest:jest", 26 | "options": { 27 | "jestConfig": "libs/dispatch-decorator/jest.config.ts", 28 | "passWithNoTests": true 29 | } 30 | } 31 | }, 32 | "type": ["lib"] 33 | } 34 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Dispatch } from './lib/decorators/dispatch'; 2 | export { NgxsDispatchPluginModule } from './lib/dispatch.module'; 3 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/decorators/dispatch.ts: -------------------------------------------------------------------------------- 1 | import { NgZone } from '@angular/core'; 2 | import { Store } from '@ngxs/store'; 3 | 4 | import { unwrapAndDispatch } from '../internals/unwrap'; 5 | import { DispatchOptions } from '../internals/internals'; 6 | import { getNgZone, getStore } from '../internals/static-injector'; 7 | import { createActionCompleter } from '../internals/action-completer'; 8 | import { ensureLocalInjectorCaptured, localInject } from '../internals/decorator-injector-adapter'; 9 | 10 | const defaultOptions: DispatchOptions = { cancelUncompleted: false }; 11 | 12 | export function Dispatch(options = defaultOptions): PropertyDecorator { 13 | return ( 14 | // eslint-disable-next-line @typescript-eslint/ban-types 15 | target: Object, 16 | propertyKey: string | symbol, 17 | // eslint-disable-next-line @typescript-eslint/ban-types 18 | descriptor?: TypedPropertyDescriptor 19 | ) => { 20 | // eslint-disable-next-line @typescript-eslint/ban-types 21 | let originalValue: Function; 22 | 23 | const actionCompleter = createActionCompleter(options.cancelUncompleted!); 24 | 25 | function wrapped(this: ThisType) { 26 | // Every time the function is invoked we have to generate event 27 | // to cancel previously uncompleted asynchronous job 28 | if (actionCompleter !== null) { 29 | actionCompleter.cancelPreviousAction(); 30 | } 31 | 32 | const store = localInject(this, Store) || getStore(); 33 | const ngZone = localInject(this, NgZone) || getNgZone(); 34 | // eslint-disable-next-line prefer-rest-params 35 | const wrapped = originalValue.apply(this, arguments); 36 | 37 | return ngZone.runOutsideAngular(() => unwrapAndDispatch(store, wrapped, actionCompleter)); 38 | } 39 | 40 | if (typeof descriptor?.value === 'function') { 41 | originalValue = descriptor.value!; 42 | descriptor.value = wrapped; 43 | } else { 44 | Object.defineProperty(target, propertyKey, { 45 | set: value => (originalValue = value), 46 | get: () => wrapped 47 | }); 48 | } 49 | 50 | ensureLocalInjectorCaptured(target); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/dispatch.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders, NgModuleRef } from '@angular/core'; 2 | 3 | import { setInjector } from './internals/static-injector'; 4 | 5 | @NgModule() 6 | export class NgxsDispatchPluginModule { 7 | constructor(ngModuleRef: NgModuleRef) { 8 | setInjector(ngModuleRef.injector); 9 | ngModuleRef.onDestroy(() => { 10 | setInjector(null); 11 | }); 12 | } 13 | 14 | static forRoot(): ModuleWithProviders { 15 | return { 16 | ngModule: NgxsDispatchPluginModule 17 | }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/internals/action-completer.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | 3 | export class ActionCompleter { 4 | cancelUncompleted$ = new Subject(); 5 | 6 | cancelPreviousAction(): void { 7 | this.cancelUncompleted$.next(); 8 | } 9 | } 10 | 11 | export function createActionCompleter(cancelUncompleted: boolean): ActionCompleter | null { 12 | return cancelUncompleted ? new ActionCompleter() : null; 13 | } 14 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/internals/decorator-injector-adapter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InjectionToken, 3 | Injector, 4 | INJECTOR, 5 | Type, 6 | ɵɵdirectiveInject, 7 | ɵglobal 8 | } from '@angular/core'; 9 | 10 | // Will be provided through Terser global definitions by Angular CLI 11 | // during the production build. This is how Angular does tree-shaking internally. 12 | declare const ngDevMode: boolean; 13 | 14 | // Angular doesn't export `NG_FACTORY_DEF`. 15 | const NG_FACTORY_DEF = 'ɵfac'; 16 | 17 | // A `Symbol` which is used to save the `Injector` onto the class instance. 18 | const InjectorInstance: unique symbol = Symbol('InjectorInstance'); 19 | 20 | // A `Symbol` which is used to determine if factory has been decorated previously or not. 21 | const FactoryHasBeenDecorated: unique symbol = Symbol('FactoryHasBeenDecorated'); 22 | 23 | // eslint-disable-next-line @typescript-eslint/ban-types 24 | export function ensureLocalInjectorCaptured(target: Object): void { 25 | if (FactoryHasBeenDecorated in target.constructor.prototype) { 26 | return; 27 | } 28 | 29 | const constructor: ConstructorWithDefinitionAndFactory = target.constructor; 30 | // Means we're in AOT mode. 31 | if (typeof constructor[NG_FACTORY_DEF] === 'function') { 32 | decorateFactory(constructor); 33 | } else if (ngDevMode) { 34 | // We're running in JIT mode and that means we're not able to get the compiled definition 35 | // on the class inside the property decorator during the current message loop tick. We have 36 | // to wait for the next message loop tick. Note that this is safe since this Promise will be 37 | // resolved even before the `APP_INITIALIZER` is resolved. 38 | // The below code also will be executed only in development mode, since it's never recommended 39 | // to use the JIT compiler in production mode (by setting "aot: false"). 40 | decorateFactoryLater(constructor); 41 | } 42 | 43 | target.constructor.prototype[FactoryHasBeenDecorated] = true; 44 | } 45 | 46 | export function localInject( 47 | instance: PrivateInstance, 48 | token: InjectionToken | Type 49 | ): T | null { 50 | const injector: Injector | undefined = instance[InjectorInstance]; 51 | return injector ? injector.get(token) : null; 52 | } 53 | 54 | function decorateFactory(constructor: ConstructorWithDefinitionAndFactory): void { 55 | const factory = constructor[NG_FACTORY_DEF]; 56 | 57 | if (typeof factory !== 'function') { 58 | return; 59 | } 60 | 61 | // Let's try to get any definition. 62 | // Caretaker note: this will be compatible only with Angular 9+, since Angular 9 is the first 63 | // Ivy-stable version. Previously definition properties were named differently (e.g. `ngComponentDef`). 64 | const def = constructor.ɵprov || constructor.ɵpipe || constructor.ɵcmp || constructor.ɵdir; 65 | 66 | const decoratedFactory = () => { 67 | const instance = factory(); 68 | // Caretaker note: `inject()` won't work here. 69 | // We can use the `directiveInject` only during the component 70 | // construction, since Angular captures the currently active injector. 71 | // We're not able to use this function inside the getter (when the `selectorId` property is 72 | // requested for the first time), since the currently active injector will be null. 73 | instance[InjectorInstance] = ɵɵdirectiveInject( 74 | // We're using `INJECTOR` token except of the `Injector` class since the compiler 75 | // throws: `Cannot assign an abstract constructor type to a non-abstract constructor type.`. 76 | // Caretaker note: that this is the same way of getting the injector. 77 | INJECTOR 78 | ); 79 | return instance; 80 | }; 81 | 82 | if (def) { 83 | def.factory = decoratedFactory; 84 | } 85 | 86 | Object.defineProperty(constructor, NG_FACTORY_DEF, { 87 | get: () => decoratedFactory 88 | }); 89 | } 90 | 91 | function decorateFactoryLater(constructor: ConstructorWithDefinitionAndFactory): void { 92 | // This function actually will be tree-shaken away when building for production since it's guarded with `ngDevMode`. 93 | // We're having the `try-catch` here because of the `SyncTestZoneSpec`, which throws 94 | // an error when micro or macrotask is used within a synchronous test. E.g. `Cannot call 95 | // Promise.then from within a sync test`. 96 | try { 97 | Promise.resolve().then(() => { 98 | decorateFactory(constructor); 99 | }); 100 | } catch { 101 | // This is kind of a "hack", but we try to be backwards-compatible, 102 | // tho this `catch` block will only be executed when tests are run with Jasmine or Jest. 103 | ɵglobal.process && 104 | ɵglobal.process.nextTick && 105 | ɵglobal.process.nextTick(() => { 106 | decorateFactory(constructor); 107 | }); 108 | } 109 | } 110 | 111 | // We could've used `ɵɵFactoryDef` but we try to be backwards-compatible, 112 | // since it's not exported in older Angular versions. 113 | type Factory = () => PrivateInstance; 114 | 115 | // We could've used `ɵɵInjectableDef`, `ɵɵPipeDef`, etc. We try to be backwards-compatible 116 | // since they're not exported in older Angular versions. 117 | interface Definition { 118 | factory: Factory | null; 119 | } 120 | 121 | interface ConstructorWithDefinitionAndFactory extends Function { 122 | // Provider definition for the `@Injectable()` class. 123 | ɵprov?: Definition; 124 | // Pipe definition for the `@Pipe()` class. 125 | ɵpipe?: Definition; 126 | // Component definition for the `@Component()` class. 127 | ɵcmp?: Definition; 128 | // Directive definition for the `@Directive()` class. 129 | ɵdir?: Definition; 130 | [NG_FACTORY_DEF]?: Factory; 131 | } 132 | 133 | interface PrivateInstance { 134 | [InjectorInstance]?: Injector; 135 | } 136 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/internals/internals.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export type Action = new (payload?: T) => any; 4 | 5 | export type DispatchFactory = (actionOrActions: ActionOrActions) => void; 6 | 7 | export type ActionOrActions = Action | Action[]; 8 | 9 | /** 10 | * This can be a plain action/actions or Promisified/streamifed action/actions 11 | * ```typescript 12 | * @Dispatch() increment = () => new Increment(); 13 | * // OR 14 | * @Dispatch() increment = () => Promise.resolve(new Increment()); 15 | * ``` 16 | */ 17 | export type Wrapped = ActionOrActions | Observable | Promise; 18 | 19 | export interface DispatchOptions { 20 | cancelUncompleted?: boolean; 21 | } 22 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/internals/static-injector.ts: -------------------------------------------------------------------------------- 1 | import { Injector, NgZone } from '@angular/core'; 2 | import { Store } from '@ngxs/store'; 3 | 4 | class NgxsDispatchPluginModuleNotImported extends Error { 5 | override message = 'NgxsDispatchPluginModule is not imported'; 6 | } 7 | 8 | let _injector: Injector | null = null; 9 | 10 | export function setInjector(injector: Injector | null): void { 11 | _injector = injector; 12 | } 13 | 14 | export function getStore(): never | Store { 15 | if (_injector === null) { 16 | throw new NgxsDispatchPluginModuleNotImported(); 17 | } else { 18 | return _injector.get(Store); 19 | } 20 | } 21 | 22 | export function getNgZone(): never | NgZone { 23 | if (_injector === null) { 24 | throw new NgxsDispatchPluginModuleNotImported(); 25 | } else { 26 | return _injector.get(NgZone); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/internals/unwrap.ts: -------------------------------------------------------------------------------- 1 | import { ɵisPromise } from '@angular/core'; 2 | import { Store } from '@ngxs/store'; 3 | import { isObservable, Observable } from 'rxjs'; 4 | import { takeUntil } from 'rxjs/operators'; 5 | 6 | import { ActionCompleter } from './action-completer'; 7 | import { Wrapped, ActionOrActions } from './internals'; 8 | 9 | function unwrapObservable( 10 | store: Store, 11 | wrapped: Observable, 12 | actionCompleter: ActionCompleter | null 13 | ): Observable { 14 | if (actionCompleter !== null) { 15 | wrapped = wrapped.pipe(takeUntil(actionCompleter.cancelUncompleted$)); 16 | } 17 | 18 | wrapped.subscribe({ 19 | next: actionOrActions => store.dispatch(actionOrActions) 20 | }); 21 | 22 | return wrapped; 23 | } 24 | 25 | function unwrapPromise(store: Store, wrapped: Promise): Promise { 26 | return wrapped.then(actionOrActions => { 27 | store.dispatch(actionOrActions); 28 | return actionOrActions; 29 | }); 30 | } 31 | 32 | export function unwrapAndDispatch( 33 | store: Store, 34 | wrapped: Wrapped, 35 | actionCompleter: ActionCompleter | null 36 | ) { 37 | if (ɵisPromise(wrapped)) { 38 | return unwrapPromise(store, wrapped); 39 | } else if (isObservable(wrapped)) { 40 | return unwrapObservable(store, wrapped, actionCompleter); 41 | } else { 42 | store.dispatch(wrapped); 43 | return wrapped; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/tests/dispatch.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { TestBed } from '@angular/core/testing'; 4 | import { Injectable, NgZone } from '@angular/core'; 5 | import { NgxsModule, State, Action, Store, StateContext } from '@ngxs/store'; 6 | 7 | import { of, timer } from 'rxjs'; 8 | import { delay, concatMapTo, mapTo } from 'rxjs/operators'; 9 | 10 | import { Dispatch } from '../decorators/dispatch'; 11 | import { NgxsDispatchPluginModule } from '../dispatch.module'; 12 | 13 | describe(NgxsDispatchPluginModule.name, () => { 14 | class Increment { 15 | static readonly type = '[Counter] Increment'; 16 | } 17 | 18 | class Decrement { 19 | static readonly type = '[Counter] Decrement'; 20 | } 21 | 22 | @State({ 23 | name: 'counter', 24 | defaults: 0 25 | }) 26 | @Injectable() 27 | class CounterState { 28 | @Action(Increment) 29 | increment(ctx: StateContext) { 30 | ctx.setState(state => state + 1); 31 | } 32 | 33 | @Action(Decrement) 34 | decrement(ctx: StateContext) { 35 | ctx.setState(state => state - 1); 36 | } 37 | } 38 | 39 | it('should be possible to dispatch events using @Dispatch() decorator', () => { 40 | // Arrange 41 | class CounterFacade { 42 | @Dispatch() increment = () => new Increment(); 43 | } 44 | 45 | // Act 46 | TestBed.configureTestingModule({ 47 | imports: [ 48 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 49 | NgxsDispatchPluginModule.forRoot() 50 | ] 51 | }); 52 | 53 | const facade = new CounterFacade(); 54 | const store: Store = TestBed.inject(Store); 55 | 56 | facade.increment(); 57 | 58 | const counter: number = store.selectSnapshot(CounterState); 59 | // Assert 60 | expect(counter).toBe(1); 61 | }); 62 | 63 | it('should be possible to dispatch plain objects using @Dispatch() decorator', () => { 64 | // Arrange 65 | class CounterFacade { 66 | @Dispatch() increment = () => ({ 67 | type: '[Counter] Increment' 68 | }); 69 | } 70 | 71 | // Act 72 | TestBed.configureTestingModule({ 73 | imports: [ 74 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 75 | NgxsDispatchPluginModule.forRoot() 76 | ] 77 | }); 78 | 79 | const facade = new CounterFacade(); 80 | const store: Store = TestBed.inject(Store); 81 | 82 | facade.increment(); 83 | 84 | const counter: number = store.selectSnapshot(CounterState); 85 | // Assert 86 | expect(counter).toBe(1); 87 | }); 88 | 89 | it('should dispatch if method returns a `Promise`', async () => { 90 | // Arrange 91 | class CounterFacade { 92 | @Dispatch() incrementAsync = () => Promise.resolve(new Increment()); 93 | } 94 | 95 | // Act 96 | TestBed.configureTestingModule({ 97 | imports: [ 98 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 99 | NgxsDispatchPluginModule.forRoot() 100 | ] 101 | }); 102 | 103 | const facade = new CounterFacade(); 104 | const store: Store = TestBed.inject(Store); 105 | 106 | await facade.incrementAsync(); 107 | 108 | const counter: number = store.selectSnapshot(CounterState); 109 | // Assert 110 | expect(counter).toBe(1); 111 | }); 112 | 113 | it('should dispatch if method returns an `Observable`', async () => { 114 | // Arrange 115 | class CounterFacade { 116 | @Dispatch() incrementAsync = () => of(new Increment()).pipe(delay(1000)); 117 | } 118 | 119 | // Act 120 | TestBed.configureTestingModule({ 121 | imports: [ 122 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 123 | NgxsDispatchPluginModule.forRoot() 124 | ] 125 | }); 126 | 127 | const facade = new CounterFacade(); 128 | const store: Store = TestBed.inject(Store); 129 | 130 | await facade.incrementAsync().toPromise(); 131 | 132 | const counter: number = store.selectSnapshot(CounterState); 133 | // Assert 134 | expect(counter).toBe(1); 135 | }); 136 | 137 | it('events should be handled outside of Angular zone but dispatched within', async () => { 138 | // Arrange 139 | function delay(timeout: number): Promise { 140 | return new Promise(resolve => setTimeout(resolve, timeout)); 141 | } 142 | 143 | class CounterFacade { 144 | @Dispatch() incrementAsync = async () => { 145 | await delay(200); 146 | expect(NgZone.isInAngularZone()).toBeFalsy(); 147 | await delay(200); 148 | return new Increment(); 149 | }; 150 | } 151 | 152 | // Act 153 | TestBed.configureTestingModule({ 154 | imports: [ 155 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 156 | NgxsDispatchPluginModule.forRoot() 157 | ] 158 | }); 159 | 160 | const facade = new CounterFacade(); 161 | const store: Store = TestBed.inject(Store); 162 | 163 | await facade.incrementAsync(); 164 | 165 | const counter: number = store.selectSnapshot(CounterState); 166 | // Assert 167 | expect(counter).toBe(1); 168 | }); 169 | 170 | it('should be possible to dispatch an array of events', () => { 171 | // Arrange 172 | class CounterFacade { 173 | @Dispatch() increment = () => [new Increment(), new Increment()]; 174 | } 175 | 176 | // Act 177 | TestBed.configureTestingModule({ 178 | imports: [ 179 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 180 | NgxsDispatchPluginModule.forRoot() 181 | ] 182 | }); 183 | 184 | const facade = new CounterFacade(); 185 | const store: Store = TestBed.inject(Store); 186 | 187 | facade.increment(); 188 | 189 | const counter: number = store.selectSnapshot(CounterState); 190 | // Assert 191 | expect(counter).toBe(2); 192 | }); 193 | 194 | it('should be possible to use queue of events', () => { 195 | // Arrange 196 | class CounterFacade { 197 | @Dispatch() increment = () => of(null).pipe(concatMapTo([new Increment(), new Increment()])); 198 | } 199 | 200 | // Act 201 | TestBed.configureTestingModule({ 202 | imports: [ 203 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 204 | NgxsDispatchPluginModule.forRoot() 205 | ] 206 | }); 207 | 208 | const facade = new CounterFacade(); 209 | const store: Store = TestBed.inject(Store); 210 | 211 | facade.increment(); 212 | 213 | const counter: number = store.selectSnapshot(CounterState); 214 | // Assert 215 | expect(counter).toBe(2); 216 | }); 217 | 218 | it('should be possible to access instance properties', () => { 219 | // Arrange 220 | abstract class BaseCounterFacade { 221 | private action = new Increment(); 222 | 223 | protected getAction() { 224 | return this.action; 225 | } 226 | } 227 | 228 | class CounterFacade extends BaseCounterFacade { 229 | @Dispatch() increment() { 230 | return this.getAction(); 231 | } 232 | 233 | @Dispatch() incrementLambda = () => this.getAction(); 234 | } 235 | 236 | // Act 237 | TestBed.configureTestingModule({ 238 | imports: [ 239 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 240 | NgxsDispatchPluginModule.forRoot() 241 | ] 242 | }); 243 | 244 | const facade = new CounterFacade(); 245 | const store: Store = TestBed.inject(Store); 246 | 247 | facade.increment(); 248 | facade.incrementLambda(); 249 | 250 | const counter: number = store.selectSnapshot(CounterState); 251 | // Assert 252 | expect(counter).toBe(2); 253 | }); 254 | 255 | it('should not dispatch multiple times if subscribed underneath and directly', async () => { 256 | // Arrange 257 | class CounterFacade { 258 | @Dispatch() incrementAsync = () => timer(0).pipe(mapTo(new Increment())); 259 | @Dispatch() decrementAsync = () => timer(0).pipe(mapTo(new Decrement())); 260 | } 261 | 262 | // Act 263 | TestBed.configureTestingModule({ 264 | imports: [ 265 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 266 | NgxsDispatchPluginModule.forRoot() 267 | ] 268 | }); 269 | 270 | const facade = new CounterFacade(); 271 | const store: Store = TestBed.inject(Store); 272 | 273 | // `toPromise` causes to `subscribe` under the hood 274 | await facade.incrementAsync().toPromise(); 275 | await facade.decrementAsync().toPromise(); 276 | 277 | const counter = store.selectSnapshot(CounterState); 278 | // Assert 279 | expect(counter).toBe(0); 280 | }); 281 | 282 | it('should cancel previously uncompleted asynchronous operations', async () => { 283 | // Arrange 284 | class CounterFacade { 285 | @Dispatch({ cancelUncompleted: true }) increment = () => 286 | timer(500).pipe(mapTo(new Increment())); 287 | } 288 | 289 | // Act 290 | TestBed.configureTestingModule({ 291 | imports: [ 292 | NgxsModule.forRoot([CounterState], { developmentMode: true }), 293 | NgxsDispatchPluginModule.forRoot() 294 | ] 295 | }); 296 | 297 | const facade = new CounterFacade(); 298 | const store: Store = TestBed.inject(Store); 299 | 300 | facade.increment(); 301 | facade.increment(); 302 | await facade.increment().toPromise(); 303 | 304 | const counter = store.selectSnapshot(CounterState); 305 | // Assert 306 | expect(counter).toBe(1); 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/tests/fresh-platform.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { DOCUMENT } from '@angular/common'; 3 | import { ɵgetDOM as getDOM } from '@angular/platform-browser'; 4 | import { destroyPlatform, createPlatform } from '@angular/core'; 5 | 6 | function createRootElement() { 7 | const document = TestBed.inject(DOCUMENT); 8 | const root = getDOM().createElement('app-root', document); 9 | document.body.appendChild(root); 10 | } 11 | 12 | function removeRootElement() { 13 | const root: Element = document.getElementsByTagName('app-root').item(0)!; 14 | try { 15 | document.body.removeChild(root); 16 | // eslint-disable-next-line no-empty 17 | } catch {} 18 | } 19 | 20 | function destroyPlatformBeforeBootstrappingTheNewOne() { 21 | destroyPlatform(); 22 | createRootElement(); 23 | } 24 | 25 | // As we create our custom platform via `bootstrapModule` 26 | // we have to destroy it after assetions and revert 27 | // the previous one 28 | function resetPlatformAfterBootstrapping() { 29 | removeRootElement(); 30 | destroyPlatform(); 31 | createPlatform(TestBed); 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/ban-types 35 | export function freshPlatform(fn: Function): (...args: any[]) => any { 36 | return async function testWithAFreshPlatform(this: any, ...args: any[]) { 37 | try { 38 | destroyPlatformBeforeBootstrappingTheNewOne(); 39 | return await fn.apply(this, args); 40 | } finally { 41 | resetPlatformAfterBootstrapping(); 42 | } 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/tests/parallel-modules.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, Injectable, NgModule, ɵivyEnabled } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | import { Action, NgxsModule, State, StateContext, Store } from '@ngxs/store'; 5 | 6 | import { Dispatch } from '../decorators/dispatch'; 7 | 8 | import { freshPlatform } from './fresh-platform'; 9 | import { skipConsoleLogging } from './skip-console-logging'; 10 | 11 | describe('Parallel modules', () => { 12 | if (!ɵivyEnabled) { 13 | throw new Error('This test requires Ivy to be enabled.'); 14 | } 15 | 16 | class Increment { 17 | static readonly type = '[Counter] Increment'; 18 | } 19 | 20 | class Decrement { 21 | static readonly type = '[Counter] Decrement'; 22 | } 23 | 24 | @State({ 25 | name: 'counter', 26 | defaults: 0 27 | }) 28 | @Injectable() 29 | class CounterState { 30 | @Action(Increment) 31 | increment(ctx: StateContext) { 32 | ctx.setState(state => state + 1); 33 | } 34 | 35 | @Action(Decrement) 36 | decrement(ctx: StateContext) { 37 | ctx.setState(state => state - 1); 38 | } 39 | } 40 | 41 | it( 42 | 'should be possible to bootstrap modules in parallel like in server-side environment', 43 | freshPlatform(async () => { 44 | // Arrange & act 45 | @Injectable() 46 | class CounterFacade { 47 | @Dispatch() increment = () => new Increment(); 48 | } 49 | 50 | @Component({ selector: 'app-root', template: '' }) 51 | class TestComponent {} 52 | 53 | @NgModule({ 54 | imports: [BrowserModule, NgxsModule.forRoot([CounterState], { developmentMode: true })], 55 | declarations: [TestComponent], 56 | bootstrap: [TestComponent], 57 | providers: [CounterFacade] 58 | }) 59 | class TestModule {} 60 | 61 | const platform = platformBrowserDynamic(); 62 | 63 | // Now let's bootstrap 2 different apps in parallel, this is basically the same what 64 | // Angular Universal does internally for concurrent HTTP requests. 65 | const [firstNgModuleRef, secondNgModuleRef] = await skipConsoleLogging(() => 66 | Promise.all([platform.bootstrapModule(TestModule), platform.bootstrapModule(TestModule)]) 67 | ); 68 | 69 | const firstStore = firstNgModuleRef.injector.get(Store); 70 | const secondStore = secondNgModuleRef.injector.get(Store); 71 | 72 | const firstCounterFacade = firstNgModuleRef.injector.get(CounterFacade); 73 | const secondCounterFacade = secondNgModuleRef.injector.get(CounterFacade); 74 | 75 | firstCounterFacade.increment(); 76 | firstCounterFacade.increment(); 77 | 78 | // Assert 79 | expect(firstStore.selectSnapshot(CounterState)).toEqual(2); 80 | expect(secondStore.selectSnapshot(CounterState)).toEqual(0); 81 | 82 | secondCounterFacade.increment(); 83 | secondCounterFacade.increment(); 84 | secondCounterFacade.increment(); 85 | 86 | expect(firstStore.selectSnapshot(CounterState)).toEqual(2); 87 | expect(secondStore.selectSnapshot(CounterState)).toEqual(3); 88 | 89 | firstNgModuleRef.destroy(); 90 | 91 | secondCounterFacade.increment(); 92 | 93 | expect(firstStore.selectSnapshot(CounterState)).toEqual(2); 94 | expect(secondStore.selectSnapshot(CounterState)).toEqual(4); 95 | }) 96 | ); 97 | }); 98 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/lib/tests/skip-console-logging.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | function createFn() { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | return () => {}; 6 | } 7 | 8 | export function skipConsoleLogging any>(fn: T): ReturnType { 9 | const consoleSpies = [ 10 | jest.spyOn(console, 'log').mockImplementation(createFn()), 11 | jest.spyOn(console, 'warn').mockImplementation(createFn()), 12 | jest.spyOn(console, 'error').mockImplementation(createFn()), 13 | jest.spyOn(console, 'info').mockImplementation(createFn()) 14 | ]; 15 | function restoreSpies() { 16 | consoleSpies.forEach(spy => spy.mockRestore()); 17 | } 18 | let restoreSpyAsync = false; 19 | try { 20 | const returnValue = fn(); 21 | if (returnValue instanceof Promise) { 22 | restoreSpyAsync = true; 23 | return returnValue.finally(() => restoreSpies()) as ReturnType; 24 | } 25 | return returnValue; 26 | } finally { 27 | if (!restoreSpyAsync) { 28 | restoreSpies(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "references": [ 4 | { 5 | "path": "./tsconfig.lib.json" 6 | }, 7 | { 8 | "path": "./tsconfig.spec.json" 9 | } 10 | ], 11 | "compilerOptions": { 12 | "target": "es2020" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "module": "es2015", 6 | "inlineSources": true, 7 | "importHelpers": true, 8 | "downlevelIteration": true, 9 | "lib": ["dom", "es2018"], 10 | "types": ["node"], 11 | "useDefineForClassFields": false 12 | }, 13 | "angularCompilerOptions": { 14 | "compilationMode": "partial", 15 | "annotateForClosureCompiler": true, 16 | "skipTemplateCodegen": true, 17 | "strictMetadataEmit": true, 18 | "fullTemplateTypeCheck": true, 19 | "strictInjectionParameters": true, 20 | "enableResourceInlining": true 21 | }, 22 | "include": ["**/*.ts"], 23 | "exclude": ["**/*.spec.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /libs/dispatch-decorator/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "files": ["src/test-setup.ts"], 9 | "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "affected": { 3 | "defaultBase": "master" 4 | }, 5 | "npmScope": "dispatch-decorator", 6 | "tasksRunnerOptions": { 7 | "default": { 8 | "runner": "nx/tasks-runners/default", 9 | "options": { 10 | "cacheableOperations": ["build", "lint", "test", "e2e"], 11 | "parallel": 1 12 | } 13 | } 14 | }, 15 | "defaultProject": "dispatch-decorator", 16 | "generators": { 17 | "@nrwl/angular:application": { 18 | "linter": "eslint", 19 | "unitTestRunner": "jest" 20 | }, 21 | "@nrwl/angular:library": { 22 | "linter": "eslint", 23 | "unitTestRunner": "jest" 24 | }, 25 | "@nrwl/angular:component": {} 26 | }, 27 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 28 | "targetDefaults": { 29 | "build": { 30 | "dependsOn": ["^build"], 31 | "inputs": ["production", "^production"] 32 | }, 33 | "e2e": { 34 | "inputs": ["default", "^production"] 35 | }, 36 | "test": { 37 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"] 38 | }, 39 | "lint": { 40 | "inputs": ["default", "{workspaceRoot}/.eslintrc.json"] 41 | } 42 | }, 43 | "namedInputs": { 44 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 45 | "sharedGlobals": [ 46 | "{workspaceRoot}/angular.json", 47 | "{workspaceRoot}/tsconfig.json", 48 | "{workspaceRoot}/tslint.json", 49 | "{workspaceRoot}/nx.json" 50 | ], 51 | "production": [ 52 | "default", 53 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 54 | "!{projectRoot}/tsconfig.spec.json", 55 | "!{projectRoot}/jest.config.[jt]s", 56 | "!{projectRoot}/.eslintrc.json" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dispatch", 3 | "version": "0.0.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/ngxs-labs/dispatch.git" 7 | }, 8 | "license": "MIT", 9 | "homepage": "https://github.com/ngxs-labs/dispatch#readme", 10 | "bugs": { 11 | "url": "https://github.com/ngxs-labs/dispatch/issues" 12 | }, 13 | "keywords": [ 14 | "ngxs", 15 | "redux", 16 | "store" 17 | ], 18 | "scripts": { 19 | "prebuild": "nx build dispatch-decorator --skip-nx-cache", 20 | "build": "yarn prebuild && yarn postbuild", 21 | "postbuild": "cpy README.md dist/libs/dispatch-decorator", 22 | "test": "nx test dispatch-decorator", 23 | "e2e": "nx e2e demos-e2e" 24 | }, 25 | "private": true, 26 | "devDependencies": { 27 | "@angular-devkit/build-angular": "15.1.1", 28 | "@angular-devkit/core": "15.1.1", 29 | "@angular-devkit/schematics": "15.1.1", 30 | "@angular-eslint/eslint-plugin": "15.0.0", 31 | "@angular-eslint/eslint-plugin-template": "15.0.0", 32 | "@angular-eslint/template-parser": "15.0.0", 33 | "@angular/animations": "15.1.0", 34 | "@angular/cli": "15.1.1", 35 | "@angular/common": "15.1.0", 36 | "@angular/compiler": "15.1.0", 37 | "@angular/compiler-cli": "15.1.0", 38 | "@angular/core": "15.1.0", 39 | "@angular/platform-browser": "15.1.0", 40 | "@angular/platform-browser-dynamic": "15.1.0", 41 | "@angular/platform-server": "15.1.0", 42 | "@commitlint/cli": "^17.5.0", 43 | "@commitlint/config-conventional": "^17.4.4", 44 | "@nguniversal/builders": "15.1.0", 45 | "@nguniversal/express-engine": "15.1.0", 46 | "@ngxs/store": "3.7.6", 47 | "@nrwl/angular": "15.8.9", 48 | "@nrwl/cli": "15.8.9", 49 | "@nrwl/cypress": "15.8.9", 50 | "@nrwl/eslint-plugin-nx": "15.8.9", 51 | "@nrwl/jest": "15.8.9", 52 | "@nrwl/linter": "15.8.9", 53 | "@nrwl/workspace": "15.8.9", 54 | "@schematics/angular": "15.1.1", 55 | "@types/express": "^4.17.2", 56 | "@types/jest": "29.4.4", 57 | "@types/node": "^16.11.7", 58 | "@typescript-eslint/eslint-plugin": "5.43.0", 59 | "@typescript-eslint/parser": "5.43.0", 60 | "cpy-cli": "^3.1.1", 61 | "cypress": "^10.0.0", 62 | "eslint": "8.15.0", 63 | "eslint-config-prettier": "^8.3.0", 64 | "eslint-plugin-cypress": "^2.12.1", 65 | "express": "^4.17.1", 66 | "husky": "^8.0.0", 67 | "jest": "29.4.3", 68 | "jest-environment-jsdom": "29.4.3", 69 | "jest-preset-angular": "13.0.0", 70 | "lint-staged": "^13.0.0", 71 | "ng-packagr": "~15.1.0", 72 | "nx": "15.8.9", 73 | "postcss": "8.4.16", 74 | "postcss-import": "14.1.0", 75 | "postcss-preset-env": "7.5.0", 76 | "postcss-url": "10.1.3", 77 | "prettier": "2.7.1", 78 | "rxjs": "^7.5.4", 79 | "ts-jest": "29.0.5", 80 | "ts-node": "^10.0.0", 81 | "tslib": "^2.3.1", 82 | "typescript": "4.8.4", 83 | "zone.js": "0.12.0" 84 | }, 85 | "lint-staged": { 86 | "*.{js,ts,html,md}": [ 87 | "prettier --write" 88 | ] 89 | }, 90 | "prettier": { 91 | "semi": true, 92 | "endOfLine": "lf", 93 | "arrowParens": "avoid", 94 | "tabWidth": 2, 95 | "printWidth": 100, 96 | "trailingComma": "none", 97 | "bracketSpacing": true, 98 | "singleQuote": true 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "@ngxs-labs/dispatch-decorator": ["libs/dispatch-decorator/src/index.ts"] 7 | }, 8 | "target": "es5", 9 | "lib": ["es2017", "dom"], 10 | "moduleResolution": "node", 11 | "typeRoots": ["node_modules/@types"], 12 | "strict": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "noImplicitAny": false, 16 | "resolveJsonModule": true, 17 | "downlevelIteration": true, 18 | "noStrictGenericChecks": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true, 21 | "suppressImplicitAnyIndexErrors": true 22 | }, 23 | "exclude": ["cypress"] 24 | } 25 | --------------------------------------------------------------------------------