├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .yarn └── releases │ └── yarn-berry.cjs ├── .yarnrc.yml ├── README.md ├── babel.config.js ├── bin ├── initialize-server.sh └── setup-server.sh ├── docs ├── first-class-collection.puml ├── notification.puml ├── state.puml └── strategy.puml ├── jest.config.js ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── index.html ├── renovate.json ├── server └── db.seed.json ├── spec └── views │ └── cart │ ├── cart-item-builder.ts │ ├── cart-item-list.spec.ts │ └── interaction.spec.ts ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ └── HelloWorld.vue ├── main.ts ├── router.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts └── views │ ├── Home.vue │ ├── button-behavior │ ├── controller │ │ └── interaction.ts │ ├── model │ │ ├── button-behavior.ts │ │ └── loading-state │ │ │ ├── loading-state.ts │ │ │ ├── state-factory.ts │ │ │ └── state-list.ts │ └── view │ │ ├── LoadingStateSelector.vue │ │ ├── LoadingStateSelectorList.vue │ │ └── View.vue │ ├── cart │ ├── controller │ │ └── interaction.ts │ ├── model │ │ ├── cart-item-list.ts │ │ ├── cart-item │ │ │ ├── product.ts │ │ │ └── state.ts │ │ ├── repository.ts │ │ └── repository │ │ │ ├── from-api.ts │ │ │ └── on-memory.ts │ └── view │ │ ├── Cart.vue │ │ ├── CartByCompositionApi.vue │ │ └── CartItem.vue │ ├── error │ ├── ErrorJob.vue │ ├── job-error.ts │ └── notification.ts │ └── todo │ ├── model │ ├── repository.ts │ ├── repository │ │ ├── from-api.ts │ │ └── on-memory.ts │ ├── select-task-state.ts │ ├── task-state-class.js │ ├── task-state-legacy.js │ ├── task-state-plane.js │ ├── task-state.ts │ └── todo.ts │ └── view │ ├── TodoList.vue │ └── TodoTask.vue ├── tsconfig.json └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/standard', 9 | '@vue/typescript' 10 | ], 11 | rules: { 12 | 'no-useless-constructor': 'off', 13 | 'no-unused-vars': 'off', 14 | camelcase: 'off', 15 | 'no-console': 'off', 16 | 'no-undef': 'off', // avoid the error on "components" key in vue-class-component decorator 17 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 18 | }, 19 | parserOptions: { 20 | parser: '@typescript-eslint/parser' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .yarn/releases/** binary 2 | .yarn/plugins/** binary 3 | 4 | * text eol-lf 5 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # jest not support v13 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | 26 | - name: Get yarn cache directory path 27 | id: yarn-cache-dir-path 28 | run: echo "::set-output name=dir::$(yarn cache dir)" 29 | 30 | - uses: actions/cache@v2 31 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 32 | with: 33 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 34 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-yarn- 37 | 38 | - name: Use Node.js ${{ matrix.node-version }} 39 | uses: actions/setup-node@v2 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | 43 | - run: yarn install 44 | - run: yarn add --peer 45 | - run: yarn ci 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | .node-version 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # mock server 11 | server/db.json 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Editor directories and files 19 | .idea 20 | .vscode 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # Yarn2 28 | .pnp.* 29 | .yarn/* 30 | !.yarn/patches 31 | !.yarn/plugins 32 | !.yarn/releases 33 | !.yarn/sdks 34 | !.yarn/versions 35 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: ".yarn/releases/yarn-berry.cjs" 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sample-for-vue-with-design-patterns 2 | ![Node.js CI](https://github.com/tooppoo/sample-for-vue-with-design-patterns/workflows/Node.js%20CI/badge.svg) 3 | 4 | ## Project setup 5 | ``` 6 | yarn install 7 | ``` 8 | 9 | ### Compiles and hot-reloads for development 10 | ``` 11 | yarn run serve 12 | ``` 13 | 14 | ### Compiles and minifies for production 15 | ``` 16 | yarn run build 17 | ``` 18 | 19 | ### Run your tests 20 | ``` 21 | yarn run test 22 | ``` 23 | 24 | ### Lints and fixes files 25 | ``` 26 | yarn run lint 27 | ``` 28 | 29 | ### Customize configuration 30 | See [Configuration Reference](https://cli.vuejs.org/config/). 31 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /bin/initialize-server.sh: -------------------------------------------------------------------------------- 1 | 2 | here=$(dirname $0) 3 | server=$here/../server 4 | 5 | cp $server/db.seed.json $server/db.json 6 | 7 | echo "initialized mock server" 8 | -------------------------------------------------------------------------------- /bin/setup-server.sh: -------------------------------------------------------------------------------- 1 | 2 | here=$(dirname $0) 3 | server=$here/../server 4 | 5 | if [ -e $server/db.json ] 6 | then 7 | echo "already initialized" 8 | else 9 | bash $here/initialize-server.sh 10 | fi 11 | -------------------------------------------------------------------------------- /docs/first-class-collection.puml: -------------------------------------------------------------------------------- 1 | @startuml First Class Collection Pattern 2 | class CartItemList { 3 | remove(item) 4 | add(item) 5 | 6 | buyLater(item) 7 | buyNow(item) 8 | 9 | onlyBuyNow(): CartItemList 10 | 11 | totalPrice(): number 12 | length(): number 13 | } 14 | 15 | CartView *-> CartItemView 16 | CartItemList o-> CartItem 17 | 18 | CartView --> CartItemList 19 | CartItemView --> CartItem 20 | @enduml -------------------------------------------------------------------------------- /docs/notification.puml: -------------------------------------------------------------------------------- 1 | @startuml Notification pattern 2 | 3 | class Notification { 4 | hasError(): boolean 5 | addError(error: Error): void 6 | } 7 | 8 | class Error { 9 | message: string 10 | } 11 | 12 | Notification o-> "0..n" Error 13 | 14 | Component -> Notification 15 | @enduml -------------------------------------------------------------------------------- /docs/state.puml: -------------------------------------------------------------------------------- 1 | @startuml State Pattern 2 | class TodoList 3 | class TodoTask 4 | 5 | interface TaskState 6 | 7 | TaskState <|-- NormalState 8 | TaskState <|-- CloseToLimitState 9 | TaskState <|-- LimitOverState 10 | 11 | TodoList o-> "0..n" TodoTask 12 | TodoTask o-> "1" TaskState 13 | @enduml 14 | -------------------------------------------------------------------------------- /docs/strategy.puml: -------------------------------------------------------------------------------- 1 | @startuml View And State 2 | package View { 3 | class App 4 | class "LoadingStateList" as VStateList 5 | class "LoadingState" as VState 6 | class Button 7 | 8 | App o-r-> VStateList 9 | VStateList o-r-> "n" VState 10 | 11 | App o-r-> Button 12 | } 13 | 14 | package Controller { 15 | class Interactor 16 | } 17 | 18 | package State { 19 | interface ButtonBehavior 20 | interface "LoadingState" as MState { 21 | activated: boolean 22 | } 23 | class "LoadingStateList" as MStateList 24 | MStateList o-l-> "n" MState 25 | 26 | AppState o--> MStateList 27 | Interactor .r.> AppState : create 28 | Interactor ..> ButtonBehavior : use 29 | } 30 | 31 | App --> Interactor 32 | App o--> AppState 33 | VStateList ..> MStateList 34 | VState ..> MState 35 | Button ..> ButtonBehavior 36 | @enduml 37 | 38 | @startuml Strategy Pattern 39 | package State { 40 | interface ButtonBehavior 41 | class "StateList" as MStateList 42 | interface "State" as MState { 43 | activated: boolean 44 | } 45 | AppState o-r-> MStateList 46 | MStateList o-r-> "n" MState 47 | MState -r-> "1" ButtonBehavior 48 | 49 | MState <|.. Loading 50 | MState <|.. Success 51 | MState <|.. Failure 52 | 53 | ButtonBehavior <|.. NoAction 54 | ButtonBehavior <|.. Alert 55 | ButtonBehavior <|.. Reload 56 | } 57 | 58 | package Controller { 59 | class Interactor 60 | } 61 | 62 | Interactor .r.> AppState : create 63 | Interactor ..> ButtonBehavior : use 64 | @enduml 65 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'ts'], 3 | moduleNameMapper: { 4 | '@\\/(.+)': ['/src/$1'] 5 | }, 6 | testMatch: [ 7 | '/spec/**/*.spec.ts' 8 | ], 9 | transform: { 10 | '^.+\\.(ts|tsx)$': 'ts-jest' 11 | }, 12 | verbose: true 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "run-p launch api", 7 | "serve:init": "run-p launch api:init", 8 | "build": "vue-cli-service build", 9 | "lint": "vue-cli-service lint", 10 | "launch": "vue-cli-service serve", 11 | "api": "run-s setup-api launch-api", 12 | "api:init": "run-s init-api launch-api", 13 | "setup-api": "bash ./bin/setup-server.sh", 14 | "init-api": "bash bin/initialize-server.sh", 15 | "launch-api": "json-server --watch --port 8090 server/db.json", 16 | "test": "jest", 17 | "ci": "run-p build test lint" 18 | }, 19 | "engines": { 20 | "node": "^12.0.0 || ^14.0.0 || ^16.0.0" 21 | }, 22 | "dependencies": { 23 | "core-js": "3.19.1", 24 | "vue": "2.6.14", 25 | "vue-class-component": "7.2.6", 26 | "vue-property-decorator": "9.1.2", 27 | "vue-router": "3.5.3" 28 | }, 29 | "devDependencies": { 30 | "@types/jest": "27.0.2", 31 | "@typescript-eslint/eslint-plugin": "4.33.0", 32 | "@typescript-eslint/parser": "4.33.0", 33 | "@vue/cli-plugin-babel": "4.5.15", 34 | "@vue/cli-plugin-eslint": "4.5.15", 35 | "@vue/cli-plugin-typescript": "4.5.15", 36 | "@vue/cli-service": "4.5.15", 37 | "@vue/composition-api": "1.3.3", 38 | "@vue/eslint-config-standard": "6.1.0", 39 | "@vue/eslint-config-typescript": "7.0.0", 40 | "autoprefixer": "^10.4.0", 41 | "eslint": "7.32.0", 42 | "eslint-plugin-import": "2.25.3", 43 | "eslint-plugin-node": "11.1.0", 44 | "eslint-plugin-promise": "5.1.1", 45 | "eslint-plugin-standard": "4.1.0", 46 | "eslint-plugin-vue": "7.20.0", 47 | "jest": "27.3.1", 48 | "json-server": "0.17.0", 49 | "npm-run-all": "4.1.5", 50 | "postcss": "^8.3.11", 51 | "ts-jest": "27.0.7", 52 | "typescript": "4.4.4", 53 | "vue-template-compiler": "2.6.14" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tooppoo/sample-for-vue-with-design-patterns/c169beb26f3557ae1469df5818aeff88434cb28a/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | sample-for-vue-with-design-patterns 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "github>tooppoo/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /server/db.seed.json: -------------------------------------------------------------------------------- 1 | { 2 | "todos": [ 3 | { 4 | "id": 1, 5 | "content": "タスク1", 6 | "limit_at": "2020-08-10", 7 | "completed": false 8 | }, 9 | { 10 | "id": 2, 11 | "content": "タスク2", 12 | "limit_at": "2020-07-05", 13 | "completed": false 14 | }, 15 | { 16 | "id": 3, 17 | "content": "タスク3", 18 | "limit_at": "2020-06-20", 19 | "completed": false 20 | } 21 | ], 22 | "cart_items": [ 23 | { 24 | "id": "cup-noodle", 25 | "image_url": "https://1.bp.blogspot.com/-oTMkckUVbRo/XT_Lb4t5ONI/AAAAAAABT6M/vXST7HLpgPU4elBVqIQVuof9Ui4-5PUYwCLcBGAs/s800/food_cup_ra-men_close.png", 26 | "product_name": "カップラーメン", 27 | "unit_price": 180, 28 | "unit_count": 1, 29 | "will_purchase": true 30 | }, 31 | { 32 | "id": "baked-cake-fish-tube", 33 | "image_url": "https://3.bp.blogspot.com/-0cxQy6x5pk0/WyH_0OKogUI/AAAAAAABMtI/ndUgGKr7P1sbNKKR1cFegNk90zTxyLv_gCLcBGAs/s800/food_yaki_chikuwa.png", 34 | "product_name": "焼き竹輪", 35 | "unit_price": 100, 36 | "unit_count": 1, 37 | "will_purchase": true 38 | }, 39 | { 40 | "id": "whisky", 41 | "image_url": "https://4.bp.blogspot.com/-0blALkNdKAg/W8BOQd0NHcI/AAAAAAABPXQ/T9Fdxh6R_DQSO6ncVIVBbYtBBf5rbWUZgCLcBGAs/s800/drink_whisky_irish.png", 42 | "product_name": "ウィスキー", 43 | "unit_price": 5000, 44 | "unit_count": 1, 45 | "will_purchase": true 46 | }, 47 | { 48 | "id": "miso-soup", 49 | "image_url": "https://2.bp.blogspot.com/-bznPJlS0rUQ/Wm1ylMeQrzI/AAAAAAABJ6E/xMvem7AfbcUiUtpAVP92Iabml8a6r7ruQCLcBGAs/s800/food_misoshiru_asari.png", 50 | "product_name": "味噌汁", 51 | "unit_price": 300, 52 | "unit_count": 1, 53 | "will_purchase": true 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /spec/views/cart/cart-item-builder.ts: -------------------------------------------------------------------------------- 1 | import { CartItem, CartItemCount, UnitPrice } from '@/views/cart/model/cart-item-list' 2 | import { Product } from '@/views/cart/model/cart-item/product' 3 | import { CartItemState } from '@/views/cart/model/cart-item/state' 4 | 5 | export class CartItemBuilder { 6 | static create (): CartItemBuilder { 7 | return new CartItemBuilder( 8 | { 9 | id: 'test', 10 | name: 'てすと', 11 | image: 'image.png', 12 | unitPrice: UnitPrice.valueOf(1000) 13 | }, 14 | CartItemCount.valueOf(1), 15 | { 16 | buyNow: true 17 | } 18 | ) 19 | } 20 | 21 | private constructor ( 22 | private item: Product, 23 | private count: CartItemCount, 24 | private state: CartItemState 25 | ) { } 26 | 27 | idIs (id: string): CartItemBuilder { 28 | return new CartItemBuilder( 29 | { 30 | ...this.item, 31 | id 32 | }, 33 | this.count, 34 | this.state 35 | ) 36 | } 37 | 38 | itemPriceIs (price: number): CartItemBuilder { 39 | return new CartItemBuilder( 40 | { 41 | ...this.item, 42 | unitPrice: UnitPrice.valueOf(price) 43 | }, 44 | this.count, 45 | this.state 46 | ) 47 | } 48 | 49 | countIs (count: number): CartItemBuilder { 50 | return new CartItemBuilder( 51 | this.item, 52 | CartItemCount.valueOf(count), 53 | this.state 54 | ) 55 | } 56 | 57 | build (): CartItem { 58 | return CartItem.valueOf({ 59 | item: this.item, 60 | count: this.count, 61 | state: this.state 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /spec/views/cart/cart-item-list.spec.ts: -------------------------------------------------------------------------------- 1 | import { CartItem, CartItemCount, CartItemList } from '@/views/cart/model/cart-item-list' 2 | import { CartItemBuilder } from './cart-item-builder' 3 | 4 | describe(CartItemList, () => { 5 | describe('totalPrice', () => { 6 | const builder = CartItemBuilder.create() 7 | 8 | describe.each([ 9 | [ 10 | CartItemList.valueOf([ 11 | builder.itemPriceIs(1000).build(), 12 | builder.itemPriceIs(2000).build(), 13 | builder.itemPriceIs(3000).build() 14 | ]), 15 | 6000 16 | ], 17 | [ 18 | CartItemList.valueOf([ 19 | builder.itemPriceIs(1000).countIs(1).build(), 20 | builder.itemPriceIs(2000).countIs(2).build(), 21 | builder.itemPriceIs(3000).countIs(3).build() 22 | ]), 23 | 14000 24 | ] 25 | ])( 26 | 'when list is %p', 27 | (list: CartItemList, expected: number) => { 28 | it(`should return ${expected}`, () => { 29 | expect(list.totalPayment).toBe(expected) 30 | }) 31 | } 32 | ) 33 | }) 34 | describe('totalCount', () => { 35 | const builder = CartItemBuilder.create() 36 | 37 | describe.each([ 38 | [ 39 | CartItemList.valueOf([ 40 | builder.countIs(1).build(), 41 | builder.countIs(1).build(), 42 | builder.countIs(1).build() 43 | ]), 44 | CartItemCount.valueOf(3) 45 | ], 46 | [ 47 | CartItemList.valueOf([ 48 | builder.countIs(1).build(), 49 | builder.countIs(2).build(), 50 | builder.countIs(3).build() 51 | ]), 52 | CartItemCount.valueOf(6) 53 | ] 54 | ])( 55 | 'when list is %p', 56 | (list: CartItemList, expected: CartItemCount) => { 57 | it(`should return ${expected}`, () => { 58 | expect(list.totalCount).toStrictEqual(expected) 59 | }) 60 | } 61 | ) 62 | }) 63 | describe('replace', () => { 64 | const builder = CartItemBuilder.create() 65 | 66 | describe.each([ 67 | [ 68 | CartItemList.valueOf([ 69 | builder.idIs('item1').build().buyNow(), 70 | builder.idIs('item2').build().buyNow(), 71 | builder.idIs('item3').build().buyNow() 72 | ]), 73 | builder.idIs('item2').build().buyLater(), 74 | CartItemList.valueOf([ 75 | builder.idIs('item1').build().buyNow(), 76 | builder.idIs('item2').build().buyLater(), 77 | builder.idIs('item3').build().buyNow() 78 | ]) 79 | ], 80 | [ 81 | CartItemList.valueOf([ 82 | builder.idIs('item1').build().buyNow(), 83 | builder.idIs('item2').build().buyNow(), 84 | builder.idIs('item3').build().buyNow() 85 | ]), 86 | builder.idIs('none').build().buyLater(), 87 | CartItemList.valueOf([ 88 | builder.idIs('item1').build().buyNow(), 89 | builder.idIs('item2').build().buyNow(), 90 | builder.idIs('item3').build().buyNow() 91 | ]) 92 | ] 93 | ])( 94 | 'when list %s replace by %s', 95 | (list: CartItemList, target: CartItem, expected: CartItemList) => { 96 | it(`should return ${expected}`, () => { 97 | expect(list.replace(target)).toStrictEqual(expected) 98 | }) 99 | } 100 | ) 101 | }) 102 | describe('onlyBuyNow', () => { 103 | const builder = CartItemBuilder.create() 104 | 105 | describe.each([ 106 | [ 107 | CartItemList.valueOf([ 108 | builder.idIs('item1').build().buyNow(), 109 | builder.idIs('item2').build().buyNow(), 110 | builder.idIs('item3').build().buyNow() 111 | ]), 112 | CartItemList.valueOf([ 113 | builder.idIs('item1').build().buyNow(), 114 | builder.idIs('item2').build().buyNow(), 115 | builder.idIs('item3').build().buyNow() 116 | ]) 117 | ], 118 | [ 119 | CartItemList.valueOf([ 120 | builder.idIs('item1').build().buyNow(), 121 | builder.idIs('item2').build().buyLater(), 122 | builder.idIs('item3').build().buyNow() 123 | ]), 124 | CartItemList.valueOf([ 125 | builder.idIs('item1').build().buyNow(), 126 | builder.idIs('item3').build().buyNow() 127 | ]) 128 | ], 129 | [ 130 | CartItemList.valueOf([ 131 | builder.idIs('item1').build().buyLater(), 132 | builder.idIs('item2').build().buyLater(), 133 | builder.idIs('item3').build().buyLater() 134 | ]), 135 | CartItemList.valueOf([]) 136 | ] 137 | ])( 138 | 'when list is %s', 139 | (list: CartItemList, expected: CartItemList) => { 140 | it(`should return ${expected}`, () => { 141 | expect(list.onlyBuyNow()).toStrictEqual(expected) 142 | }) 143 | } 144 | ) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /spec/views/cart/interaction.spec.ts: -------------------------------------------------------------------------------- 1 | import { CartItem, CartItemCount, CartItemList} from '@/views/cart/model/cart-item-list' 2 | import { CartInteraction } from '@/views/cart/controller/interaction' 3 | import { CartItemListRepository } from '@/views/cart/model/repository' 4 | import { CartItemBuilder } from './cart-item-builder' 5 | 6 | const builder = CartItemBuilder.create() 7 | const item1 = builder.idIs('item-1').countIs(1).build().buyNow() 8 | const item2 = builder.idIs('item-2').countIs(1).build().buyLater() 9 | const item3 = builder.idIs('item-3').countIs(1).build().buyNow() 10 | 11 | class TestRepository implements CartItemListRepository { 12 | readonly saved: CartItem[] = [] 13 | readonly deleted: CartItem[] = [] 14 | 15 | async list () { 16 | return CartItemList.valueOf([item1, item2, item3]) 17 | } 18 | 19 | async save (cartItem: CartItem): Promise { 20 | this.saved.push(cartItem) 21 | } 22 | 23 | async delete (cartItem: CartItem): Promise { 24 | this.deleted.push(cartItem) 25 | } 26 | } 27 | describe(CartInteraction, () => { 28 | let repository: TestRepository 29 | let interaction: CartInteraction 30 | 31 | beforeEach(async () => { 32 | repository = new TestRepository() 33 | interaction = CartInteraction.create({ repository }) 34 | 35 | await interaction.initialize() 36 | }) 37 | 38 | describe('buyNow', () => { 39 | it('should replace target item as buy-now', async () => { 40 | await interaction.buyNow(item2) 41 | 42 | await expect(interaction.cartItemList).toStrictEqual(CartItemList.valueOf([ 43 | item1, 44 | item2.buyNow(), 45 | item3 46 | ])) 47 | }) 48 | it('should save via repository', async () => { 49 | await interaction.buyNow(item2) 50 | 51 | expect(repository.saved).toStrictEqual([item2.buyNow()]) 52 | }) 53 | }) 54 | describe('buyLater', () => { 55 | it('should replace target item as buy-later', async () => { 56 | await interaction.buyLater(item3) 57 | 58 | await expect(interaction.cartItemList).toStrictEqual(CartItemList.valueOf([ 59 | item1, 60 | item2, 61 | item3.buyLater() 62 | ])) 63 | }) 64 | it('should save via repository', async () => { 65 | await interaction.buyLater(item3) 66 | 67 | expect(repository.saved).toStrictEqual([item3.buyLater()]) 68 | }) 69 | }) 70 | describe('changeCount', () => { 71 | it('should update count in cart of target', async () => { 72 | await interaction.changeCount(item1, CartItemCount.valueOf(3)) 73 | 74 | await expect(interaction.cartItemList).toStrictEqual(CartItemList.valueOf([ 75 | item1.changeCount(CartItemCount.valueOf(3)), 76 | item2, 77 | item3 78 | ])) 79 | }) 80 | it('should save via repository', async () => { 81 | await interaction.changeCount(item1, CartItemCount.valueOf(3)) 82 | 83 | expect(repository.saved).toStrictEqual([item1.changeCount(CartItemCount.valueOf(3))]) 84 | }) 85 | }) 86 | 87 | describe('remove', () => { 88 | it('should remove target', async () => { 89 | await interaction.remove(item1) 90 | 91 | await expect(interaction.cartItemList).toStrictEqual(CartItemList.valueOf([ 92 | item2, item3 93 | ])) 94 | }) 95 | it('should remove via repository', async () => { 96 | await interaction.remove(item1) 97 | 98 | expect(repository.deleted).toStrictEqual([item1]) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 56 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tooppoo/sample-for-vue-with-design-patterns/c169beb26f3557ae1469df5818aeff88434cb28a/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 42 | 43 | 44 | 60 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | 5 | import VueCompositionAPI from '@vue/composition-api' 6 | 7 | Vue.config.productionTip = false 8 | 9 | Vue.use(VueCompositionAPI) 10 | 11 | new Vue({ 12 | router, 13 | render: h => h(App) 14 | }).$mount('#app') 15 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Home from './views/Home.vue' 4 | import { OnMemoryCartItemListRepository } from './views/cart/model/repository/on-memory' 5 | import { FromApiCartItemListRepository } from './views/cart/model/repository/from-api' 6 | import { OnMemoryTodoRepository } from './views/todo/model/repository/on-memory' 7 | import { FromApiTodoRepository } from './views/todo/model/repository/from-api' 8 | 9 | Vue.use(Router) 10 | 11 | export default new Router({ 12 | mode: 'history', 13 | base: process.env.BASE_URL, 14 | routes: [ 15 | { 16 | path: '/', 17 | name: 'home', 18 | component: Home 19 | }, 20 | { 21 | path: '/state-pattern/on-memory', 22 | name: 'state-on-memory', 23 | // route level code-splitting 24 | // this generates a separate chunk (about.[hash].js) for this route 25 | // which is lazy-loaded when the route is visited. 26 | component: () => import(/* webpackChunkName: "about" */ './views/todo/view/TodoList.vue'), 27 | props: () => ({ 28 | repository: new OnMemoryTodoRepository() 29 | }) 30 | }, 31 | { 32 | path: '/state-pattern/from-api', 33 | name: 'state-from-api', 34 | // route level code-splitting 35 | // this generates a separate chunk (about.[hash].js) for this route 36 | // which is lazy-loaded when the route is visited. 37 | component: () => import(/* webpackChunkName: "about" */ './views/todo/view/TodoList.vue'), 38 | props: () => ({ 39 | repository: new FromApiTodoRepository() 40 | }) 41 | }, 42 | { 43 | path: '/notification-pattern', 44 | name: 'notification', 45 | // route level code-splitting 46 | // this generates a separate chunk (about.[hash].js) for this route 47 | // which is lazy-loaded when the route is visited. 48 | component: () => import(/* webpackChunkName: "about" */ './views/error/ErrorJob.vue') 49 | }, 50 | { 51 | path: '/first-class-collection-pattern/on-memory', 52 | name: 'first-class-collection-on-memory', 53 | // route level code-splitting 54 | // this generates a separate chunk (about.[hash].js) for this route 55 | // which is lazy-loaded when the route is visited. 56 | component: () => import(/* webpackChunkName: "about" */ './views/cart/view/Cart.vue'), 57 | props: () => ({ 58 | repository: new OnMemoryCartItemListRepository() 59 | }) 60 | }, 61 | { 62 | path: '/first-class-collection-pattern/from-api', 63 | name: 'first-class-collection-on-memory/from-api', 64 | // route level code-splitting 65 | // this generates a separate chunk (about.[hash].js) for this route 66 | // which is lazy-loaded when the route is visited. 67 | component: () => import(/* webpackChunkName: "about" */ './views/cart/view/Cart.vue'), 68 | props: () => ({ 69 | repository: new FromApiCartItemListRepository() 70 | }) 71 | }, 72 | { 73 | path: '/first-class-collection-pattern/composition-api', 74 | name: 'first-class-collection-on-memory/composition-api', 75 | // route level code-splitting 76 | // this generates a separate chunk (about.[hash].js) for this route 77 | // which is lazy-loaded when the route is visited. 78 | component: () => import(/* webpackChunkName: "about" */ './views/cart/view/CartByCompositionApi.vue'), 79 | props: () => ({ 80 | repository: new OnMemoryCartItemListRepository() 81 | }) 82 | }, 83 | { 84 | path: '/strategy-pattern', 85 | name: 'strategy', 86 | // route level code-splitting 87 | // this generates a separate chunk (about.[hash].js) for this route 88 | // which is lazy-loaded when the route is visited. 89 | component: () => import(/* webpackChunkName: "about" */ './views/button-behavior/view/View.vue') 90 | } 91 | 92 | ] 93 | }) 94 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /src/views/button-behavior/controller/interaction.ts: -------------------------------------------------------------------------------- 1 | import { LoadingStateList } from '@/views/button-behavior/model/loading-state/state-list' 2 | import { Alert, Disable, NoAction, Reload } from '../model/button-behavior' 3 | import { LoadingState, WhenFailed, WhenLoading, WhenSuccess } from '../model/loading-state/loading-state' 4 | 5 | export interface AppState { 6 | states: LoadingStateList 7 | } 8 | 9 | export class Interaction { 10 | initialize (): AppState { 11 | const states = LoadingStateList.create([ 12 | WhenLoading(Disable(NoAction('Now Loading...'))).activate(), 13 | WhenSuccess(Alert('Success!!')('Click Me!!')), 14 | WhenFailed(Reload('Please Retry')) 15 | ]) 16 | 17 | return { states } 18 | } 19 | 20 | selectStatus (selected: LoadingState, { states }: AppState): AppState { 21 | return { 22 | states: states.activate(selected) 23 | } 24 | } 25 | 26 | currentState ({ states }: AppState): LoadingState { 27 | const activated = states.find(s => s.isActivated) 28 | 29 | if (activated === null) { 30 | throw new Error('not found activated state') 31 | } 32 | 33 | return activated 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/views/button-behavior/model/button-behavior.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface ButtonBehavior { 3 | readonly label: string 4 | readonly isDisable: boolean 5 | onClick (): void 6 | } 7 | 8 | const buildAction = (action: () => void) => (label: string): ButtonBehavior => ({ 9 | label, 10 | onClick: action, 11 | isDisable: false 12 | }) 13 | 14 | export const Disable = (behavior: ButtonBehavior): ButtonBehavior => ({ 15 | ...behavior, 16 | isDisable: true 17 | }) 18 | 19 | export const NoAction = buildAction(() => {}) 20 | export const Alert = (message: string) => buildAction(() => alert(message)) 21 | export const Reload = buildAction(() => location.reload()) 22 | -------------------------------------------------------------------------------- /src/views/button-behavior/model/loading-state/loading-state.ts: -------------------------------------------------------------------------------- 1 | import { buildState, StateFactory } from '@/views/button-behavior/model/loading-state/state-factory' 2 | import { ButtonBehavior } from '../button-behavior' 3 | 4 | export type StateValue = 'loading' | 'success' | 'failed' 5 | export interface LoadingState { 6 | readonly label: string 7 | readonly value: StateValue 8 | readonly isActivated: boolean 9 | readonly buttonBehavior: ButtonBehavior 10 | 11 | activate(): LoadingState 12 | inactivate(): LoadingState 13 | 14 | equals(other: LoadingState): boolean 15 | } 16 | 17 | export const WhenLoading: StateFactory = buildState('WhenLoading', 'loading') 18 | export const WhenSuccess: StateFactory = buildState('WhenSuccess', 'success') 19 | export const WhenFailed: StateFactory = buildState('WhenFailed', 'failed') 20 | -------------------------------------------------------------------------------- /src/views/button-behavior/model/loading-state/state-factory.ts: -------------------------------------------------------------------------------- 1 | import { ButtonBehavior } from '../button-behavior' 2 | import { LoadingState, StateValue } from './loading-state' 3 | 4 | export type StateFactory = (bb: ButtonBehavior) => LoadingState 5 | export const buildState = (label: string, value: StateValue) => 6 | (buttonBehavior: ButtonBehavior) => 7 | BaseState.initialize(label, value, buttonBehavior) 8 | 9 | class BaseState implements LoadingState { 10 | static initialize (label: string, value: StateValue, buttonBehavior: ButtonBehavior): LoadingState { 11 | return new BaseState(label, value, false, buttonBehavior) 12 | } 13 | 14 | private constructor ( 15 | readonly label: string, 16 | readonly value: StateValue, 17 | readonly isActivated: boolean, 18 | readonly buttonBehavior: ButtonBehavior 19 | ) { } 20 | 21 | activate (): LoadingState { 22 | return new BaseState(this.label, this.value, true, this.buttonBehavior) 23 | } 24 | 25 | inactivate (): LoadingState { 26 | return new BaseState(this.label, this.value, false, this.buttonBehavior) 27 | } 28 | 29 | equals (other: LoadingState): boolean { 30 | return this.value === other.value 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/views/button-behavior/model/loading-state/state-list.ts: -------------------------------------------------------------------------------- 1 | import { LoadingState } from '@/views/button-behavior/model/loading-state/loading-state' 2 | 3 | export class LoadingStateList { 4 | static create (states: readonly LoadingState[]): LoadingStateList { 5 | return new LoadingStateList(states) 6 | } 7 | 8 | private constructor (private readonly states: readonly LoadingState[]) { 9 | if (states.every(s => !s.isActivated)) { 10 | throw new Error('at least one state is activated') 11 | } 12 | } 13 | 14 | find (finder: (s: LoadingState) => boolean): LoadingState | null { 15 | return this.states.find(finder) || null 16 | } 17 | 18 | activate (target: LoadingState): LoadingStateList { 19 | return new LoadingStateList( 20 | this.states.map( 21 | s => target.equals(s) ? target.activate() : s.inactivate() 22 | ) 23 | ) 24 | } 25 | 26 | toArray (): readonly LoadingState[] { 27 | return this.states 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/views/button-behavior/view/LoadingStateSelector.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 43 | 44 | 49 | -------------------------------------------------------------------------------- /src/views/button-behavior/view/LoadingStateSelectorList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 36 | 37 | 45 | -------------------------------------------------------------------------------- /src/views/button-behavior/view/View.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 68 | 69 | 81 | -------------------------------------------------------------------------------- /src/views/cart/controller/interaction.ts: -------------------------------------------------------------------------------- 1 | import { CartItem, CartItemCount, CartItemList } from '@/views/cart/model/cart-item-list' 2 | import { CartItemListRepository } from '@/views/cart/model/repository' 3 | 4 | export class CartInteraction { 5 | static create ({ repository }: { repository: CartItemListRepository }): CartInteraction { 6 | return new CartInteraction(repository) 7 | } 8 | 9 | private list: CartItemList = CartItemList.empty() 10 | 11 | private constructor (private readonly repository: CartItemListRepository) {} 12 | 13 | get cartItemList (): CartItemList { 14 | return this.list 15 | } 16 | 17 | async initialize (): Promise { 18 | this.list = await this.repository.list() 19 | } 20 | 21 | async buyNow (target: CartItem): Promise { 22 | await this.tryUpdate(target.buyNow()) 23 | } 24 | 25 | async buyLater (target: CartItem): Promise { 26 | await this.tryUpdate(target.buyLater()) 27 | } 28 | 29 | async changeCount (target: CartItem, newCount: CartItemCount): Promise { 30 | await this.tryUpdate(target.changeCount(newCount)) 31 | } 32 | 33 | async remove (target: CartItem): Promise { 34 | await this.repository.delete(target) 35 | 36 | this.list = this.list.remove(target) 37 | } 38 | 39 | private async tryUpdate (target: CartItem) { 40 | await this.repository.save(target) 41 | 42 | this.list = this.list.replace(target) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/views/cart/model/cart-item-list.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '@/views/cart/model/cart-item/product' 2 | import { CartItemState } from '@/views/cart/model/cart-item/state' 3 | 4 | export class CartItemList { 5 | static valueOf (items: CartItem[]): CartItemList { 6 | return new CartItemList(items) 7 | } 8 | 9 | static empty (): CartItemList { 10 | return new CartItemList([]) 11 | } 12 | 13 | private constructor (private readonly cartItems: CartItem[]) { } 14 | 15 | get totalPayment (): number { 16 | return this.cartItems.reduce( 17 | (total: number, cartItem: CartItem) => total + cartItem.payment, 18 | 0 19 | ) 20 | } 21 | 22 | get totalCount (): CartItemCount { 23 | return this.cartItems.reduce( 24 | (total: CartItemCount, cartItem: CartItem) => total.plus(cartItem.count), 25 | CartItemCount.zero 26 | ) 27 | } 28 | 29 | get length (): number { 30 | return this.cartItems.length 31 | } 32 | 33 | onlyBuyNow (): CartItemList { 34 | return this.filter(cartItem => cartItem.willBuyNow) 35 | } 36 | 37 | remove (target: CartItem): CartItemList { 38 | return this.filter(stored => !stored.equals(target)) 39 | } 40 | 41 | replace (target: CartItem): CartItemList { 42 | return new CartItemList( 43 | this.cartItems.map(cartItem => cartItem.equals(target) ? target : cartItem) 44 | ) 45 | } 46 | 47 | toArray (): CartItem[] { 48 | return [...this.cartItems] // shallow copy 49 | } 50 | 51 | private filter (filter: (cartItem: CartItem) => boolean): CartItemList { 52 | return new CartItemList( 53 | this.cartItems.filter(filter) 54 | ) 55 | } 56 | } 57 | 58 | export class CartItem { 59 | static valueOf ( 60 | { item, count, state }: { item: Product, count: CartItemCount, state: CartItemState } 61 | ): CartItem { 62 | return new CartItem(item, count, state) 63 | } 64 | 65 | private constructor ( 66 | readonly item: Product, 67 | readonly count: CartItemCount, 68 | private readonly state: CartItemState 69 | ) {} 70 | 71 | get id (): string { 72 | return this.item.id 73 | } 74 | 75 | get payment (): number { 76 | return this.item.unitPrice.applyCount(this.count) 77 | } 78 | 79 | equals (other: CartItem): boolean { 80 | return this.id === other.id 81 | } 82 | 83 | get willBuyNow (): boolean { 84 | return this.state.buyNow 85 | } 86 | 87 | changeCount (count: CartItemCount): CartItem { 88 | return CartItem.valueOf({ 89 | item: this.item, 90 | count, 91 | state: this.state 92 | }) 93 | } 94 | 95 | buyNow (): CartItem { 96 | return CartItem.valueOf({ 97 | item: this.item, 98 | count: this.count, 99 | state: { 100 | buyNow: true 101 | } 102 | }) 103 | } 104 | 105 | buyLater (): CartItem { 106 | return CartItem.valueOf({ 107 | item: this.item, 108 | count: this.count, 109 | state: { 110 | buyNow: false 111 | } 112 | }) 113 | } 114 | } 115 | 116 | export class CartItemCount { 117 | static readonly zero: CartItemCount = new CartItemCount(0) 118 | 119 | static valueOf (value: number): CartItemCount { 120 | if (value === 0) { 121 | return CartItemCount.zero 122 | } 123 | 124 | return new CartItemCount(value) 125 | } 126 | 127 | private constructor (private readonly value: number) { 128 | if (value < 0) { 129 | throw new Error(`cart item count must be >= 0, but ${value}`) 130 | } 131 | } 132 | 133 | plus (other: CartItemCount): CartItemCount { 134 | return CartItemCount.valueOf(this.value + other.value) 135 | } 136 | 137 | toNumber (): number { 138 | return this.value 139 | } 140 | 141 | toString (): string { 142 | return `${this.value}` 143 | } 144 | } 145 | 146 | export class UnitPrice { 147 | static valueOf (value: number): UnitPrice { 148 | return new UnitPrice(value) 149 | } 150 | 151 | private constructor (private readonly value: number) { 152 | if (value < 0) { 153 | throw new Error(`price must be >= 0, but ${value}`) 154 | } 155 | } 156 | 157 | applyCount (count: CartItemCount): number { 158 | return this.value * count.toNumber() 159 | } 160 | 161 | toNumber (): number { 162 | return this.value 163 | } 164 | 165 | toString (): string { 166 | return `${this.value}` 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/views/cart/model/cart-item/product.ts: -------------------------------------------------------------------------------- 1 | import { UnitPrice } from '@/views/cart/model/cart-item-list' 2 | 3 | export interface Product { 4 | id: string 5 | image: string 6 | name: string 7 | unitPrice: UnitPrice 8 | } 9 | -------------------------------------------------------------------------------- /src/views/cart/model/cart-item/state.ts: -------------------------------------------------------------------------------- 1 | export interface CartItemState { 2 | buyNow: boolean 3 | } 4 | -------------------------------------------------------------------------------- /src/views/cart/model/repository.ts: -------------------------------------------------------------------------------- 1 | import { CartItem, CartItemList } from '@/views/cart/model/cart-item-list' 2 | 3 | export interface CartItemListRepository { 4 | list(): Promise 5 | 6 | save(cartItem: CartItem): Promise 7 | 8 | delete(cartItem: CartItem): Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/views/cart/model/repository/from-api.ts: -------------------------------------------------------------------------------- 1 | import { CartItemListRepository } from '@/views/cart/model/repository' 2 | import { CartItemList, CartItem, CartItemCount, UnitPrice } from '../cart-item-list' 3 | 4 | interface CartItemResponse { 5 | id: string 6 | image_url: string 7 | product_name: string 8 | unit_price: number 9 | unit_count: number 10 | will_purchase: boolean 11 | } 12 | 13 | export class FromApiCartItemListRepository implements CartItemListRepository { 14 | constructor ( 15 | private readonly baseUrl: string = 'http://localhost:8090/cart_items' 16 | ) {} 17 | 18 | async list (): Promise { 19 | const response = await fetch(this.baseUrl) 20 | const responseItems: CartItemResponse[] = await response.json() 21 | 22 | return this.convertResponseToDomainObject(responseItems) 23 | } 24 | 25 | async save (cartItem: CartItem): Promise { 26 | await fetch(`${this.baseUrl}/${cartItem.id}`, { 27 | method: 'PUT', 28 | headers: { 29 | 'Content-Type': 'application/json' 30 | }, 31 | body: JSON.stringify({ 32 | id: cartItem.id, 33 | image_url: cartItem.item.image, 34 | product_name: cartItem.item.name, 35 | unit_price: cartItem.item.unitPrice, 36 | unit_count: cartItem.count.toNumber(), 37 | will_purchase: cartItem.willBuyNow 38 | }) 39 | }) 40 | } 41 | 42 | async delete (cartItem: CartItem): Promise { 43 | await fetch(`${this.baseUrl}/${cartItem.id}`, { 44 | method: 'DELETE' 45 | }) 46 | } 47 | 48 | private convertResponseToDomainObject (responseItems: CartItemResponse[]): CartItemList { 49 | return CartItemList.valueOf( 50 | responseItems.map(item => CartItem.valueOf({ 51 | item: { 52 | id: item.id, 53 | name: item.product_name, 54 | unitPrice: UnitPrice.valueOf(item.unit_price), 55 | image: item.image_url 56 | }, 57 | count: CartItemCount.valueOf(item.unit_count), 58 | state: { 59 | buyNow: item.will_purchase 60 | } 61 | })) 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/views/cart/model/repository/on-memory.ts: -------------------------------------------------------------------------------- 1 | import { CartItemListRepository } from '@/views/cart/model/repository' 2 | import { CartItemList, CartItem, CartItemCount, UnitPrice } from '../cart-item-list' 3 | 4 | export class OnMemoryCartItemListRepository implements CartItemListRepository { 5 | private _list = CartItemList.valueOf([ 6 | CartItem.valueOf({ 7 | item: { 8 | id: 'cup-noodle', 9 | image: 'https://1.bp.blogspot.com/-oTMkckUVbRo/XT_Lb4t5ONI/AAAAAAABT6M/vXST7HLpgPU4elBVqIQVuof9Ui4-5PUYwCLcBGAs/s800/food_cup_ra-men_close.png', 10 | name: 'カップラーメン', 11 | unitPrice: UnitPrice.valueOf(180) 12 | }, 13 | count: CartItemCount.valueOf(1), 14 | state: { buyNow: true } 15 | }), 16 | CartItem.valueOf({ 17 | item: { 18 | id: 'baked-cake-fish-tube', 19 | image: 'https://3.bp.blogspot.com/-0cxQy6x5pk0/WyH_0OKogUI/AAAAAAABMtI/ndUgGKr7P1sbNKKR1cFegNk90zTxyLv_gCLcBGAs/s800/food_yaki_chikuwa.png', 20 | name: '焼き竹輪', 21 | unitPrice: UnitPrice.valueOf(100) 22 | }, 23 | count: CartItemCount.valueOf(1), 24 | state: { buyNow: true } 25 | }), 26 | CartItem.valueOf({ 27 | item: { 28 | id: 'whisky', 29 | image: 'https://4.bp.blogspot.com/-0blALkNdKAg/W8BOQd0NHcI/AAAAAAABPXQ/T9Fdxh6R_DQSO6ncVIVBbYtBBf5rbWUZgCLcBGAs/s800/drink_whisky_irish.png', 30 | name: 'ウィスキー', 31 | unitPrice: UnitPrice.valueOf(5000) 32 | }, 33 | count: CartItemCount.valueOf(1), 34 | state: { buyNow: true } 35 | }), 36 | CartItem.valueOf({ 37 | item: { 38 | id: 'miso-soup', 39 | image: 'https://2.bp.blogspot.com/-bznPJlS0rUQ/Wm1ylMeQrzI/AAAAAAABJ6E/xMvem7AfbcUiUtpAVP92Iabml8a6r7ruQCLcBGAs/s800/food_misoshiru_asari.png', 40 | name: '味噌汁', 41 | unitPrice: UnitPrice.valueOf(300) 42 | }, 43 | count: CartItemCount.valueOf(1), 44 | state: { buyNow: true } 45 | }) 46 | ]) 47 | 48 | async list (): Promise { 49 | return this._list 50 | } 51 | 52 | async save (cartItem: CartItem): Promise { 53 | this._list.replace(cartItem) 54 | } 55 | 56 | async delete (cartItem: CartItem): Promise { 57 | this._list.remove(cartItem) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/views/cart/view/Cart.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 100 | 101 | 127 | -------------------------------------------------------------------------------- /src/views/cart/view/CartByCompositionApi.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 94 | 95 | 121 | -------------------------------------------------------------------------------- /src/views/cart/view/CartItem.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 116 | 117 | 142 | -------------------------------------------------------------------------------- /src/views/error/ErrorJob.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 114 | -------------------------------------------------------------------------------- /src/views/error/job-error.ts: -------------------------------------------------------------------------------- 1 | export class JobError { 2 | constructor (private readonly _message: string) { 3 | // 4 | } 5 | 6 | get message (): string { 7 | return this._message 8 | } 9 | 10 | equals (other: JobError): boolean { 11 | return this.message === other.message 12 | } 13 | } 14 | 15 | export const jobNotEnoughParameterError = new JobError('処理に必要な情報が不足しています') 16 | export const jobForbiddenError = new JobError('処理は許可されていません') 17 | export const jobConflictedError = new JobError('処理がコンフリクトしました') 18 | export const jobNotFoundError = new JobError('指定された処理が見つかりませんでした') 19 | export const jobTimeoutError = new JobError('処理がタイムアウトしました') 20 | -------------------------------------------------------------------------------- /src/views/error/notification.ts: -------------------------------------------------------------------------------- 1 | import { JobError } from '@/views/error/job-error' 2 | 3 | export class Notification { 4 | private _errors: JobError[] = [] 5 | 6 | get errors (): JobError[] { 7 | return this._errors 8 | } 9 | 10 | hasError (error: JobError): boolean { 11 | return this._errors.find(e => e.equals(error)) !== null 12 | } 13 | 14 | addError (error: JobError): void { 15 | this._errors = [ 16 | ...this._errors, 17 | error 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/views/todo/model/repository.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from './todo' 2 | 3 | export interface TodoRepository { 4 | list (): Promise 5 | 6 | save (todo: Todo): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/views/todo/model/repository/from-api.ts: -------------------------------------------------------------------------------- 1 | import { TodoRepository } from '../repository' 2 | import { Todo } from '@/views/todo/model/todo' 3 | 4 | interface TodoResponse { 5 | id: number 6 | content: string 7 | limit_at: string 8 | completed: boolean 9 | } 10 | 11 | export class FromApiTodoRepository implements TodoRepository { 12 | constructor ( 13 | private readonly baseUrl: string = 'http://localhost:8090/todos' 14 | ) {} 15 | 16 | async list (): Promise { 17 | const response = await fetch(this.baseUrl) 18 | const responseTodo: TodoResponse[] = await response.json() 19 | 20 | return responseTodo.map(todo => Todo.valueOf({ 21 | id: todo.id, 22 | content: todo.content, 23 | limit: todo.limit_at, 24 | completed: todo.completed 25 | })) 26 | } 27 | 28 | async save (todo: Todo): Promise { 29 | await fetch(`${this.baseUrl}/${todo.id}`, { 30 | method: 'PUT', 31 | headers: { 32 | 'Content-Type': 'application/json' 33 | }, 34 | body: JSON.stringify({ 35 | id: todo.id, 36 | content: todo.content, 37 | limit_at: todo.limit, 38 | completed: todo.completed 39 | }) 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/views/todo/model/repository/on-memory.ts: -------------------------------------------------------------------------------- 1 | import { TodoRepository } from '../repository' 2 | import { Todo } from '@/views/todo/model/todo' 3 | 4 | export class OnMemoryTodoRepository implements TodoRepository { 5 | private _list: Todo[] = [ 6 | Todo.valueOf({ 7 | id: 1, 8 | content: 'まだ当分先のタスク', 9 | limit: '2019-11-20', 10 | completed: false 11 | }), 12 | Todo.valueOf({ 13 | id: 2, 14 | content: '期限間近のタスク', 15 | limit: '2019-10-18', 16 | completed: false 17 | }), 18 | Todo.valueOf({ 19 | id: 3, 20 | content: '期限切れのタスク', 21 | limit: '2019-10-16', 22 | completed: false 23 | }) 24 | ] 25 | 26 | async list (): Promise { 27 | return this._list 28 | } 29 | 30 | async save (todo: Todo): Promise { 31 | this._list.map(stored => stored.id === todo.id ? todo : stored) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/views/todo/model/select-task-state.ts: -------------------------------------------------------------------------------- 1 | import TaskState, { CloseToLimitState, LimitOverState, NormalState } from '@/views/todo/model/task-state' 2 | import { Todo } from '@/views/todo/model/todo' 3 | 4 | export const selectTaskState = (task: Todo, baseDate: Date): TaskState => { 5 | const limit = new Date(task.limit) 6 | 7 | const diff = limit.getTime() - baseDate.getTime() 8 | const oneDay = 1000 * 60 * 60 * 24 9 | 10 | if (diff > oneDay * 3) { 11 | return NormalState.create() 12 | } 13 | if (diff >= 0) { 14 | return CloseToLimitState.create() 15 | } 16 | 17 | return LimitOverState.create() 18 | } 19 | -------------------------------------------------------------------------------- /src/views/todo/model/task-state-class.js: -------------------------------------------------------------------------------- 1 | 2 | export class NormalState { 3 | static create () { 4 | return new NormalState() 5 | } 6 | 7 | get style () { 8 | return { 9 | 'border-color': 'green' 10 | } 11 | } 12 | 13 | get notification () { 14 | return '[class]' 15 | } 16 | } 17 | export class CloseToLimitState { 18 | static create () { 19 | return new CloseToLimitState() 20 | } 21 | 22 | get style () { 23 | return { 24 | 'border-color': 'yellow', 25 | 'background-color': 'yellow' 26 | } 27 | } 28 | 29 | get notification () { 30 | return '※期限が迫っています[class]' 31 | } 32 | } 33 | export class LimitOverState { 34 | static create () { 35 | return new LimitOverState() 36 | } 37 | 38 | get style () { 39 | return { 40 | 'border-color': 'red', 41 | 'background-color': 'red', 42 | color: 'white', 43 | 'font-weight': 'bold' 44 | } 45 | } 46 | 47 | get notification () { 48 | return '※期限が過ぎています[class]' 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/views/todo/model/task-state-legacy.js: -------------------------------------------------------------------------------- 1 | 2 | export const NormalState = (function () { 3 | const constructor = function NormalStateConstructor () { } 4 | 5 | constructor.create = function () { 6 | return new constructor() 7 | } 8 | 9 | constructor.prototype.style = { 10 | 'border-color': 'green' 11 | } 12 | constructor.prototype.notification = '[legacy]' 13 | 14 | return constructor 15 | })() 16 | export const CloseToLimitState = (function () { 17 | const constructor = function CloseToLimitStateConstructor () { } 18 | 19 | constructor.create = function () { 20 | return new constructor() 21 | } 22 | constructor.prototype.style = { 23 | 'border-color': 'yellow', 24 | 'background-color': 'yellow' 25 | } 26 | constructor.prototype.notification = '※期限が迫っています[legacy]' 27 | 28 | return constructor 29 | })() 30 | export const LimitOverState = (function () { 31 | const constructor = function LimitOverStateConstructor () { } 32 | 33 | constructor.create = function () { 34 | return new constructor() 35 | } 36 | constructor.prototype.style = { 37 | 'border-color': 'red', 38 | 'background-color': 'red', 39 | color: 'white', 40 | 'font-weight': 'bold' 41 | } 42 | constructor.prototype.notification = '※期限が過ぎています[legacy]' 43 | 44 | return constructor 45 | })() 46 | -------------------------------------------------------------------------------- /src/views/todo/model/task-state-plane.js: -------------------------------------------------------------------------------- 1 | 2 | export const NormalState = { 3 | create () { 4 | return { 5 | style: { 6 | 'border-color': 'green' 7 | }, 8 | notification: '[plane]' 9 | } 10 | } 11 | } 12 | export const CloseToLimitState = { 13 | create () { 14 | return { 15 | style: { 16 | 'border-color': 'yellow', 17 | 'background-color': 'yellow' 18 | }, 19 | notification: '※期限が迫っています[plane]' 20 | } 21 | } 22 | } 23 | export const LimitOverState = { 24 | create () { 25 | return { 26 | style: { 27 | 'border-color': 'red', 28 | 'background-color': 'red', 29 | color: 'white', 30 | 'font-weight': 'bold' 31 | }, 32 | notification: '※期限が過ぎています[plane]' 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/views/todo/model/task-state.ts: -------------------------------------------------------------------------------- 1 | 2 | export default interface TaskState { 3 | readonly style: object 4 | readonly notification: string 5 | } 6 | 7 | export class NormalState implements TaskState { 8 | static create (): TaskState { 9 | return new NormalState() 10 | } 11 | 12 | get style (): object { 13 | return { 14 | 'border-color': 'green' 15 | } 16 | } 17 | 18 | get notification (): string { 19 | return '' 20 | } 21 | } 22 | export class CloseToLimitState implements TaskState { 23 | static create (): TaskState { 24 | return new CloseToLimitState() 25 | } 26 | 27 | get style (): object { 28 | return { 29 | 'border-color': 'yellow', 30 | 'background-color': 'yellow' 31 | } 32 | } 33 | 34 | get notification (): string { 35 | return '※期限が迫っています' 36 | } 37 | } 38 | export class LimitOverState implements TaskState { 39 | static create (): TaskState { 40 | return new LimitOverState() 41 | } 42 | 43 | get style (): object { 44 | return { 45 | 'border-color': 'red', 46 | 'background-color': 'red', 47 | color: 'white', 48 | 'font-weight': 'bold' 49 | } 50 | } 51 | 52 | get notification (): string { 53 | return '※期限が過ぎています' 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/views/todo/model/todo.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Todo { 3 | static valueOf (input: { 4 | id: number, 5 | content: string, 6 | limit: string, 7 | completed: boolean 8 | }): Todo { 9 | return new Todo( 10 | input.id, input.content, input.limit, input.completed 11 | ) 12 | } 13 | 14 | private constructor ( 15 | readonly id: number, 16 | readonly content: string, 17 | readonly limit: string, 18 | readonly completed: boolean 19 | ) {} 20 | 21 | changeLimit (limit: string): Todo { 22 | return Todo.valueOf({ 23 | ...this, 24 | limit 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/views/todo/view/TodoList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 55 | -------------------------------------------------------------------------------- /src/views/todo/view/TodoTask.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 58 | 59 | 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "spec/**/*.ts", 35 | "spec/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | --------------------------------------------------------------------------------