├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── example ├── index.html ├── src │ ├── api │ │ └── shop.ts │ ├── app.ts │ ├── components │ │ ├── App.vue │ │ ├── ProductList.vue │ │ └── ShoppingCart.vue │ ├── shims-vue.d.ts │ └── store │ │ ├── cart.ts │ │ ├── index.ts │ │ └── products.ts ├── tsconfig.json └── webpack.config.js ├── jestconfig.json ├── package-lock.json ├── package.json ├── src ├── VuexModule.ts ├── actions.ts ├── index.ts ├── module-factory.ts ├── module.ts └── mutations.ts ├── test ├── actions-inheritance.ts ├── actions.ts ├── constructor.ts ├── generate-mutations.ts ├── getters-inheritance.ts ├── getters.ts ├── instanceof.ts ├── local-functions-inheritance.ts ├── local-functions.ts ├── module-reference.ts ├── mutations-inheritance.ts ├── mutations.ts ├── state.ts ├── tsconfig.json └── watch.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | /commonjs 4 | /example/build.js 5 | /example/build.js.map 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 120 ], 3 | "prettier.printWidth": 120, 4 | "prettier.disableLanguages": [], 5 | 6 | "[typescript]": { 7 | "editor.formatOnSave": true 8 | }, 9 | "[vue]": { 10 | "editor.formatOnSave": true 11 | }, 12 | "tslint.autoFixOnSave": true, 13 | 14 | "vetur.format.defaultFormatter.html": "none", 15 | "vetur.format.defaultFormatter.ts": "none" 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 gertqin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vuex-class-modules 2 | 3 | This is yet another package to introduce a simple type-safe class style syntax for your vuex modules, inspired by [vue-class-component](https://github.com/vuejs/vue-class-component). 4 | 5 | [![npm](https://img.shields.io/npm/v/vuex-class-modules.svg)](https://www.npmjs.com/package/vuex-class-modules) 6 | 7 | ## Installation 8 | 9 | `npm install vuex-class-modules` 10 | 11 | And make sure to have the `--experimentalDecorators` flag enabled. 12 | 13 | Both a `commonjs` and a `esm` module build are published. If you have a webpack-based setup, it will use the `esm` modules by default. 14 | 15 | ## Usage 16 | 17 | Vuex modules can be written using decorators as a class: 18 | 19 | ```typescript 20 | // user-module.ts 21 | import { VuexModule, Module, Mutation, Action } from "vuex-class-modules"; 22 | 23 | @Module 24 | class UserModule extends VuexModule { 25 | // state 26 | firstName = "Foo"; 27 | lastName = "Bar"; 28 | 29 | // getters 30 | get fullName() { 31 | return this.firstName + " " + this.lastName; 32 | } 33 | 34 | // mutations 35 | @Mutation 36 | setFirstName(firstName: string) { 37 | this.firstName = firstName; 38 | } 39 | @Mutation 40 | setLastName(lastName: string) { 41 | this.lastName = lastName; 42 | } 43 | 44 | // actions 45 | @Action 46 | async loadUser() { 47 | const user = await fetchUser(); 48 | this.setFirstName(user.firstName); 49 | this.setLastName(user.lastName); 50 | } 51 | } 52 | 53 | // register module (could be in any file) 54 | import store from "path/to/store"; 55 | export const userModule = new UserModule({ store, name: "user" }); 56 | ``` 57 | 58 | The module will automatically be registered to the store as a namespaced dynamic module when it is instantiated. (The modules are namespaced to avoid name conflicts between modules for getters/mutations/actions.) 59 | 60 | The module can then be used in vue components as follows: 61 | 62 | ```ts 63 | // MyComponent.vue 64 | import Vue from "vue"; 65 | import { userModule } from "path/to/user-module.ts"; 66 | 67 | export class MyComponent extends Vue { 68 | get firstName() { 69 | return userModule.firstName; // -> store.state.user.firstName 70 | } 71 | get fullName() { 72 | return userModule.fullName; // -> store.getters["user/fullName] 73 | } 74 | 75 | created() { 76 | userModule.setFirstName("Foo"); // -> store.commit("user/setFirstName", "Foo") 77 | userModule.loadUser(); // -> store.dispatch("user/loadUser") 78 | } 79 | } 80 | ``` 81 | 82 | ### What about `rootState` and `rootGetters`? 83 | 84 | There are two ways to access other modules within a module, or dispatch actions to other modules. 85 | 86 | 1. Simply import the instantiated module (suitable if the modules are instantiated in the same file as they are defined): 87 | 88 | ```ts 89 | // my-module.ts 90 | 91 | // import the module instance 92 | import { otherModule } from "./other-module"; 93 | 94 | @Module 95 | class MyModule extends VuexModule { 96 | get myGetter() { 97 | return otherModule.foo; 98 | } 99 | 100 | @Action 101 | async myAction() { 102 | await otherModule.someAction(); 103 | // ... 104 | } 105 | } 106 | ``` 107 | 108 | 2. The other module can be registered through the constructor (suitable if the modules are instantiated elsewhere) 109 | 110 | ```ts 111 | // my-module.ts 112 | 113 | // import the class, not the instance 114 | import { OtherModule } from "./other-module"; 115 | 116 | @Module 117 | export class MyModule extends VuexModule { 118 | private otherModule: OtherModule; 119 | 120 | constructor(otherModule: OtherModule, options: RegisterOptions) { 121 | super(options); 122 | this.otherModule = otherModule; 123 | } 124 | 125 | get myGetter() { 126 | return this.otherModule.foo; 127 | } 128 | 129 | @Action 130 | async myAction() { 131 | await this.otherModule.someAction(); 132 | // ... 133 | } 134 | } 135 | 136 | // register-modules.ts 137 | import store from "path/to/store"; 138 | import { OtherModule } from "path/to/other-module"; 139 | import { MyModule } from "path/to/my-module"; 140 | 141 | export const otherModule = new OtherModule({ store, name: "otherModule" }); 142 | export const myModule = new MyModule(otherModule, { store, name: "myModule" }); 143 | ``` 144 | 145 | The local modules will not be part of the state and cannot be accessed from the outside, so they should always be declared private. 146 | 147 | ```ts 148 | myModule.otherModule; // -> undefined 149 | ``` 150 | 151 | ### The `store.watch` function 152 | 153 | Vuex can also be used ouside of vue modules. To listen for changes to the state, vuex provides a [watch method](https://vuex.vuejs.org/api/#watch). 154 | 155 | This api is also provided by vuex-class-modules under the method name `$watch` to prevent name collisions. For example you can do: 156 | 157 | ```ts 158 | import store from "./store"; 159 | import { MyModule } from "./my-module"; 160 | 161 | const myModule = new MyModule({ store, name: "MyModule" }); 162 | myModule.$watch( 163 | (theModule) => theModule.fullName, 164 | (newName: string, oldName: string) => { 165 | // ... 166 | }, 167 | { 168 | deep: false, 169 | immediate: false, 170 | } 171 | ); 172 | ``` 173 | 174 | and to unwatch: 175 | 176 | ```ts 177 | const unwatch = myModule.$watch(...); 178 | unwatch(); 179 | ``` 180 | 181 | ### Register options 182 | 183 | - `name` [required]: Name of the module 184 | - `store` [required]: The vuex store - which can just be instantiated as empty: 185 | 186 | ```ts 187 | // store.ts 188 | import Vue from "vue"; 189 | import Vuex from "vuex"; 190 | Vue.use(Vuex); 191 | const store = new Vuex.Store({}); 192 | ``` 193 | 194 | ### Module options 195 | 196 | The module decorator can also accept options: 197 | 198 | - `generateMutationSetters` [optional, default=false]: Whether automatic mutation setters for the state properties should be generated, see [Generate Mutation Setters](#generate-mutation-setters). 199 | 200 | ## Example 201 | 202 | The vuex shopping cart example rewritten using `vue-class-component` and `vuex-class-modules` can be found in the [example directory](/example). Build the example using: 203 | 204 | `npm run example` 205 | 206 | ## Caveats of `this` 207 | 208 | As for vue-class-component `this` inside the module is just a proxy object to the store. It can therefore only access what the corresponding vuex module function would be able to access: 209 | 210 | ```ts 211 | @Module 212 | class MyModule extends VuexModule { 213 | foo = "bar"; 214 | 215 | get someGetter() { 216 | return 123; 217 | } 218 | get myGetter() { 219 | this.foo; // -> "bar" 220 | this.someGetter; // -> 123 221 | this.someMutation(); // undefined, getters cannot call mutations 222 | this.someAction(); // -> undefined, getters cannot call actions 223 | } 224 | 225 | @Mutation 226 | someMutation() { 227 | /* ... */ 228 | } 229 | @Mutation 230 | myMutation() { 231 | this.foo; // -> "bar" 232 | this.someGetter; // -> undefined, mutations dont have access to getters 233 | this.someMutation(); // -> undefined, mutations cannot call other mutations 234 | this.someAction(); // -> undefined, mutations cannot call actions 235 | } 236 | 237 | @Action 238 | async someAction() { 239 | /* ... */ 240 | } 241 | @Action 242 | async myAction() { 243 | this.foo; // -> "bar" 244 | this.someGetter; // -> 123 245 | this.myMutation(); // Ok 246 | await this.someAction(); // Ok 247 | } 248 | } 249 | ``` 250 | 251 | ## Local Functions 252 | 253 | The module can have non-mutation/action functions which can be used inside the module. As for local modules, these functions will not be exposed outside the module and should therefore be private. `this` will be passed on to the local function from the getter/mutation/action. 254 | 255 | ```ts 256 | @Module 257 | class MyModule extends VuexModule { 258 | get myGetter() { 259 | return myGetterHelper(); 260 | } 261 | private myGetterHelper() { 262 | // same 'this' context as myGetter 263 | } 264 | 265 | @Mutation 266 | myMutation() { 267 | this.myMutationHelper(); 268 | } 269 | 270 | // should be private 271 | myMutationHelper() { /* ... */} 272 | } 273 | const myModule = new MyModule({ store, name: "myModule }); 274 | myModule.myMutationHelper // -> undefined. 275 | ``` 276 | 277 | ## Generate Mutation Setters 278 | 279 | As I often find myself writing a lot of simple setter mutations like 280 | 281 | ```ts 282 | @Module 283 | class UserModule extends VuexModule { 284 | firstName = "Foo"; 285 | lastName = "Bar"; 286 | 287 | @Mutation 288 | setFirstName(firstName: string) { 289 | this.firstName = firstName; 290 | } 291 | @Mutation 292 | setLastName(lastName: string) { 293 | this.lastName = lastName; 294 | } 295 | } 296 | ``` 297 | 298 | a module option `generateMutationSetters` has been added, which when enabled will generate a setter mutation for each state property. The state can then be modified directly from the actions: 299 | 300 | ```ts 301 | @Module({ generateMutationSetters: true }) 302 | class UserModule extends VuexModule { 303 | firstName = "Foo"; 304 | lastName = "Bar"; 305 | 306 | // Auto generated: 307 | // @Mutation set__firstName(val: any) { this.firstName = val } 308 | // @Mutation set__lastName(val: any) { this.lastName = val } 309 | 310 | @Action 311 | async loadUser() { 312 | const user = await fetchUser(); 313 | this.firstName = user.firstName; // -> this.set__firstName(user.firstName); 314 | this.lastName = user.lastName; // -> this.set__lastName(user.lastName); 315 | } 316 | } 317 | ``` 318 | 319 | _NOTE:_ Setters are only generated for root-level state properties, so in order to update a property of an object you have to use a mutation or replace the entire object: 320 | 321 | ```ts 322 | @Module({ generateMutationSetters: true }) 323 | class UserModule extends VuexModule { 324 | user = { 325 | id: 123, 326 | name: "Foo", 327 | }; 328 | 329 | @Mutation 330 | setUserName() { 331 | this.user.name = "Bar"; // OK! 332 | } 333 | 334 | @Action 335 | async loadUser() { 336 | this.user.name = "Bar"; // Bad, the state is mutated outside a mutation 337 | this.user = { ...this.user, name: "Bar" }; // OK! 338 | } 339 | } 340 | ``` 341 | 342 | ## Vite HMR 343 | 344 | [Vite](https://vitejs.dev/) (and possibly other bundlers) uses `import.meta.hot` for HMR, which `vuex-class-modules` doesn't support currently. Instead a static property 345 | 346 | ```ts 347 | VuexModule.__useHotUpdate = true; // default false 348 | ``` 349 | 350 | is provided, which will force hot updates to the store instead of throwing an error when a module with a duplicate name is registered. This could for instance be set only in dev mode 351 | 352 | ```ts 353 | VuexModule.__useHotUpdate = import.meta.env.DEV; 354 | ``` 355 | 356 | ## License 357 | 358 | [MIT](http://opensource.org/licenses/MIT) 359 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vuex Class Modules Example 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /example/src/api/shop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mocking client-server processing 3 | */ 4 | export interface Product { 5 | id: number; 6 | title: string; 7 | price: number; 8 | inventory: number; 9 | } 10 | 11 | export interface CartItem { 12 | id: number; 13 | quantity: number; 14 | } 15 | 16 | const products: Product[] = [ 17 | { id: 1, title: "iPad 4 Mini", price: 500.01, inventory: 2 }, 18 | { id: 2, title: "H&M T-Shirt White", price: 10.99, inventory: 10 }, 19 | { id: 3, title: "Charli XCX - Sucker CD", price: 19.99, inventory: 5 } 20 | ]; 21 | 22 | export default { 23 | async getProducts() { 24 | await new Promise(resolve => setTimeout(resolve, 100)); 25 | return products; 26 | }, 27 | 28 | async buyProducts(items: CartItem[]) { 29 | await new Promise(resolve => setTimeout(resolve, 100)); 30 | 31 | if (Math.random() > 0.5) { 32 | throw Error(); 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /example/src/app.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./components/App.vue"; 3 | import store from "./store"; 4 | 5 | new Vue({ 6 | el: "#app", 7 | store, 8 | render: h => h(App) 9 | }); 10 | -------------------------------------------------------------------------------- /example/src/components/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /example/src/components/ProductList.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 32 | -------------------------------------------------------------------------------- /example/src/components/ShoppingCart.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 38 | -------------------------------------------------------------------------------- /example/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import Vue from "vue"; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /example/src/store/cart.ts: -------------------------------------------------------------------------------- 1 | import store from "./"; 2 | import shop, { CartItem, Product } from "../api/shop"; 3 | import { Module, Mutation, Action, VuexModule } from "../../../lib/index"; 4 | import { productsModule } from "./products"; 5 | 6 | @Module({ generateMutationSetters: true }) 7 | class Cart extends VuexModule { 8 | items: CartItem[] = []; 9 | checkoutStatus = ""; 10 | 11 | get cartProducts() { 12 | return this.items.map(({ id, quantity }) => { 13 | const product = productsModule.all.find(p => p.id === id); 14 | return { 15 | title: product!.title, 16 | price: product!.price, 17 | quantity 18 | }; 19 | }); 20 | } 21 | 22 | get cartTotalPrice() { 23 | return this.cartProducts.reduce((total, product) => { 24 | return total + product.price * product.quantity; 25 | }, 0); 26 | } 27 | 28 | @Mutation 29 | pushProductToCart(id: number) { 30 | this.items.push({ 31 | id, 32 | quantity: 1 33 | }); 34 | } 35 | 36 | @Mutation 37 | incrementItemQuantity(id: number) { 38 | const cartItem = this.items.find(item => item.id === id); 39 | cartItem!.quantity++; 40 | } 41 | 42 | @Action 43 | async addProductToCart(product: Product) { 44 | this.checkoutStatus = ""; 45 | 46 | if (product.inventory > 0) { 47 | const cartItem = this.items.find(item => item.id === product.id); 48 | if (!cartItem) { 49 | this.pushProductToCart(product.id); 50 | } else { 51 | this.incrementItemQuantity(cartItem.id); 52 | } 53 | // remove 1 item from stock 54 | productsModule.decrementProductInventory(product.id); 55 | } 56 | } 57 | 58 | @Action 59 | async checkout() { 60 | const savedCartItems = [...this.items]; 61 | this.checkoutStatus = ""; 62 | 63 | // empty cart 64 | this.items = []; 65 | 66 | try { 67 | await shop.buyProducts(savedCartItems); 68 | this.checkoutStatus = "successful"; 69 | } catch (e) { 70 | this.items = savedCartItems; 71 | this.checkoutStatus = "failed"; 72 | } 73 | } 74 | } 75 | 76 | export const cartModule = new Cart({ store, name: "cart" }); 77 | -------------------------------------------------------------------------------- /example/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | Vue.use(Vuex); 4 | 5 | export default new Vuex.Store({}); 6 | -------------------------------------------------------------------------------- /example/src/store/products.ts: -------------------------------------------------------------------------------- 1 | import store from "./"; 2 | import shop, { Product } from "../api/shop"; 3 | import { Module, Mutation, Action, VuexModule } from "../../../lib/index"; 4 | 5 | @Module({ generateMutationSetters: true }) 6 | class Products extends VuexModule { 7 | all: Product[] = []; 8 | 9 | @Mutation 10 | decrementProductInventory(id: number) { 11 | const product = this.all.find(p => p.id === id); 12 | product!.inventory--; 13 | } 14 | 15 | @Action 16 | async getAllProducts() { 17 | this.all = await shop.getProducts(); 18 | } 19 | } 20 | 21 | export const productsModule = new Products({ store, name: "products" }); 22 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "esnext" 7 | ], 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": false 14 | }, 15 | "include": [ 16 | "./**/*.ts", 17 | ] 18 | } -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const VueLoaderPlugin = require('vue-loader/lib/plugin') 2 | 3 | module.exports = { 4 | mode: 'development', 5 | context: __dirname, 6 | entry: './src/app.ts', 7 | output: { 8 | path: __dirname, 9 | filename: 'build.js' 10 | }, 11 | resolve: { 12 | extensions: ['.ts', '.tsx', '.js'] 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | exclude: /node_modules/, 19 | use: [ 20 | { 21 | loader: 'ts-loader', 22 | options: { 23 | appendTsSuffixTo: [/\.vue$/], 24 | appendTsxSuffixTo: [/\.vue$/] 25 | } 26 | } 27 | ] 28 | }, 29 | { 30 | test: /\.vue$/, 31 | use: ['vue-loader'] 32 | } 33 | ] 34 | }, 35 | devtool: 'source-map', 36 | plugins: [ 37 | new VueLoaderPlugin() 38 | ] 39 | } -------------------------------------------------------------------------------- /jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "rootDir": "./test", 4 | "testMatch": ["/**/*.ts"], 5 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], 6 | "globals": { 7 | "ts-jest": { 8 | "tsConfig": { 9 | "isolatedModules": false, 10 | "esModuleInterop": true 11 | } 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-class-modules", 3 | "version": "1.3.0", 4 | "description": "Typescript class decorators for class-style vuex modules.", 5 | "main": "commonjs/index.js", 6 | "module": "lib/index.js", 7 | "exports": { 8 | ".": { 9 | "require": "./commonjs/index.js", 10 | "default": "./lib/index.js" 11 | } 12 | }, 13 | "types": "lib/index.d.ts", 14 | "files": [ 15 | "lib", 16 | "commonjs" 17 | ], 18 | "scripts": { 19 | "test": "jest --config jestconfig.json", 20 | "build": "npm run build:es2015 && npm run build:commonjs", 21 | "build:es2015": "tsc", 22 | "build:commonjs": "tsc -m commonjs --outDir ./commonjs", 23 | "lint": "tslint -p tsconfig.json --fix", 24 | "example": "npm run build && webpack --config ./example/webpack.config.js", 25 | "prepare": "npm run build", 26 | "prepublishOnly": "npm test && npm run lint", 27 | "preversion": "npm run lint", 28 | "version": "git add -A src", 29 | "postversion": "git push && git push --tags" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/gertqin/vuex-class-modules.git" 34 | }, 35 | "keywords": [ 36 | "vue", 37 | "vuex", 38 | "typescript", 39 | "class", 40 | "decorators" 41 | ], 42 | "author": "Gert Qin Hansen", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/gertqin/vuex-class-modules/issues" 46 | }, 47 | "homepage": "https://github.com/gertqin/vuex-class-modules#readme", 48 | "devDependencies": { 49 | "@types/node": "12.12.2", 50 | "@types/jest": "^26.0.14", 51 | "@types/webpack-env": "^1.13.9", 52 | "css-loader": "^3.2.0", 53 | "jest": "^26.4.0", 54 | "ts-jest": "^26.4.0", 55 | "ts-loader": "^6.2.0", 56 | "tslint": "^5.20.0", 57 | "tslint-config-prettier": "^1.18.0", 58 | "tslint-plugin-prettier": "^2.0.1", 59 | "typescript": "^3.8.4", 60 | "vue": "^2.6.10", 61 | "vue-class-component": "^7.1.0", 62 | "vue-loader": "^15.7.0", 63 | "vue-template-compiler": "^2.6.10", 64 | "vuex": "^3.1.1", 65 | "webpack": "^4.44.2", 66 | "webpack-cli": "^3.3.12" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/VuexModule.ts: -------------------------------------------------------------------------------- 1 | import { RegisterOptions } from "./module-factory"; 2 | import { WatchOptions } from "vue"; 3 | 4 | export class VuexModule { 5 | private __options: RegisterOptions; 6 | 7 | static __useHotUpdate: boolean = false; 8 | 9 | constructor(options: RegisterOptions) { 10 | this.__options = options; 11 | } 12 | 13 | $watch(fn: (arg: this) => T, callback: (newValue: T, oldValue: T) => void, options?: WatchOptions): Function { 14 | return function() {}; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { ModulePrototype } from "./module-factory"; 2 | 3 | export function Action( 4 | target: T, 5 | key: string | symbol, 6 | descriptor: TypedPropertyDescriptor<(arg?: any) => any> 7 | ) { 8 | const vuexModule = target.constructor as ModulePrototype; 9 | if (!vuexModule.__actions) { 10 | vuexModule.__actions = {}; 11 | } 12 | if (descriptor.value) { 13 | vuexModule.__actions[key as string] = descriptor.value; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { RegisterOptions, ModuleOptions } from "./module-factory"; 2 | export { Action } from "./actions"; 3 | export { Module } from "./module"; 4 | export { Mutation } from "./mutations"; 5 | export { VuexModule } from "./VuexModule"; 6 | -------------------------------------------------------------------------------- /src/module-factory.ts: -------------------------------------------------------------------------------- 1 | import { Store, Module as StoreModule, GetterTree, ActionContext, Dispatch, Commit } from "vuex"; 2 | import { WatchOptions } from "vue"; 3 | import { VuexModule } from "./VuexModule"; 4 | 5 | export interface ModuleOptions { 6 | generateMutationSetters?: boolean; 7 | } 8 | 9 | export interface RegisterOptions { 10 | store: Store; 11 | name: string; 12 | } 13 | 14 | export interface IVuexModule extends Dictionary { 15 | __options: RegisterOptions; 16 | } 17 | export interface IModulePrototype { 18 | __mutations?: Dictionary<(payload?: any) => void>; 19 | __actions?: Dictionary<(payload?: any) => Promise>; 20 | } 21 | export type ModulePrototype = IModulePrototype & Function; 22 | 23 | type Dictionary = { [k: string]: T }; 24 | 25 | interface ModuleDefinition { 26 | state: Dictionary; 27 | moduleRefs: Dictionary; 28 | getters: Dictionary<() => void>; 29 | mutations: Dictionary<(payload?: any) => void>; 30 | actions: Dictionary<(payload?: any) => Promise>; 31 | localFunctions: Dictionary<(...args: any[]) => any>; 32 | } 33 | interface StoreProxyDefinition { 34 | state?: Dictionary; 35 | stateSetter?: (key: string, val: any) => void; 36 | 37 | getters?: Dictionary; 38 | commit?: Commit; 39 | dispatch?: Dispatch; 40 | 41 | useNamespaceKey?: boolean; 42 | excludeModuleRefs?: boolean; 43 | excludeLocalFunctions?: boolean; 44 | } 45 | 46 | export class VuexClassModuleFactory { 47 | moduleOptions: ModuleOptions; 48 | instance: IVuexModule; 49 | registerOptions: RegisterOptions; 50 | 51 | definition: ModuleDefinition = { 52 | state: {}, 53 | moduleRefs: {}, 54 | getters: {}, 55 | mutations: {}, 56 | actions: {}, 57 | localFunctions: {} 58 | }; 59 | 60 | constructor(classModule: ModulePrototype, instance: IVuexModule, moduleOptions: ModuleOptions) { 61 | this.moduleOptions = moduleOptions; 62 | this.instance = instance; 63 | this.registerOptions = instance.__options; 64 | this.init(classModule); 65 | } 66 | 67 | private init(classModule: ModulePrototype) { 68 | // state 69 | for (const key of Object.keys(this.instance)) { 70 | const val = this.instance[key]; 71 | if (key !== "__options" && this.instance.hasOwnProperty(key)) { 72 | if (val instanceof VuexModule) { 73 | this.definition.moduleRefs[key] = val; 74 | } else { 75 | this.definition.state[key] = this.instance[key]; 76 | } 77 | } 78 | } 79 | 80 | const actionKeys = Object.keys(classModule.__actions || {}); 81 | const mutationKeys = Object.keys(classModule.__mutations || {}); 82 | const isAction = (key: string) => actionKeys.indexOf(key) !== -1; 83 | const isMutation = (key: string) => mutationKeys.indexOf(key) !== -1; 84 | 85 | for (const module of getModulePrototypes(classModule)) { 86 | for (const key of Object.getOwnPropertyNames(module.prototype)) { 87 | const descriptor = Object.getOwnPropertyDescriptor(module.prototype, key) as PropertyDescriptor; 88 | 89 | const isGetter = !!descriptor.get; 90 | if (isGetter && !(key in this.definition.getters)) { 91 | this.definition.getters[key] = descriptor.get!; 92 | } 93 | 94 | if (isAction(key) && !(key in this.definition.actions) && descriptor.value) { 95 | this.definition.actions[key] = module.prototype[key]; 96 | } 97 | if (isMutation(key) && !(key in this.definition.mutations) && descriptor.value) { 98 | this.definition.mutations[key] = module.prototype[key]; 99 | } 100 | 101 | const isHelperFunction = 102 | descriptor.value && 103 | typeof module.prototype[key] === "function" && 104 | !isAction(key) && 105 | !isMutation(key) && 106 | key !== "constructor"; 107 | 108 | if (isHelperFunction && !(key in this.definition.localFunctions)) { 109 | this.definition.localFunctions[key] = module.prototype[key]; 110 | } 111 | } 112 | } 113 | } 114 | 115 | registerVuexModule() { 116 | const vuexModule: StoreModule = { 117 | state: this.definition.state, 118 | getters: {}, 119 | mutations: {}, 120 | actions: {}, 121 | namespaced: true 122 | }; 123 | 124 | // getters 125 | mapValues(vuexModule.getters!, this.definition.getters, getter => { 126 | return (state: any, getters: GetterTree) => { 127 | const thisObj = this.buildThisProxy({ state, getters }); 128 | return getter.call(thisObj); 129 | }; 130 | }); 131 | 132 | // mutations 133 | mapValues(vuexModule.mutations!, this.definition.mutations, mutation => { 134 | return (state: any, payload: any) => { 135 | const thisObj = this.buildThisProxy({ 136 | state, 137 | stateSetter: (stateField: string, val: any) => { 138 | state[stateField] = val; 139 | } 140 | }); 141 | mutation.call(thisObj, payload); 142 | }; 143 | }); 144 | if (this.moduleOptions.generateMutationSetters) { 145 | for (const stateKey of Object.keys(this.definition.state)) { 146 | const mutation = (state: any, payload: any) => { 147 | state[stateKey] = payload; 148 | }; 149 | vuexModule.mutations![this.getMutationSetterName(stateKey)] = mutation; 150 | } 151 | } 152 | 153 | // actions 154 | mapValues(vuexModule.actions!, this.definition.actions, action => { 155 | return (context: ActionContext, payload: any) => { 156 | const proxyDefinition: StoreProxyDefinition = { 157 | ...context, 158 | stateSetter: this.moduleOptions.generateMutationSetters 159 | ? (field: string, val: any) => { 160 | context.commit(this.getMutationSetterName(field), val); 161 | } 162 | : undefined 163 | }; 164 | const thisObj = this.buildThisProxy(proxyDefinition); 165 | 166 | return action.call(thisObj, payload); 167 | }; 168 | }); 169 | 170 | // register module 171 | const { store, name } = this.registerOptions; 172 | if (store.state[name]) { 173 | if (VuexModule.__useHotUpdate || (typeof module !== "undefined" && module.hot)) { 174 | store.hotUpdate({ 175 | modules: { 176 | [name]: vuexModule 177 | } 178 | }); 179 | } else { 180 | throw Error(`[vuex-class-module]: A module with name '${name}' already exists.`); 181 | } 182 | } else { 183 | store.registerModule(this.registerOptions.name, vuexModule); 184 | } 185 | } 186 | 187 | buildAccessor() { 188 | const { store, name } = this.registerOptions; 189 | 190 | const stateSetter = this.moduleOptions.generateMutationSetters 191 | ? (field: string, val: any) => { 192 | store.commit(`${name}/${this.getMutationSetterName(field)}`, val); 193 | } 194 | : undefined; 195 | 196 | const accessorModule = this.buildThisProxy({ 197 | ...store, 198 | state: store.state[name], 199 | stateSetter, 200 | useNamespaceKey: true, 201 | excludeModuleRefs: true, 202 | excludeLocalFunctions: true 203 | }); 204 | 205 | // watch API 206 | accessorModule.$watch = ( 207 | fn: (arg: VuexModule) => any, 208 | callback: (newValue: any, oldValue: any) => void, 209 | options?: WatchOptions 210 | ) => { 211 | return store.watch( 212 | (state: any, getters: any) => 213 | fn( 214 | this.buildThisProxy({ 215 | state: state[name], 216 | getters, 217 | useNamespaceKey: true 218 | }) 219 | ), 220 | callback, 221 | options 222 | ); 223 | }; 224 | 225 | Object.setPrototypeOf(accessorModule, Object.getPrototypeOf(this.instance)); 226 | Object.freeze(accessorModule); 227 | 228 | return accessorModule; 229 | } 230 | 231 | private buildThisProxy(proxyDefinition: StoreProxyDefinition) { 232 | const obj: any = {}; 233 | 234 | if (proxyDefinition.state) { 235 | mapValuesToProperty( 236 | obj, 237 | this.definition.state, 238 | key => proxyDefinition.state![key], 239 | proxyDefinition.stateSetter 240 | ? (key, val) => proxyDefinition.stateSetter!(key, val) 241 | : () => { 242 | throw Error("[vuex-class-module]: Cannot modify state outside mutations."); 243 | } 244 | ); 245 | } 246 | if (!proxyDefinition.excludeModuleRefs) { 247 | mapValues(obj, this.definition.moduleRefs, val => val); 248 | } 249 | 250 | const namespaceKey = proxyDefinition.useNamespaceKey ? this.registerOptions.name + "/" : ""; 251 | 252 | if (proxyDefinition.getters) { 253 | mapValuesToProperty(obj, this.definition.getters, key => proxyDefinition.getters![`${namespaceKey}${key}`]); 254 | } 255 | 256 | if (proxyDefinition.commit) { 257 | mapValues(obj, this.definition.mutations, (mutation, key) => { 258 | return (payload?: any) => proxyDefinition.commit!(`${namespaceKey}${key}`, payload); 259 | }); 260 | } 261 | 262 | if (proxyDefinition.dispatch) { 263 | mapValues(obj, this.definition.actions, (action, key) => { 264 | return (payload?: any) => proxyDefinition.dispatch!(`${namespaceKey}${key}`, payload); 265 | }); 266 | } 267 | 268 | if (!proxyDefinition.excludeLocalFunctions) { 269 | mapValues(obj, this.definition.localFunctions, localFunction => { 270 | return (...args: any[]) => localFunction.apply(obj, args); 271 | }); 272 | } 273 | 274 | return obj; 275 | } 276 | 277 | private getMutationSetterName(stateKey: string) { 278 | return "set__" + stateKey; 279 | } 280 | } 281 | 282 | function mapValues(target: Dictionary, source: Dictionary, mapFunc: (val: S, key: string) => V) { 283 | for (const key of Object.keys(source)) { 284 | target[key] = mapFunc(source[key], key); 285 | } 286 | } 287 | 288 | function mapValuesToProperty( 289 | target: Dictionary, 290 | source: Dictionary, 291 | get: (key: string) => any, 292 | set?: (key: string, val: any) => void 293 | ) { 294 | for (const key of Object.keys(source)) { 295 | Object.defineProperty(target, key, { 296 | get: () => get(key), 297 | set: set ? (val: string) => set(key, val) : undefined 298 | }); 299 | } 300 | } 301 | 302 | function getModulePrototypes(module: ModulePrototype): ModulePrototype[] { 303 | const prototypes: ModulePrototype[] = []; 304 | 305 | for (let prototype = module; prototype && prototype !== VuexModule; prototype = Object.getPrototypeOf(prototype)) { 306 | prototypes.push(prototype); 307 | } 308 | 309 | return prototypes; 310 | } 311 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { VuexClassModuleFactory, ModuleOptions, IVuexModule } from "./module-factory"; 2 | import { VuexModule } from "./VuexModule"; 3 | 4 | type VuexModuleClass = new (...args: any[]) => VuexModule; 5 | export function Module(target: T): T; 6 | export function Module(options?: ModuleOptions): ClassDecorator; 7 | export function Module(arg?: ModuleOptions | T): ClassDecorator | T { 8 | if (typeof arg === "function") { 9 | return moduleDecoratorFactory()(arg) as T; 10 | } else { 11 | return moduleDecoratorFactory(arg); 12 | } 13 | } 14 | 15 | function moduleDecoratorFactory(moduleOptions?: ModuleOptions) { 16 | return (constructor: TFunction): TFunction => { 17 | const accessor: any = function(...args: any[]) { 18 | const instance = new constructor.prototype.constructor(...args) as IVuexModule; 19 | Object.setPrototypeOf(instance, accessor.prototype); 20 | 21 | const factory = new VuexClassModuleFactory(constructor, instance, moduleOptions || {}); 22 | 23 | factory.registerVuexModule(); 24 | return factory.buildAccessor(); 25 | }; 26 | accessor.prototype = Object.create(constructor.prototype); 27 | accessor.prototype.constructor = accessor; 28 | return accessor; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/mutations.ts: -------------------------------------------------------------------------------- 1 | import { ModulePrototype } from "./module-factory"; 2 | 3 | export function Mutation( 4 | target: T, 5 | key: string | symbol, 6 | descriptor: TypedPropertyDescriptor<(arg?: any) => void> 7 | ) { 8 | const vuexModule = target.constructor as ModulePrototype; 9 | if (!vuexModule.__mutations) { 10 | vuexModule.__mutations = {}; 11 | } 12 | if (descriptor.value) { 13 | vuexModule.__mutations[key as string] = descriptor.value; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/actions-inheritance.ts: -------------------------------------------------------------------------------- 1 | import { Action, Module, Mutation, VuexModule, RegisterOptions } from "../src"; 2 | import Vuex, { Store } from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | 7 | abstract class ParentModule extends VuexModule { 8 | foo = "init"; 9 | 10 | get bigFoo() { 11 | return this.foo.toUpperCase(); 12 | } 13 | 14 | get decoratedFoo() { 15 | return `***${this.foo}***`; 16 | } 17 | 18 | @Mutation 19 | updateFoo(value: string) { 20 | this.foo = value; 21 | } 22 | 23 | @Action 24 | action1() { 25 | // 26 | } 27 | 28 | @Action 29 | action2() { 30 | // 31 | } 32 | 33 | @Action 34 | action3() { 35 | // 36 | } 37 | 38 | @Action 39 | action4() { 40 | // 41 | } 42 | 43 | @Action 44 | action5() { 45 | this.updateFoo(this.foo + "action5"); 46 | } 47 | 48 | @Action 49 | action6() { 50 | this.updateFoo(this.foo + "polymorphicCallOf"); 51 | this.action7(); 52 | } 53 | 54 | abstract action7(): void; 55 | } 56 | 57 | @Module 58 | class Module1 extends ParentModule { 59 | private tag = "child1"; 60 | foo: string = "init" + this.tag; 61 | 62 | get doubleFoo() { 63 | return this.foo + this.foo; 64 | } 65 | 66 | @Mutation 67 | setFooToExample() { 68 | this.foo = "example" + this.tag; 69 | } 70 | 71 | @Action 72 | action1() { 73 | this.setFooToExample(); 74 | } 75 | 76 | @Action 77 | action2() { 78 | this.updateFoo("bar" + this.tag); 79 | } 80 | 81 | @Action 82 | action3() { 83 | this.updateFoo(this.doubleFoo); 84 | } 85 | 86 | @Action 87 | action4() { 88 | this.updateFoo(this.bigFoo); 89 | } 90 | 91 | @Action 92 | action5() { 93 | super.action5(); 94 | this.updateFoo(this.foo + this.tag); 95 | } 96 | 97 | @Action 98 | action7(): void { 99 | this.updateFoo(this.foo + "action7" + this.tag); 100 | } 101 | } 102 | 103 | @Module 104 | class Module2 extends ParentModule { 105 | private tag = "child2"; 106 | 107 | constructor(options: RegisterOptions) { 108 | super(options); 109 | this.foo = this.foo + this.tag; 110 | } 111 | 112 | get tripleFoo() { 113 | return this.foo + this.foo + this.foo; 114 | } 115 | 116 | @Mutation 117 | setFooToAnotherExample() { 118 | this.foo = "example" + this.tag; 119 | } 120 | 121 | @Action 122 | action1() { 123 | this.setFooToAnotherExample(); 124 | } 125 | 126 | @Action 127 | action2() { 128 | this.updateFoo("baz" + this.tag); 129 | } 130 | 131 | @Action 132 | action3() { 133 | this.updateFoo(this.tripleFoo); 134 | } 135 | 136 | @Action 137 | action4() { 138 | this.updateFoo(this.decoratedFoo); 139 | } 140 | 141 | @Action 142 | action5() { 143 | super.action5(); 144 | this.updateFoo(this.foo + this.tag); 145 | } 146 | 147 | @Action 148 | action7(): void { 149 | this.updateFoo(this.foo + "action7" + this.tag); 150 | } 151 | } 152 | 153 | describe("actions-inheritance", () => { 154 | let store: Store; 155 | let child1: Module1; 156 | let child2: Module2; 157 | 158 | beforeEach(() => { 159 | store = new Vuex.Store({}); 160 | child1 = new Module1({ store, name: "child1" }); 161 | child2 = new Module2({ store, name: "child2" }); 162 | }); 163 | 164 | test("overriden action has access to mutations", () => { 165 | child1.action1(); 166 | child2.action1(); 167 | expect(child1.foo).toBe("examplechild1"); 168 | expect(child2.foo).toBe("examplechild2"); 169 | }); 170 | 171 | test("overriden action has access to parent mutations", () => { 172 | child1.action2(); 173 | child2.action2(); 174 | expect(child1.foo).toBe("barchild1"); 175 | expect(child2.foo).toBe("bazchild2"); 176 | }); 177 | 178 | test("overriden action has access to getters", () => { 179 | child1.action3(); 180 | child2.action3(); 181 | expect(child1.foo).toBe("initchild1initchild1"); 182 | expect(child2.foo).toBe("initchild2initchild2initchild2"); 183 | }); 184 | 185 | test("overriden action has access to parent getters", () => { 186 | child1.action4(); 187 | child2.action4(); 188 | expect(child1.foo).toBe("INITCHILD1"); 189 | expect(child2.foo).toBe("***initchild2***"); 190 | }); 191 | 192 | test("overriden action has access to parent method implementation", () => { 193 | child1.action5(); 194 | child2.action5(); 195 | expect(child1.foo).toBe("initchild1action5child1"); 196 | expect(child2.foo).toBe("initchild2action5child2"); 197 | }); 198 | 199 | test("parent action access derived action polymorphically", () => { 200 | child1.action6(); 201 | child2.action6(); 202 | expect(child1.foo).toBe("initchild1polymorphicCallOfaction7child1"); 203 | expect(child2.foo).toBe("initchild2polymorphicCallOfaction7child2"); 204 | }); 205 | test("access action7 directly", () => { 206 | child1.action7(); 207 | child2.action7(); 208 | expect(child1.foo).toBe("initchild1action7child1"); 209 | expect(child2.foo).toBe("initchild2action7child2"); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /test/actions.ts: -------------------------------------------------------------------------------- 1 | import { Module, Mutation, Action, VuexModule } from "../src"; 2 | import Vuex, { Payload, MutationPayload } from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | documentId = 0; 11 | text = ""; 12 | 13 | get documentHasText() { 14 | return this.documentId > 10; 15 | } 16 | 17 | @Mutation 18 | setDocumentId(id: number) { 19 | this.documentId = id; 20 | } 21 | @Mutation 22 | setText(text: string) { 23 | this.text = text; 24 | } 25 | 26 | @Action 27 | async dummyAction(payload: any) { 28 | // to test accessor 29 | } 30 | 31 | @Action 32 | async loadText(documentId: number) { 33 | if (this.documentId === 0) { 34 | this.setDocumentId(documentId); 35 | 36 | if (this.documentHasText) { 37 | const text = await Promise.resolve("some other text"); 38 | this.setText(text); 39 | } 40 | } 41 | } 42 | } 43 | 44 | const myModule = new MyModule({ store, name: "myModule" }); 45 | 46 | interface ActionPayload extends Payload { 47 | payload?: any; 48 | } 49 | 50 | describe("actions", () => { 51 | test("accessor dispatches action", async () => { 52 | // subscribeAction missing from vuex typings 53 | const actionObserver = jest.fn((action: ActionPayload) => action); 54 | (store as any).subscribeAction(actionObserver); 55 | 56 | await myModule.dummyAction(5); 57 | 58 | expect(actionObserver.mock.calls.length).toBe(1); 59 | 60 | const mutationPayload = actionObserver.mock.results[0].value as ActionPayload; 61 | expect(mutationPayload.type).toBe("myModule/dummyAction"); 62 | expect(mutationPayload.payload).toBe(5); 63 | }); 64 | 65 | test("'this' matches vuex context", async () => { 66 | const mutationObserver = jest.fn((mutation: MutationPayload) => mutation); 67 | store.subscribe(mutationObserver); 68 | 69 | await store.dispatch("myModule/loadText", 11); 70 | 71 | expect(mutationObserver.mock.calls.length).toBe(2); 72 | 73 | const firstMutation = mutationObserver.mock.results[0].value as MutationPayload; 74 | expect(firstMutation.type).toBe("myModule/setDocumentId"); 75 | expect(firstMutation.payload).toBe(11); 76 | 77 | const secondMutation = mutationObserver.mock.results[1].value as MutationPayload; 78 | expect(secondMutation.type).toBe("myModule/setText"); 79 | expect(secondMutation.payload).toBe("some other text"); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/constructor.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, RegisterOptions } from "../src"; 2 | import Vuex from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | foo: string; 11 | 12 | constructor(foo: string, options: RegisterOptions) { 13 | super(options); 14 | this.foo = foo; 15 | } 16 | } 17 | 18 | test("constructor", () => { 19 | const myModule = new MyModule("bar", { store, name: "myModule" }); 20 | expect(myModule.foo).toBe("bar"); 21 | }); 22 | -------------------------------------------------------------------------------- /test/generate-mutations.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex, { MutationPayload } from "vuex"; 3 | import { Action, Module, VuexModule } from "../src"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module({ generateMutationSetters: true }) 9 | class MyModule extends VuexModule { 10 | id = 0; 11 | text = ""; 12 | 13 | @Action 14 | async loadData() { 15 | const { id, text } = await Promise.resolve({ id: 1, text: "some text" }); 16 | this.id = id; 17 | this.text = text; 18 | } 19 | } 20 | 21 | const myModule = new MyModule({ store, name: "myModule" }); 22 | 23 | test("generate-mutations", async () => { 24 | const mutationObserver = jest.fn((mutation: MutationPayload) => mutation); 25 | store.subscribe(mutationObserver); 26 | 27 | await myModule.loadData(); 28 | 29 | const firstMutation = mutationObserver.mock.results[0].value as MutationPayload; 30 | expect(firstMutation.type).toBe("myModule/set__id"); 31 | expect(firstMutation.payload).toBe(1); 32 | 33 | const secondMutation = mutationObserver.mock.results[1].value as MutationPayload; 34 | expect(secondMutation.type).toBe("myModule/set__text"); 35 | expect(secondMutation.payload).toBe("some text"); 36 | 37 | // change state directly using generated mutation 38 | myModule.text = "some other text"; 39 | 40 | const thirdMutation = mutationObserver.mock.results[2].value as MutationPayload; 41 | expect(thirdMutation.type).toBe("myModule/set__text"); 42 | expect(thirdMutation.payload).toBe("some other text"); 43 | }); 44 | -------------------------------------------------------------------------------- /test/getters-inheritance.ts: -------------------------------------------------------------------------------- 1 | import { Action, Module, Mutation, VuexModule } from "../src"; 2 | import Vuex, { Store } from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | 7 | class ParentModule extends VuexModule { 8 | foo = "bar"; 9 | 10 | get bigFoo() { 11 | return this.foo.toUpperCase(); 12 | } 13 | 14 | get snakeFoo() { 15 | return "__"; 16 | } 17 | 18 | @Mutation 19 | myMutation(value: string) { 20 | this.foo = value; 21 | } 22 | 23 | @Action 24 | myAction() { 25 | if (this.bigFoo === "BAR") { 26 | this.myMutation("ok"); 27 | } 28 | if (this.bigFoo === "BAZ") { 29 | this.myMutation("alright"); 30 | } 31 | } 32 | } 33 | 34 | @Module 35 | class Module1 extends ParentModule { 36 | private tag = "child1"; 37 | 38 | get snakeFoo() { 39 | return `_${this.foo}_${this.tag}_`; 40 | } 41 | } 42 | 43 | @Module 44 | class Module2 extends ParentModule { 45 | private tag = "child2"; 46 | foo = "baz"; 47 | 48 | get snakeFoo() { 49 | return `_${this.foo}_${this.tag}_`; 50 | } 51 | } 52 | 53 | describe("getters-inheritance", () => { 54 | let store: Store; 55 | let child1: Module1; 56 | let child2: Module2; 57 | 58 | beforeEach(() => { 59 | store = new Vuex.Store({}); 60 | child1 = new Module1({ store, name: "child1" }); 61 | child2 = new Module2({ store, name: "child2" }); 62 | }); 63 | 64 | test("allows the use of getters from an inheriting class", () => { 65 | expect(child1.bigFoo).toBe("BAR"); 66 | expect(child1.bigFoo).toBe(store.getters["child1/bigFoo"]); 67 | expect(child2.bigFoo).toBe("BAZ"); 68 | expect(child2.bigFoo).toBe(store.getters["child2/bigFoo"]); 69 | }); 70 | 71 | test("allows the use of getters in inherited class", () => { 72 | child1.myAction(); 73 | child2.myAction(); 74 | expect(child1.bigFoo).toBe("OK"); 75 | expect(child1.bigFoo).toBe(store.getters["child1/bigFoo"]); 76 | expect(child2.bigFoo).toBe("ALRIGHT"); 77 | expect(child2.bigFoo).toBe(store.getters["child2/bigFoo"]); 78 | }); 79 | 80 | test("overriden getters behave as expected", () => { 81 | expect(child1.snakeFoo).toBe("_bar_child1_"); 82 | expect(child1.snakeFoo).toBe(store.getters["child1/snakeFoo"]); 83 | expect(child2.snakeFoo).toBe("_baz_child2_"); 84 | expect(child2.snakeFoo).toBe(store.getters["child2/snakeFoo"]); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/getters.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule } from "../src"; 2 | import Vuex from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | foo = { 11 | text: "some text" 12 | }; 13 | 14 | get textTransforms() { 15 | return { 16 | original: this.foo.text, 17 | upperCase: this.foo.text.toUpperCase() 18 | }; 19 | } 20 | } 21 | 22 | const myModule = new MyModule({ store, name: "myModule" }); 23 | 24 | test("getters", () => { 25 | expect(myModule.textTransforms).toBe(store.getters["myModule/textTransforms"]); 26 | expect(myModule.textTransforms.upperCase).toBe("SOME TEXT"); 27 | }); 28 | -------------------------------------------------------------------------------- /test/instanceof.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule } from "../src"; 2 | import Vuex from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | class OriginalModule extends VuexModule { 9 | foo = { 10 | text: "some text" 11 | }; 12 | bar = 1; 13 | } 14 | 15 | /** Manually apply decorator, to have access to initial class definition */ 16 | const MyModule = Module(OriginalModule); 17 | const myModule = new MyModule({ store, name: "myModule" }); 18 | 19 | test("instance of", () => { 20 | expect(myModule instanceof OriginalModule).toBe(true); 21 | expect(myModule instanceof MyModule).toBe(true); 22 | expect(myModule instanceof VuexModule).toBe(true); 23 | }); 24 | -------------------------------------------------------------------------------- /test/local-functions-inheritance.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex, { Store } from "vuex"; 3 | import { Action, Module, Mutation, VuexModule } from "../src"; 4 | 5 | Vue.use(Vuex); 6 | 7 | abstract class Parent extends VuexModule { 8 | canTransform = true; 9 | text = "parent text"; 10 | 11 | get upperCaseText() { 12 | return this.text.toUpperCase(); 13 | } 14 | 15 | get noSpaceText() { 16 | return this.snakeText(); 17 | } 18 | 19 | private snakeText() { 20 | // 'this' has access to state & getters 21 | return this.canTransform ? this.upperCaseText.replace(/ /g, "_") : ""; 22 | } 23 | 24 | @Mutation 25 | setText(text: string) { 26 | this.localSetText(text); 27 | } 28 | 29 | @Mutation 30 | clearText() { 31 | this.text = ""; 32 | } 33 | 34 | protected abstract localSetText(text: string): void; 35 | protected localLoadText() { 36 | this.setText("parent: yet another text"); 37 | } 38 | } 39 | 40 | @Module 41 | class Module1 extends Parent { 42 | private tag = "child1"; 43 | canTransform = true; 44 | text = `${this.tag} text`; 45 | 46 | get upperCaseText() { 47 | return this.text.toUpperCase(); 48 | } 49 | 50 | protected localSetText(text: string) { 51 | // 'this' has state 52 | this.text = `${text} ${this.tag}`; 53 | } 54 | 55 | @Action 56 | async loadText() { 57 | this.localLoadText(); 58 | } 59 | 60 | protected localLoadText() { 61 | // 'this' has getters & mutations 62 | if (!this.upperCaseText) { 63 | this.setText(`${this.tag.toUpperCase()}: yet another text`); 64 | } 65 | } 66 | } 67 | 68 | @Module 69 | class Module2 extends Parent { 70 | private tag = "child2"; 71 | canTransform = true; 72 | text = `${this.tag} text`; 73 | 74 | get upperCaseText() { 75 | return this.text.toUpperCase(); 76 | } 77 | get noSpaceText() { 78 | return this.dashText(); 79 | } 80 | 81 | private dashText() { 82 | // 'this' has access to state & getters 83 | return this.canTransform ? this.upperCaseText.replace(/ /g, "--") : ""; 84 | } 85 | 86 | protected localSetText(text: string) { 87 | // 'this' has state 88 | this.text = `***${text}*** ${this.tag}`; 89 | } 90 | 91 | @Action 92 | async loadText() { 93 | this.localLoadText(); 94 | } 95 | 96 | protected localLoadText() { 97 | // 'this' has getters & mutations 98 | if (!this.upperCaseText) { 99 | this.setText(`${this.tag}: yet another text`); 100 | } 101 | } 102 | } 103 | 104 | describe("local-functions", () => { 105 | let child1: Module1; 106 | let child2: Module2; 107 | let store: Store; 108 | 109 | beforeEach(() => { 110 | store = new Vuex.Store({}); 111 | child1 = new Module1({ store, name: "myModule1" }); 112 | child2 = new Module2({ store, name: "myModule2" }); 113 | }); 114 | 115 | test("from getter", () => { 116 | expect(child1.noSpaceText).toBe("CHILD1_TEXT"); 117 | expect(child2.noSpaceText).toBe("CHILD2--TEXT"); 118 | }); 119 | 120 | test("from mutation", () => { 121 | child1.setText("some other text"); 122 | child2.setText("some other text"); 123 | expect(child1.text).toBe("some other text child1"); 124 | expect(child2.text).toBe("***some other text*** child2"); 125 | }); 126 | 127 | test("from action", async () => { 128 | child1.clearText(); 129 | child2.clearText(); 130 | 131 | await child1.loadText(); 132 | await child2.loadText(); 133 | expect(child1.text).toBe("CHILD1: yet another text child1"); 134 | expect(child2.text).toBe("***child2: yet another text*** child2"); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/local-functions.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | import { Action, Module, Mutation, VuexModule } from "../src"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | canTransform = true; 11 | text = "some text"; 12 | 13 | get upperCaseText() { 14 | return this.text.toUpperCase(); 15 | } 16 | get pascalText() { 17 | return this.transformText(); 18 | } 19 | private transformText() { 20 | // 'this' has access to state & getters 21 | return this.canTransform ? this.upperCaseText.replace(/ /g, "_") : ""; 22 | } 23 | 24 | @Mutation 25 | setText(text: string) { 26 | this.localSetText(text); 27 | } 28 | private localSetText(text: string) { 29 | // 'this' has state 30 | this.text = text; 31 | } 32 | 33 | @Action 34 | async loadText() { 35 | this.localLoadText(); 36 | } 37 | private localLoadText() { 38 | // 'this' has getters & mutations 39 | if (!this.upperCaseText) { 40 | this.setText("yet another text"); 41 | } 42 | } 43 | } 44 | 45 | const myModule = new MyModule({ store, name: "myModule" }); 46 | 47 | describe("local-functions", () => { 48 | test("from getter", () => { 49 | expect(myModule.pascalText).toBe("SOME_TEXT"); 50 | }); 51 | 52 | test("from mutation", () => { 53 | myModule.setText("some other text"); 54 | expect(myModule.text).toBe("some other text"); 55 | }); 56 | 57 | test("from action", async () => { 58 | myModule.setText(""); 59 | 60 | await myModule.loadText(); 61 | expect(myModule.text).toBe("yet another text"); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/module-reference.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, RegisterOptions } from "../src"; 2 | import Vuex from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | foo = "bar"; 11 | } 12 | 13 | @Module 14 | class OtherModule extends VuexModule { 15 | private myModule: MyModule; 16 | 17 | get moduleRef() { 18 | return this.myModule; 19 | } 20 | 21 | constructor(myModule: MyModule, options: RegisterOptions) { 22 | super(options); 23 | this.myModule = myModule; 24 | } 25 | } 26 | 27 | test("module references", () => { 28 | const myModule = new MyModule({ store, name: "myModule" }); 29 | const otherModule = new OtherModule(myModule, { store, name: "otherModule" }); 30 | expect(store.state.otherModule.myModule).toBeUndefined(); 31 | expect(otherModule.moduleRef).toBe(myModule); 32 | }); 33 | -------------------------------------------------------------------------------- /test/mutations-inheritance.ts: -------------------------------------------------------------------------------- 1 | import { Module, Mutation, VuexModule } from "../src"; 2 | import Vuex, { Store } from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | 7 | class ParentModule extends VuexModule { 8 | foo = "init"; 9 | 10 | @Mutation 11 | mutation1(value: string) { 12 | // 13 | } 14 | 15 | @Mutation 16 | mutation2(value: string) { 17 | // 18 | } 19 | 20 | @Mutation 21 | mutation3(value: string) { 22 | this.foo = value; 23 | } 24 | } 25 | 26 | @Module 27 | class Module1 extends ParentModule { 28 | private tag = "child1"; 29 | baz = "init" + this.tag; 30 | 31 | @Mutation 32 | mutation1(value: string) { 33 | this.baz = value + this.tag; 34 | } 35 | 36 | @Mutation 37 | mutation2(value: string) { 38 | this.foo = value + this.tag; 39 | } 40 | 41 | @Mutation 42 | mutation3(value: string) { 43 | super.mutation3(value); 44 | this.foo = this.foo + this.tag; 45 | } 46 | } 47 | 48 | @Module 49 | class Module2 extends ParentModule { 50 | private tag = "child2"; 51 | bar = "init" + this.tag; 52 | baz = "init" + this.tag; 53 | 54 | @Mutation 55 | mutation1(value: string) { 56 | this.bar = value + this.tag; 57 | } 58 | 59 | @Mutation 60 | mutation2(value: string) { 61 | this.baz = value + this.tag; 62 | } 63 | 64 | @Mutation 65 | mutation3(value: string) { 66 | super.mutation3(value); 67 | this.foo = this.foo + this.tag; 68 | } 69 | } 70 | 71 | describe("mutations-inheritance", () => { 72 | let store: Store; 73 | let parent: ParentModule; 74 | let child1: Module1; 75 | let child2: Module2; 76 | 77 | beforeEach(() => { 78 | store = new Vuex.Store({}); 79 | parent = new ParentModule({ store, name: "parentModule" }); 80 | child1 = new Module1({ store, name: "myModule1" }); 81 | child2 = new Module2({ store, name: "myModule2" }); 82 | }); 83 | 84 | test("overriden mutation can modify state", () => { 85 | parent.mutation1("_"); 86 | child1.mutation1("bar1"); 87 | child2.mutation1("bar2"); 88 | expect(parent.foo).toBe("init"); 89 | expect(child1.baz).toBe("bar1child1"); 90 | expect(child2.bar).toBe("bar2child2"); 91 | expect(child2.baz).toBe("initchild2"); 92 | }); 93 | 94 | test("overriden mutation can modify parent state", () => { 95 | parent.mutation2("_"); 96 | child1.mutation2("bar"); 97 | child2.mutation2("baz"); 98 | expect(parent.foo).toBe("init"); 99 | expect(child1.foo).toBe("barchild1"); 100 | expect(child2.bar).toBe("initchild2"); 101 | expect(child2.baz).toBe("bazchild2"); 102 | }); 103 | 104 | test("overriden mutation has access to parent method implementation", () => { 105 | parent.mutation3("foo_"); 106 | child1.mutation3("foo1"); 107 | child2.mutation3("foo2"); 108 | expect(parent.foo).toBe("foo_"); 109 | expect(child1.foo).toBe("foo1child1"); 110 | expect(child1.baz).toBe("initchild1"); 111 | expect(child2.foo).toBe("foo2child2"); 112 | expect(child2.bar).toBe("initchild2"); 113 | expect(child2.baz).toBe("initchild2"); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/mutations.ts: -------------------------------------------------------------------------------- 1 | import { Module, Mutation, VuexModule } from "../src"; 2 | import Vuex, { MutationPayload } from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | shouldUpdate = true; 11 | text = ""; 12 | 13 | @Mutation 14 | setText(text: string) { 15 | if (this.shouldUpdate) { 16 | this.text = text; 17 | } 18 | } 19 | } 20 | 21 | const myModule = new MyModule({ store, name: "myModule" }); 22 | 23 | describe("mutations", () => { 24 | test("accessor calls commit", () => { 25 | const mutationObserver = jest.fn((mutation: MutationPayload) => mutation); 26 | store.subscribe(mutationObserver); 27 | 28 | myModule.setText("some text"); 29 | 30 | expect(mutationObserver.mock.calls.length).toBe(1); 31 | 32 | const mutationPayload = mutationObserver.mock.results[0].value as MutationPayload; 33 | expect(mutationPayload.type).toBe("myModule/setText"); 34 | expect(mutationPayload.payload).toBe("some text"); 35 | }); 36 | 37 | test("updates store", () => { 38 | store.commit("myModule/setText", "some other text"); 39 | expect(store.state.myModule.text).toBe("some other text"); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/state.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule } from "../src"; 2 | import Vuex from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | foo = { 11 | text: "some text" 12 | }; 13 | bar = 1; 14 | 15 | square = (num: number) => num * num; 16 | } 17 | 18 | const myModule = new MyModule({ store, name: "myModule" }); 19 | 20 | test("state", () => { 21 | expect(myModule.foo).toBe(store.state.myModule.foo); 22 | expect(myModule.foo.text).toBe("some text"); 23 | 24 | expect(myModule.bar).toBe(store.state.myModule.bar); 25 | expect(myModule.bar).toBe(1); 26 | 27 | expect(myModule.square).toBe(store.state.myModule.square); 28 | expect(myModule.square(2)).toBe(4); 29 | }); 30 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "", 5 | "experimentalDecorators": true, 6 | "declaration": false 7 | }, 8 | "include": [ 9 | "./**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /test/watch.ts: -------------------------------------------------------------------------------- 1 | import { Module, Mutation, Action, VuexModule } from "../src"; 2 | import Vuex from "vuex"; 3 | import Vue from "vue"; 4 | 5 | Vue.use(Vuex); 6 | const store = new Vuex.Store({}); 7 | 8 | @Module 9 | class MyModule extends VuexModule { 10 | text = ""; 11 | 12 | get getText() { 13 | return this.text; 14 | } 15 | 16 | @Mutation 17 | setText(text: string) { 18 | this.text = text; 19 | } 20 | 21 | @Action 22 | async changeText(text: string) { 23 | this.setText(text); 24 | } 25 | } 26 | 27 | const myModule = new MyModule({ store, name: "myModule" }); 28 | 29 | describe("watch", () => { 30 | test("watch callback is called", async () => { 31 | const watchCallback = jest.fn((newValue: string, oldValue: string) => undefined); 32 | 33 | myModule.setText("bar"); 34 | myModule.$watch(theModule => theModule.getText, watchCallback); 35 | await myModule.changeText("foo"); 36 | 37 | expect(watchCallback.mock.calls.length).toBe(1); 38 | expect(watchCallback.mock.calls[0].length).toBe(2); 39 | expect(watchCallback.mock.calls[0][0]).toBe("foo"); 40 | expect(watchCallback.mock.calls[0][1]).toBe("bar"); 41 | }); 42 | 43 | test("watch for state changes as well", async () => { 44 | const watchCallback = jest.fn((newValue: string, oldValue: string) => undefined); 45 | 46 | myModule.setText("bar"); 47 | myModule.$watch(theModule => theModule.text, watchCallback); 48 | await myModule.changeText("foo"); 49 | 50 | expect(watchCallback.mock.calls.length).toBe(1); 51 | expect(watchCallback.mock.calls[0].length).toBe(2); 52 | expect(watchCallback.mock.calls[0][0]).toBe("foo"); 53 | expect(watchCallback.mock.calls[0][1]).toBe("bar"); 54 | }); 55 | 56 | test("watch should return unwatch func", async () => { 57 | const watchCallback = jest.fn((newValue: string, oldValue: string) => undefined); 58 | 59 | myModule.setText("bar"); 60 | const unwatch = myModule.$watch(theModule => theModule.text, watchCallback); 61 | await myModule.changeText("foo1"); 62 | 63 | expect(watchCallback.mock.calls.length).toBe(1); 64 | 65 | unwatch(); 66 | await myModule.changeText("foo2"); 67 | 68 | expect(watchCallback.mock.calls.length).toBe(1); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "dom", 8 | "es2015" 9 | ], 10 | "declaration": true, 11 | "outDir": "./lib", 12 | "strict": true, 13 | 14 | "experimentalDecorators": true 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules", "**/__tests__/*"] 18 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-config-prettier" 5 | ], 6 | "rulesDirectory": ["tslint-plugin-prettier"], 7 | "rules": { 8 | "prettier": [true, { "printWidth": 120 }], 9 | 10 | "no-namespace": false, 11 | "max-line-length": false, 12 | "interface-name": false, 13 | 14 | "arrow-parens": [false], 15 | "object-literal-sort-keys": false, 16 | "ordered-imports": [ 17 | false 18 | ], 19 | "member-access": [ 20 | true, "no-public" 21 | ], 22 | "max-classes-per-file": false, 23 | "trailing-comma": [ 24 | false 25 | ], 26 | "interface-over-type-literal": false, 27 | "no-console": [false], 28 | "one-line": false, 29 | "curly": false, 30 | "no-empty": [true, "allow-empty-catch", "allow-empty-functions"], 31 | "member-ordering": false, 32 | "no-unused-expression": false, 33 | "only-arrow-functions": false, 34 | "ban-types": [true, "Function"], 35 | "variable-name": false 36 | }, 37 | "defaultSeverity": "warning" 38 | } --------------------------------------------------------------------------------