├── .babelrc ├── .data ├── .gitignore └── db.example.json ├── .dev └── nginx │ └── frontend.vhost.conf ├── .docker └── rootfs │ └── entrypoint.sh ├── .dockerignore ├── .editorconfig ├── .env ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── Dockerfile ├── Makefile ├── README.md ├── assets ├── i18n │ ├── en.json │ ├── ru.json │ └── uk.json └── img │ ├── default_user_photo.jpg │ ├── loading_box.gif │ └── logo.png ├── docker-compose.yml ├── nodemon.json ├── package.json ├── server.js ├── src ├── HMRApi.ts ├── I18n.ts ├── Router.ts ├── Validation.ts ├── api │ ├── Users.ts │ ├── index.ts │ └── users.ts ├── class-component │ ├── decorators.ts │ └── hooks.ts ├── components │ ├── app │ │ ├── App.ts │ │ ├── App.vue │ │ └── app.styl │ ├── error-block │ │ ├── ErrorBlock.ts │ │ └── ErrorBlock.vue │ ├── language-switcher │ │ ├── LanguageSwitcher.ts │ │ ├── LanguageSwitcher.vue │ │ └── language-switcher.styl │ ├── loading-spinner │ │ ├── LoadingSpinner.ts │ │ └── LoadingSpinner.vue │ ├── navbar │ │ ├── Navbar.ts │ │ ├── Navbar.vue │ │ └── navbar.styl │ └── pages │ │ ├── profile │ │ ├── Profile.ts │ │ └── Profile.vue │ │ ├── sign-in │ │ ├── SignIn.ts │ │ └── SignIn.vue │ │ └── sign-up │ │ ├── SignUp.ts │ │ └── SignUp.vue ├── entry │ ├── client.ts │ └── server.ts ├── main.ts ├── store │ ├── index.ts │ ├── modules │ │ └── user │ │ │ ├── actions.ts │ │ │ ├── getters.ts │ │ │ ├── index.ts │ │ │ ├── mutations.ts │ │ │ └── state.ts │ └── root │ │ ├── actions.ts │ │ ├── getters.ts │ │ ├── mutations.ts │ │ └── state.ts ├── templates │ ├── index.html │ └── index.server.html └── themes │ └── default │ └── main.styl ├── test ├── docker │ └── suite.bats ├── e2e │ ├── .gitignore │ ├── Helper.ts │ ├── Sizes.ts │ ├── nightwatch.conf.js │ ├── pages │ │ ├── login.ts │ │ ├── profile.ts │ │ └── sign_up.ts │ ├── specs │ │ └── components │ │ │ ├── App.ts │ │ │ ├── ErrorBlock.ts │ │ │ ├── LanguageSwitcher.ts │ │ │ ├── LoadingSpinner.ts │ │ │ ├── Navbar.ts │ │ │ └── pages │ │ │ ├── login.ts │ │ │ └── sign_up.ts │ └── tsconfig.json └── unit │ ├── Helper.ts │ ├── index.ts │ ├── karma.conf.js │ └── specs │ ├── I18n.spec.ts │ ├── Router.spec.ts │ └── components │ ├── LanguageSwitcher.spec.ts │ └── pages │ ├── Profile.spec.ts │ ├── SignIn.spec.ts │ └── SignUp.spec.ts ├── tsconfig.json ├── tslint.json ├── types ├── nightwatch.d.ts ├── node-process.d.ts ├── system-import.d.ts ├── vue-file-shims.d.ts └── vue.d.ts ├── webpack ├── base.config.js ├── client.config.js ├── docs.config.js ├── records.json ├── server.config.js └── test.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.data/.gitignore: -------------------------------------------------------------------------------- 1 | db.json 2 | -------------------------------------------------------------------------------- /.data/db.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | { 4 | "email": "test@gmail.com", 5 | "id": "hwX6aOr7", 6 | "name": "Test User", 7 | "password": "123123" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.dev/nginx/frontend.vhost.conf: -------------------------------------------------------------------------------- 1 | # Declare HTTP scheme variable that contains correct value 2 | # both for direct and through reverse-proxy (with SSL termination) requests. 3 | map $http_x_forwarded_proto $proto { 4 | default $scheme; 5 | https https; 6 | http http; 7 | } 8 | # Declares variable that contains requested hostname without `www.` part. 9 | map $host $host_without_www { 10 | default $host; 11 | "~*^www\.(?.+)$" $h; 12 | } 13 | 14 | # Permanent redirection from 'www' to 'non-www'. 15 | server { 16 | listen 80; 17 | server_name www.*; 18 | return 301 $proto://$host_without_www$request_uri; 19 | } 20 | 21 | # Default server to serve frontend application. 22 | server { 23 | listen 80 default_server; 24 | server_name _; 25 | 26 | root /var/www/public; 27 | index index.html; 28 | charset utf-8; 29 | 30 | # Custom error pages. 31 | error_page 403 /403; 32 | error_page 404 /404; 33 | error_page 500 /500; 34 | 35 | 36 | location / { 37 | # Handle search engines to use SSR. 38 | if ($http_user_agent ~* "bot|crawl|slurp|spider") { 39 | proxy_pass http://node-frontend:8080; 40 | } 41 | 42 | try_files $uri $uri/ /index.html; 43 | } 44 | 45 | location /hot { 46 | try_files try_files $uri =404; 47 | } 48 | 49 | location = /index.html {} 50 | 51 | 52 | # Disable unnecessary access logs. 53 | location = /favicon.ico { 54 | access_log off; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.docker/rootfs/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | # Share application files if required. 5 | appDir=/app 6 | if [ "$SHARE_APP" == "1" ]; then 7 | mkdir -p /shared 8 | cp -rf /app/* /shared/ 9 | chown -R node:node /shared/* 10 | appDir=/shared 11 | fi 12 | 13 | 14 | # Run CMD as PID 1 under 'node' user. 15 | cmd="cd $appDir && exec $@" 16 | exec su -c "$cmd" node 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !.docker 3 | !_dist 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | max_line_length = 80 9 | 10 | [*.vue] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.{js,ts}] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [*.md] 19 | indent_style = space 20 | indent_size = 4 21 | trim_trailing_whitespace = false 22 | 23 | [*.{yaml,yml}] 24 | indent_style = space 25 | indent_size = 2 26 | 27 | [Dockerfile] 28 | indent_style = space 29 | indent_size = 4 30 | 31 | [Makefile] 32 | indent_style = tab 33 | indent_size = 4 34 | 35 | [*.json] 36 | indent_style = space 37 | indent_size = 2 38 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=vue-app-example 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /test/e2e/_build 2 | 3 | /_cache 4 | /_dist 5 | /_docs 6 | 7 | /public 8 | 9 | /node_modules 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "google", 3 | "env": { 4 | "es6": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | 4 | /_cache 5 | /_dist 6 | /_docs 7 | 8 | /public 9 | /index.server.html 10 | /vue-ssr-bundle.json 11 | /webpack.records.json 12 | 13 | /node_modules 14 | 15 | /selenium-debug.log 16 | /yarn-error.log 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: bash 4 | 5 | dist: trusty 6 | 7 | services: 8 | - docker 9 | 10 | before_script: 11 | - make clean 12 | - make deps dev=yes 13 | - make build dev=no 14 | - sudo rm -rf node_modules/ 15 | - make deps.yarn dev=no 16 | - make dist 17 | - make docker.image no-cache=yes VERSION=test 18 | - sudo rm -rf node_modules/ 19 | - make deps.yarn dev=yes 20 | 21 | script: 22 | - make lint 23 | - make test start-app=yes VERSION=test 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | 3 | 4 | COPY .docker/rootfs / 5 | 6 | COPY _dist/ /app/ 7 | 8 | RUN chmod +x /entrypoint.sh \ 9 | && chown -R node:node /app 10 | 11 | ENV SHARE_APP=0 12 | 13 | 14 | WORKDIR /app 15 | 16 | EXPOSE 8080 17 | 18 | ENTRYPOINT ["/entrypoint.sh"] 19 | 20 | CMD ["node", "./server.js"] 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | VERSION ?= 1.0.0 3 | 4 | IMAGE_NAME := instrumentisto/vue-app-example 5 | 6 | NODE_VERSION := latest 7 | NODE_ALPINE_VERSION := alpine 8 | SELENIUM_CHROME_VERSION := latest 9 | SELENIUM_FIREFOX_VERSION := 3.4.0 10 | 11 | DIST_DIR := _dist 12 | 13 | MAINLINE_BRANCH := dev 14 | CURRENT_BRANCH := $(shell git branch | grep \* | cut -d ' ' -f2) 15 | 16 | 17 | cmd ?= !default 18 | dev ?= yes 19 | no-cache ?= no 20 | 21 | 22 | comma := , 23 | empty := 24 | space := $(empty) $(empty) 25 | eq = $(if $(or $(1),$(2)),$(and $(findstring $(1),$(2)),\ 26 | $(findstring $(2),$(1))),1) 27 | 28 | 29 | 30 | # Build project from sources. 31 | # 32 | # Usage: 33 | # make build [dev=(yes|no)] [target=(all|client|server)] 34 | 35 | env ?= $(if $(call eq,$(dev),yes),development,production) 36 | target ?= all 37 | 38 | build: 39 | docker run --rm -v $(PWD):/app -w /app -e NODE_ENV=$(env) \ 40 | node:$(NODE_ALPINE_VERSION) \ 41 | yarn build:$(target) 42 | 43 | 44 | 45 | # Resolve all project dependencies. 46 | # 47 | # Usage: 48 | # make deps [dev=(yes|no)] 49 | 50 | deps: deps.yarn deps.docker 51 | ifeq ($(wildcard $(DIST_DIR)),) 52 | mkdir -p $(DIST_DIR) 53 | endif 54 | 55 | 56 | 57 | # Resolve project dependencies with Yarn. 58 | # 59 | # Optional 'cmd' parameter may be used for handy usage of docker-wrapped Yarn, 60 | # for example: `make deps.yarn cmd="update"`. 61 | # 62 | # Usage: 63 | # make deps.yarn [cmd=] [dev=(yes|no)] 64 | 65 | yarn-args = $(if $(call eq,$(cmd),!default),install,$(cmd)) 66 | yarn-args-full = $(yarn-args) \ 67 | $(if $(and $(call eq,$(dev),no),\ 68 | $(call eq,$(yarn-args),install)),\ 69 | --production,) 70 | 71 | deps.yarn: 72 | docker run --rm -v $(PWD):/app -w /app \ 73 | -e YARN_CACHE_FOLDER=/app/_cache/yarn \ 74 | node:$(NODE_ALPINE_VERSION) \ 75 | yarn $(yarn-args-full) 76 | 77 | 78 | 79 | # Resolve project Docker dependencies. 80 | # 81 | # Usage: 82 | # make deps.docker 83 | 84 | deps.docker: 85 | docker pull node:$(NODE_VERSION) 86 | docker pull node:$(NODE_ALPINE_VERSION) 87 | docker pull selenium/standalone-chrome:$(SELENIUM_CHROME_VERSION) 88 | docker pull selenium/standalone-firefox:$(SELENIUM_FIREFOX_VERSION) 89 | 90 | 91 | 92 | # Lint project TypeScript sources with TSLint. 93 | # 94 | # Usage: 95 | # make lint 96 | 97 | lint: 98 | docker run --rm -v $(PWD):/app -w /app \ 99 | node:$(NODE_ALPINE_VERSION) \ 100 | yarn lint 101 | 102 | 103 | 104 | # Run all project tests. 105 | # 106 | # Usage: 107 | # make test 108 | 109 | test: test.unit test.e2e test.docker 110 | 111 | 112 | 113 | # Run unit tests for project with Karma. 114 | # 115 | # Usage: 116 | # make test.unit [watch=(no|yes)] 117 | 118 | unit-watch-prefix = $(if $(call eq,$(watch),yes),watch:,) 119 | 120 | test.unit: 121 | docker run --rm -v $(PWD):/app -w /app node:$(NODE_VERSION) \ 122 | yarn $(unit-watch-prefix)test:unit 123 | 124 | 125 | 126 | # Run Nightwatch.js E2E tests for project. 127 | # 128 | # Usage: 129 | # make test.e2e [start-app=(no|yes)] 130 | # [browsers=chrome,firefox] 131 | # [watch=(no|yes)] 132 | 133 | start-app ?= no 134 | browsers ?= chrome,firefox 135 | selenium-chrome-port := 4444 136 | selenium-firefox-port := 4445 137 | e2e-watch-prefix = $(if $(call eq,$(watch),yes),watch:,) 138 | 139 | test.e2e: 140 | ifeq ($(start-app),yes) 141 | -@docker-compose down 142 | docker-compose up -d 143 | endif 144 | $(foreach browser,$(subst $(comma), ,$(browsers)), \ 145 | $(call checkSeleniumStarted,$(browser))) 146 | $(foreach browser,$(subst $(comma), ,$(browsers)), \ 147 | $(if $(call eq,$(run-selenium-$(browser)),yes), \ 148 | $(call startSelenium,$(browser)),)) 149 | docker run --rm --net=host -v $(PWD):/app -w /app \ 150 | -e NOT_START_SELENIUM=1 \ 151 | -e E2E_BROWSERS=$(browsers) \ 152 | node:$(NODE_ALPINE_VERSION) \ 153 | yarn $(e2e-watch-prefix)test:e2e 154 | $(foreach browser,$(subst $(comma), ,$(browsers)), \ 155 | $(if $(call eq,$(run-selenium-$(browser)),yes), \ 156 | $(call stopSelenium,$(browser)),)) 157 | ifeq ($(start-app),yes) 158 | docker-compose down 159 | endif 160 | define checkSeleniumStarted 161 | $(eval run-selenium-$(1) := \ 162 | $(if $(call eq,$(shell docker ps -q -f name=selenium-$(1)),),yes,)) 163 | endef 164 | define startSelenium 165 | $() 166 | -@docker stop selenium-$(1) 167 | -@docker rm selenium-$(1) 168 | docker run -d --name=selenium-$(1) -p $(selenium-$(1)-port):4444 \ 169 | --net=vue-app-example_default \ 170 | --link=vue-app-example-nginx:vue-app-example.localhost \ 171 | --link=vue-app-example-json-server:api.vue-app-example.localhost \ 172 | -e DBUS_SESSION_BUS_ADDRESS=/dev/null \ 173 | selenium/standalone-$(1):$(SELENIUM_$(shell echo $(1) | tr a-z A-Z)_VERSION) 174 | $(eval selenium-$(1)-started := yes) 175 | endef 176 | define stopSelenium 177 | $() 178 | docker stop selenium-$(1) 179 | docker rm selenium-$(1) 180 | endef 181 | 182 | 183 | 184 | # Run Bats tests for project Docker image. 185 | # 186 | # Usage: 187 | # make test.docker [VERSION=] 188 | 189 | test.docker: 190 | IMAGE=$(IMAGE_NAME):$(VERSION) node_modules/.bin/bats test/docker/suite.bats 191 | 192 | 193 | 194 | # Generate Typedoc documentation of project TypeScript sources. 195 | # 196 | # Documentation of Typedoc tools: 197 | # http://typedoc.org/guides/usage/ 198 | # 199 | # Usage: 200 | # make docs 201 | 202 | docs: 203 | docker run --rm -v $(PWD):/app -w /app node:$(NODE_ALPINE_VERSION) \ 204 | yarn docs 205 | 206 | 207 | 208 | # Build project Docker image. 209 | # 210 | # Usage: 211 | # make docker.image [no-cache=(yes|no)] [VERSION=] 212 | 213 | no-cache-arg = $(if $(call eq,$(no-cache),yes),--no-cache,) 214 | 215 | docker.image: 216 | docker build $(no-cache-arg) -t $(IMAGE_NAME):$(VERSION) . 217 | 218 | 219 | 220 | # Create distribution files of project. 221 | # 222 | # Usage: 223 | # make dist 224 | 225 | dist: 226 | mkdir -p $(DIST_DIR) 227 | rm -rf $(DIST_DIR)/* 228 | cp -r node_modules public index.server.html server.js vue-ssr-bundle.json \ 229 | $(DIST_DIR)/ 230 | 231 | 232 | 233 | # Squash changes of the current Git branch onto another Git branch. 234 | # 235 | # WARNING: You must merge `onto` branch in the current branch before squash! 236 | # 237 | # Usage: 238 | # make squash [onto=] [del=(no|yes)] 239 | 240 | onto ?= $(MAINLINE_BRANCH) 241 | del ?= no 242 | upstream ?= origin 243 | 244 | squash: 245 | ifeq ($(CURRENT_BRANCH),$(onto)) 246 | @echo "--> Current branch is '$(onto)' already" && false 247 | endif 248 | git checkout $(onto) 249 | git branch -m $(CURRENT_BRANCH) orig-$(CURRENT_BRANCH) 250 | git checkout -b $(CURRENT_BRANCH) 251 | git branch --set-upstream-to $(upstream)/$(CURRENT_BRANCH) 252 | git merge --squash orig-$(CURRENT_BRANCH) 253 | ifeq ($(del),yes) 254 | git branch -d orig-$(CURRENT_BRANCH) 255 | endif 256 | 257 | 258 | 259 | # Clean all project files that is not under version control. 260 | # 261 | # WARNING: This will remove all your local untracked and ignored files! 262 | # 263 | # Usage: 264 | # make clean 265 | 266 | clean: 267 | git clean -ffdx 268 | 269 | 270 | 271 | .PHONY: build lint docs \ 272 | deps deps.yarn deps.docker \ 273 | test test.unit test.e2e test.docker \ 274 | docker.image dist squash clean 275 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | vue-app-example 2 | ===================== 3 | 4 | [![Build Status](https://travis-ci.org/instrumentisto/vue-app-example.svg?branch=master)](https://travis-ci.org/instrumentisto/vue-app-example) 5 | 6 | PoC app to test [Vue.js](https://vuejs.org/) framework 7 | 8 | 9 | 10 | ## How to dev 11 | 12 | Use `docker-compose` to boot up [dockerized environment](docker-compose.yml) 13 | for development: 14 | ```bash 15 | docker-compose up 16 | 17 | # or with Yarn 18 | yarn start 19 | ``` 20 | 21 | To resolve all project dependencies use docker-wrapped command 22 | from [`Makefile`][1]. 23 | ```bash 24 | make deps 25 | 26 | # or with Yarn 27 | yarn install 28 | ``` 29 | 30 | To build both client and server bundles use docker-wrapped command 31 | from [`Makefile`][1]: 32 | ```bash 33 | make build 34 | 35 | # or specify concrete target 36 | make build target=client 37 | make build target=server 38 | 39 | # or produce production-ready bundles with minified assets 40 | make build env=production 41 | 42 | # or with Yarn 43 | yarn build:client 44 | yarn build:server 45 | yarn build:all 46 | yarn build:all --production 47 | ``` 48 | 49 | To lint TypeScript/JavaScript sources you should use docker-wrapped command 50 | from [`Makefile`][1]: 51 | ```bash 52 | make lint 53 | 54 | # or with Yarn 55 | yarn lint 56 | ``` 57 | 58 | To run tests or generate project documentation use docker-wrapped commands 59 | from [`Makefile`][1]: 60 | ```bash 61 | make test 62 | make docs 63 | 64 | # or concrete tests 65 | make test.unit 66 | make test.e2e start-app=yes 67 | make test.docker 68 | 69 | # or with Yarn 70 | yarn test:unit 71 | yarn test:e2e 72 | yarn test 73 | yarn docs 74 | ``` 75 | 76 | To make docker image (from project distribution) use docker-wrapped commands 77 | from [`Makefile`][1]: 78 | ```bash 79 | make dist && make docker.image 80 | ``` 81 | 82 | To open [http://vue-app-example.localhost](http://vue-app-example.localhost) 83 | application in browser add lines to your `hosts` file: 84 | ``` 85 | 127.0.0.1 vue-app-example.localhost 86 | 127.0.0.1 api.vue-app-example.localhost 87 | ``` 88 | 89 | To test Server-Side Rendering, you can emulate search bot request: 90 | ```bash 91 | curl --header "User-Agent: Googlebot" http://vue-app-example.localhost/ 92 | ``` 93 | or directly do request to Express server: 94 | ```bash 95 | curl http://vue-app-example.localhost:8080/ 96 | ``` 97 | 98 | #### Notable moments 99 | 100 | - If you change project files layout, make sure that project builds as expected 101 | via `make build` and project distribution is formed correctly via `make dist`. 102 | - All project dependencies should be declared with [Yarn][3]. In other words, 103 | using [NPM][4] or [Bower][5] is __not allowed__. 104 | - All project TypeScript and JavaScript sources must be written accordingly with 105 | [ECMAScript 2016 language specifications][6]. You can easily configure 106 | WebStorm to support it by following [this guide][7]. 107 | - If you don't use docker-wrapped commands, make sure that tools you use have 108 | the same version as in docker-wrapped commands. It's latest version, mainly. 109 | 110 | 111 | 112 | ### Useful Resources for Beginners 113 | 114 | - Core documentation: [Vue](https://vuejs.org/v2/guide/), 115 | [vue-router](https://router.vuejs.org/en/), 116 | [Vuex](https://vuex.vuejs.org/en/) 117 | - [Vue components style guide](https://pablohpsilva.github.io/vuejs-component-style-guide) 118 | - Vue + TS: [vue-class-component](https://github.com/vuejs/vue-class-component), 119 | [vue-property-decorator](https://github.com/kaorun343/vue-property-decorator), 120 | [vuex-class](https://github.com/ktsn/vuex-class/) 121 | - TypeScript modules: http://www.typescriptlang.org/docs/handbook/modules.html 122 | - Yarn: https://yarnpkg.com/en/docs/usage 123 | - Pug: https://pugjs.org/language/attributes.html 124 | - Chai BDD: http://chaijs.com/api/bdd/ 125 | - VeeValidate plugin: http://vee-validate.logaretm.com/ 126 | - vue-i18n plugin: https://kazupon.github.io/vue-i18n/en/ 127 | - All Vue plugins catalog: https://github.com/vuejs/awesome-vue 128 | - Vue dev tools Chrome extension: https://github.com/vuejs/vue-devtools 129 | 130 | ### TODO 131 | - ~~[Stylus](http://stylus-lang.com/)~~ ([#2](https://github.com/instrumentisto/vue-app-example/pull/2)) 132 | - ~~[Pug](https://pugjs.org/language/attributes.html)~~ ([#3](https://github.com/instrumentisto/vue-app-example/pull/3)) 133 | - ~~Improve SSR by new guidelines from https://ssr.vuejs.org/en/~~ ([#4](https://github.com/instrumentisto/vue-app-example/pull/4)) 134 | - ~~ESLint for `.js` files and improve TSLint settings~~ ([#5](https://github.com/instrumentisto/vue-app-example/pull/5)) 135 | - ~~Improve TypeScript typings~~ ([#6](https://github.com/instrumentisto/vue-app-example/pull/6)) 136 | - ~~Change npm to Yarn, remove Bower~~ ([#7](https://github.com/instrumentisto/vue-app-example/pull/7)) 137 | - ~~Docker + Makefile + Travis CI~~ ([#8](https://github.com/instrumentisto/vue-app-example/pull/8)) 138 | - ~~Improve E2E specs~~ ([#9](https://github.com/instrumentisto/vue-app-example/pull/9)) 139 | - ~~Fix eslint hanging with v4.0~~ ([#10](https://github.com/instrumentisto/vue-app-example/pull/10)) 140 | - ~~Test remove json-server from hosts~~ ([#11](https://github.com/instrumentisto/vue-app-example/pull/11)) 141 | 142 | ### Future Roadmap 143 | 144 | - GraphQL ([#12](https://github.com/instrumentisto/vue-app-example/pull/12)) 145 | - [vue-kindergarten](https://github.com/JiriChara/vue-kindergarten) 146 | - [av-ts](https://github.com/HerringtonDarkholme/av-ts): https://herringtondarkholme.github.io/2016/10/03/vue2-ts2/ 147 | - https://github.com/rowanwins/vue-dropzone 148 | - Keep an eye on Nuxt.js and HackerNews app 149 | - Mobile: 150 | - https://habrahabr.ru/post/327494/ 151 | - https://github.com/quasarframework/quasar 152 | - https://github.com/nolimits4web/Framework7 153 | - https://github.com/weexteam/weex-vue-framework 154 | 155 | ### Known Issues 156 | 157 | - Rolling back HMR updates (**temporarily postponed**) 158 | 159 | 160 | 161 | 162 | 163 | [1]: Makefile 164 | [2]: package.json 165 | [3]: https://yarnpkg.com/lang/en/ 166 | [4]: https://www.npmjs.com/ 167 | [5]: https://bower.io/ 168 | [6]: https://www.ecma-international.org/publications/standards/Ecma-262.htm 169 | [7]: https://medium.com/@brandonaaskov/enabling-es6-syntax-support-in-webstorm-48e22956ecfd 170 | -------------------------------------------------------------------------------- /assets/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "validation": { 3 | "messages": { 4 | "required": "Required", 5 | "email": "Wrong email format", 6 | "confirmed": "Passwords don't match", 7 | "ext": "Wrong file extension", 8 | "mimes": "Wrong file type", 9 | "min": "Must be at least {value} characters" 10 | }, 11 | "attributes": { 12 | "name": "Name", 13 | "email": "Email", 14 | "password": "Password", 15 | "password_confirm": "Confirm password" 16 | } 17 | }, 18 | "errors": { 19 | "access_denied": "Access denied", 20 | "email_already_taken": "This email is already taken, please choose another one", 21 | "common": "Something went wrong..." 22 | }, 23 | "sign_in": { 24 | "title": "Sign In", 25 | "login": "Log In", 26 | "do_not_have_account": "Don't have an account?", 27 | "forgot_password": "Forgot password?" 28 | }, 29 | "profile": { 30 | "title": "My Profile", 31 | "logout_label": "Logout" 32 | }, 33 | "general": { 34 | "total_registrations_label": "There are already {count} of us!", 35 | "title": "Webmasters Test App", 36 | "check_for_updates": "Check for updates" 37 | }, 38 | "sign_up": { 39 | "title": "Sign Up", 40 | "already_have_account": "Already have an account?", 41 | "sign_up_button": "Sign Up" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /assets/i18n/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "validation": { 3 | "messages": { 4 | "required": "Обязательное для заполнения", 5 | "email": "Не корректный email", 6 | "confirmed": "Пароли не совпадают", 7 | "ext": "Не верное расширение файла", 8 | "mimes": "Не верный тип файла", 9 | "min": "Должно быть как минимум {value} символов" 10 | }, 11 | "attributes": { 12 | "name": "Имя", 13 | "email": "Email", 14 | "password": "Пароль", 15 | "password_confirm": "Подтвердите пароль" 16 | } 17 | }, 18 | "errors": { 19 | "access_denied": "Не правильный логин/пароль", 20 | "email_already_taken": "Введенный email уже занят, пожалуйста выберите другой", 21 | "common": "Что-то пошло не так..." 22 | }, 23 | "sign_in": { 24 | "title": "Авторизация", 25 | "login": "Войти", 26 | "do_not_have_account": "Еще нет аккаунта?", 27 | "forgot_password": "Забыли пароль?" 28 | }, 29 | "profile": { 30 | "title": "Мой профиль", 31 | "logout_label": "Выход" 32 | }, 33 | "general": { 34 | "total_registrations_label": "Нас уже {count}!", 35 | "title": "Webmasters Test App", 36 | "check_for_updates": "Проверить обновления" 37 | }, 38 | "sign_up": { 39 | "title": "Регистрация", 40 | "already_have_account": "Уже есть аккаунт?", 41 | "sign_up_button": "Зарегистрироваться" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /assets/i18n/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "validation": { 3 | "messages": { 4 | "required": "Обов'язкове для заповнення", 5 | "email": "Не вірний формат email", 6 | "confirmed": "Паролі не співпадають", 7 | "ext": "Не вірне розширення файлу", 8 | "mimes": "Не вірний тип файлу", 9 | "min": "Має бути щонайменше {value} символів" 10 | }, 11 | "attributes": { 12 | "name": "Ім'я", 13 | "email": "Email", 14 | "password": "Пароль", 15 | "password_confirm": "Підтвердіть пароль" 16 | } 17 | }, 18 | "errors": { 19 | "access_denied": "Не вірний логін/пароль", 20 | "email_already_taken": "Цей email вже зайнято, будь ласка, оберіть інший", 21 | "common": "На жаль, щось пішло не так..." 22 | }, 23 | "sign_in": { 24 | "title": "Авторизація", 25 | "login": "Увійти", 26 | "do_not_have_account": "Ще немає аккаунту?", 27 | "forgot_password": "Забули пароль?" 28 | }, 29 | "profile": { 30 | "title": "Мій профіль", 31 | "logout_label": "Вихід" 32 | }, 33 | "general": { 34 | "total_registrations_label": "Нас вже {count}!", 35 | "title": "Webmasters Test App", 36 | "check_for_updates": "Перевірити оновлення" 37 | }, 38 | "sign_up": { 39 | "title": "Реєстрація", 40 | "already_have_account": "Вже є аккаунт?", 41 | "sign_up_button": "Зареєструватись" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /assets/img/default_user_photo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instrumentisto/vue-app-example/861fcbd4bd80a17c13dfcfca2387f28b921d8364/assets/img/default_user_photo.jpg -------------------------------------------------------------------------------- /assets/img/loading_box.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instrumentisto/vue-app-example/861fcbd4bd80a17c13dfcfca2387f28b921d8364/assets/img/loading_box.gif -------------------------------------------------------------------------------- /assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/instrumentisto/vue-app-example/861fcbd4bd80a17c13dfcfca2387f28b921d8364/assets/img/logo.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node-frontend: 5 | container_name: "${COMPOSE_PROJECT_NAME}-node-frontend" 6 | image: instrumentisto/vue-app-example:dev 7 | depends_on: 8 | - json-server 9 | build: . 10 | expose: 11 | - "8080" 12 | ports: 13 | - "8080:8080" # express 14 | volumes: 15 | - ./:/app 16 | nginx: 17 | container_name: "${COMPOSE_PROJECT_NAME}-nginx" 18 | image: nginx:stable-alpine 19 | depends_on: 20 | - node-frontend 21 | - json-server 22 | ports: 23 | - "80:80" # http 24 | volumes: 25 | - ./public:/var/www/public:ro 26 | - ./.dev/nginx/frontend.vhost.conf:/etc/nginx/conf.d/default.conf:ro 27 | json-server: 28 | container_name: "${COMPOSE_PROJECT_NAME}-json-server" 29 | image: node:alpine 30 | working_dir: /app 31 | command: /bin/sh -c "yarn json-server" 32 | expose: 33 | - "3000" 34 | ports: 35 | - "3000:3000" #json-server 36 | volumes: 37 | - ./:/app 38 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | "test", 5 | "webpack" 6 | ], 7 | "ext": "js ts json vue styl", 8 | "ignore": [ 9 | "test/e2e/_build" 10 | ], 11 | "runOnChangeOnly": true, 12 | "verbose": true 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-app-example", 3 | "scripts": { 4 | "build:all": "yarn run build:client && yarn run build:server", 5 | "build:client": "cross-env CLIENT_API_URL=http://api.vue-app-example.localhost:3000 webpack --config webpack/client.config.js", 6 | "build:server": "cross-env SERVER_API_URL=http://json-server:3000 webpack --config webpack/server.config.js", 7 | "docs": "webpack --config webpack/docs.config.js && rm _docs/*.js && rm -r _docs/img", 8 | "json-server": "cp .data/db.example.json .data/db.json && json-server --watch .data/db.json --delay 500", 9 | "lint": "eslint './**/*.js' && tslint '{src,test,types}/**/*.ts'", 10 | "start": "docker-compose up", 11 | "stop": "docker-compose down", 12 | "test:e2e": "tsc -p test/e2e && nightwatch --config test/e2e/nightwatch.conf.js --env ${E2E_BROWSERS:-chrome,firefox}", 13 | "test:e2e:chrome": "tsc -p test/e2e && nightwatch --config test/e2e/nightwatch.conf.js --env chrome", 14 | "test:e2e:firefox": "tsc -p test/e2e && nightwatch --config test/e2e/nightwatch.conf.js --env firefox", 15 | "test:unit": "karma start test/unit/karma.conf.js --single-run", 16 | "watch:test:e2e": "nodemon --exec 'yarn build:client && yarn test:e2e'", 17 | "watch:test:e2e:chrome": "nodemon --exec 'yarn build:client && yarn test:e2e:chrome'", 18 | "watch:test:e2e:firefox": "nodemon --exec 'yarn build:client && yarn test:e2e:firefox'", 19 | "watch:test:unit": "nodemon --exec 'yarn test:unit'" 20 | }, 21 | "dependencies": { 22 | "accept-language-parser": "^1.4.0", 23 | "axios": "^0.16.1", 24 | "death": "^1.1.0", 25 | "express": "^4.15.2", 26 | "node-localstorage": "^1.3.0", 27 | "serve-favicon": "^2.4.2", 28 | "vee-validate": "^2.0.0-beta.25", 29 | "vue": "^2.2.1", 30 | "vue-class-component": "5.0.1", 31 | "vue-i18n": "^6.0.0", 32 | "vue-meta": "^1.0.4", 33 | "vue-router": "^2.3.1", 34 | "vue-server-renderer": "^2.2.6" 35 | }, 36 | "devDependencies": { 37 | "@types/chai": "^3.5.0", 38 | "@types/handlebars": "4.0.33", 39 | "@types/mocha": "^2.2.40", 40 | "@types/node": "^7.0.13", 41 | "@types/vue-i18n": "^6.1.0", 42 | "@types/webpack-env": "^1.13.0", 43 | "babel-core": "^6.24.1", 44 | "babel-loader": "^7.0.0", 45 | "babel-polyfill": "^6.23.0", 46 | "babel-preset-env": "^1.4.0", 47 | "bats": "^0.4.2", 48 | "bootstrap": "^3.3.7", 49 | "chai": "^3.5.0", 50 | "chromedriver": "^2.29.0", 51 | "copy-webpack-plugin": "^4.0.1", 52 | "cross-env": "^4.0.0", 53 | "css-loader": "^0.28.4", 54 | "eslint": "^4.3.0", 55 | "eslint-config-google": "^0.7.1", 56 | "file-loader": "^0.11.1", 57 | "geckodriver": "^1.7.1", 58 | "html-webpack-plugin": "^2.28.0", 59 | "json-server": "^0.10.0", 60 | "karma": "^1.6.0", 61 | "karma-chai": "^0.1.0", 62 | "karma-mocha": "^1.3.0", 63 | "karma-phantomjs-launcher": "^1.0.4", 64 | "karma-spec-reporter": "0.0.31", 65 | "karma-webpack": "^2.0.3", 66 | "mocha": "^3.2.0", 67 | "nightwatch": "^0.9.16", 68 | "node-localstorage": "^1.3.0", 69 | "nodemon": "^1.11.0", 70 | "phantomjs-prebuilt": "^2.1.14", 71 | "pug": "^2.0.0-rc.2", 72 | "selenium-server": "3.4.0", 73 | "stylus": "^0.54.5", 74 | "stylus-loader": "^3.0.1", 75 | "ts-loader": "^2.0.1", 76 | "tslint": "^5.0.0", 77 | "typedoc": "^0.6.0", 78 | "typedoc-webpack-plugin": "^1.1.4", 79 | "typescript": "^2.2.1", 80 | "vue-hot-reload-api": "^2.0.11", 81 | "vue-loader": "^12.0.0", 82 | "vue-property-decorator": "^5.0.1", 83 | "vue-ssr-webpack-plugin": "^3.0.0", 84 | "vue-style-loader": "^3.0.1", 85 | "vue-template-compiler": "^2.2.1", 86 | "vuex": "^2.2.1", 87 | "vuex-class": "^0.2.0", 88 | "vuex-persistedstate": "^1.3.0", 89 | "webpack": "^2.2.0", 90 | "webpack-merge": "^4.1.0", 91 | "webpack-node-externals": "^1.6.0" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const favicon = require('serve-favicon'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const express = require('express'); 7 | const onDeath = require('death')({SIGHUP: true}); 8 | const acceptLanguageParser = require('accept-language-parser'); 9 | const resolve = (file) => path.resolve(__dirname, file); 10 | 11 | const renderer = require('vue-server-renderer') 12 | .createBundleRenderer(require('./vue-ssr-bundle.json'), { 13 | runInNewContext: false, 14 | template: fs.readFileSync( 15 | resolve('./index.server.html'), 'utf-8' 16 | ), 17 | }); 18 | 19 | const app = express(); 20 | 21 | app.use(favicon(resolve('./public/img/logo.png'))); 22 | 23 | app.use(express.static('./public', { 24 | index: false, 25 | })); 26 | 27 | app.get('*', (req, res) => { 28 | let acceptLanguages = []; 29 | for ( 30 | let lang of acceptLanguageParser.parse(req.headers['accept-language']) 31 | ) { 32 | acceptLanguages.push(lang.code); 33 | } 34 | res.setHeader('Content-Type', 'text/html'); 35 | 36 | const context = {url: req.url, accept_languages: acceptLanguages}; 37 | 38 | renderer.renderToString(context, (error, html) => { 39 | if (error) { 40 | switch (error.code) { 41 | case 404: 42 | res.status(404).end('404 | Page Not Found'); 43 | break; 44 | case 500: 45 | res.status(500).end('500 | Internal Server Error'); 46 | break; 47 | default: 48 | res.end('Unknown server error'); 49 | } 50 | console.error(`Error during render "${req.url}": ${error.message}`); 51 | return; 52 | } 53 | const {title, meta} = context.meta.inject(); 54 | html = html.replace('#{metaInfo}', title.text() + meta.text()); 55 | res.end(html); 56 | }); 57 | }); 58 | 59 | app.listen(8080); 60 | 61 | onDeath(function(signal) { 62 | console.log(`Received "${signal}" signal`); 63 | process.exit(); 64 | }); 65 | -------------------------------------------------------------------------------- /src/HMRApi.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable 2 | 3 | /** 4 | * Hot reload API for Vue components. 5 | * 6 | * Note, that it's available only in browser. 7 | * In server environment it will return null. 8 | * 9 | * More info: https://github.com/vuejs/vue-hot-reload-api 10 | */ 11 | export const api = (typeof window !== 'undefined') 12 | ? require('vue-hot-reload-api') 13 | : null; 14 | 15 | export default api; 16 | -------------------------------------------------------------------------------- /src/I18n.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from 'vee-validate'; 2 | import Vue from 'vue'; 3 | import VueI18n from 'vue-i18n'; 4 | 5 | import store from 'store'; 6 | 7 | 8 | /** 9 | * Describes internationalization logic of the application. 10 | * Uses vue-i18n plugin with external locale resources under the hood. 11 | */ 12 | export default class I18n { 13 | 14 | /** 15 | * Default application locale. 16 | * It's used as fallback locale when requested one is not available. 17 | */ 18 | public static readonly defaultLocale: string = 'en'; 19 | 20 | /** 21 | * Locales list, supported by application. 22 | */ 23 | public static readonly locales: string[] = ['en', 'ru', 'uk']; 24 | 25 | /** 26 | * Initialize vue-i18n plugin with given priority of locales. 27 | * We take each locale by priority, and trying to find it in supported 28 | * locales list. 29 | * 30 | * @param priority Priority array of locales. 31 | * 32 | * @return Resolved promise with initialized vue-i18n plugin instance. 33 | */ 34 | public static init(priority: string[]): Promise { 35 | priority.push(this.defaultLocale); 36 | 37 | let startLocale; 38 | for (const locale of priority) { 39 | if (this.locales.find((value) => value === locale)) { 40 | startLocale = locale; 41 | break; 42 | } 43 | } 44 | 45 | Vue.use(VueI18n); 46 | 47 | const messages = {}; 48 | for (const locale of this.locales) { 49 | messages[locale] = {}; 50 | } 51 | 52 | this.i18n = new VueI18n({ 53 | locale: startLocale, 54 | messages, 55 | }); 56 | 57 | return this.loadLocaleData(startLocale).then(() => { 58 | Validator.setLocale(startLocale); 59 | return this.i18n; 60 | }); 61 | } 62 | 63 | /** 64 | * Loads locale data and updates vue-i18n and vee-validate plugins 65 | * dictionaries with it. 66 | * 67 | * @param locale Locale, to load data for. 68 | * 69 | * @return Resolved promise with locale data. 70 | */ 71 | public static loadLocaleData(locale: string): Promise { 72 | store.state.loading = true; 73 | 74 | return System.import('~assets/i18n/' + locale + '.json') 75 | .then((data: any) => { 76 | this.i18n.setLocaleMessage(locale, data); 77 | 78 | const validationDictionary = {}; 79 | validationDictionary[locale] = { 80 | attributes: data.validation.attributes, 81 | messages: {}, 82 | }; 83 | for (const rule in data.validation.messages) { 84 | if (data.validation.messages.hasOwnProperty(rule)) { 85 | validationDictionary[locale].messages[rule] = 86 | (field, value) => { 87 | switch (rule) { 88 | case 'min': 89 | return data.validation.messages[rule] 90 | .replace('{value}', value); 91 | default: 92 | return data.validation.messages[rule]; 93 | } 94 | }; 95 | } 96 | } 97 | Validator.updateDictionary(validationDictionary); 98 | 99 | store.state.loading = false; 100 | 101 | return data; 102 | }); 103 | } 104 | 105 | /** 106 | * Instance of initialized vue-i18n plugin. 107 | */ 108 | private static i18n: VueI18n; 109 | } 110 | -------------------------------------------------------------------------------- /src/Router.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueMeta from 'vue-meta'; 3 | import VueRouter from 'vue-router'; 4 | import { RawLocation, Route, RouteConfig, RouterMode, RouterOptions } from 'vue-router/types/router'; // tslint:disable-line 5 | 6 | import store from 'store'; 7 | 8 | import SignIn from 'components/pages/sign-in/SignIn.vue'; 9 | import SignUp from 'components/pages/sign-up/SignUp.vue'; 10 | 11 | 12 | const Profile = () => System.import('components/pages/profile/Profile.vue'); 13 | 14 | /** 15 | * Describes vue-router configuration. 16 | * 17 | * More info: http://router.vuejs.org/en/ 18 | */ 19 | export default class Router implements RouterOptions { 20 | 21 | /** 22 | * List of all routes, supported by application. 23 | * Each router must implement RouteConfig interface, and has at least 24 | * "path" and "component" properties. 25 | */ 26 | public routes: RouteConfig[] = [ 27 | { path: '/', component: SignIn }, 28 | { path: '/login', component: SignIn }, 29 | { path: '/sign_up', component: SignUp }, 30 | { path: '/profile', component: Profile, meta: { requiresAuth: true } }, 31 | ]; 32 | 33 | /** 34 | * Vue-router operating mode. 35 | * Available values: 36 | * - hash 37 | * - history 38 | * - abstract 39 | * 40 | * More info: http://router.vuejs.org/en/api/options.html 41 | */ 42 | public mode: RouterMode = 'history'; 43 | 44 | /** 45 | * Vue-router initialized instance. 46 | */ 47 | private router: VueRouter; 48 | 49 | /** 50 | * Creates router instance with pre-configured class properties. 51 | */ 52 | public constructor() { 53 | Vue.use(VueRouter); 54 | Vue.use(VueMeta); 55 | 56 | this.router = new VueRouter(this); 57 | 58 | this.router.beforeEach(this.beforeEach); 59 | this.router.afterEach(this.afterEach); 60 | this.router.onReady(this.onReady); 61 | } 62 | 63 | /** 64 | * Returns vue-router instance. 65 | */ 66 | public get instance(): VueRouter { 67 | return this.router; 68 | } 69 | 70 | /** 71 | * Function, that will be executed before each page navigation. 72 | * 73 | * @param to Route, to which navigation is performed. 74 | * @param from Route, from which navigation is performed. 75 | * @param next Function, that MUST be called after performing all other 76 | * actions. 77 | */ 78 | private beforeEach( 79 | to: Route, 80 | from: Route, 81 | next: (to?: RawLocation | false | ((vm: Vue) => any) | void) => void, 82 | ) { 83 | store.state.loading = true; 84 | 85 | if (to.matched.some((record) => record.meta.requiresAuth)) { 86 | next(); 87 | if (!store.state.user.authorized) { 88 | next({ 89 | path: '/login', 90 | }); 91 | } else { 92 | next(); 93 | } 94 | } else { 95 | next(); 96 | } 97 | } 98 | 99 | /** 100 | * Function, that will be executed after each page navigation. 101 | * 102 | * @param to Route, to which navigation was performed. 103 | * @param from Route, from which navigation was performed. 104 | */ 105 | private afterEach(to: Route, from: Route) { 106 | store.state.loading = false; 107 | } 108 | 109 | /** 110 | * Function, that will be performed, after route initialization was 111 | * performed. 112 | */ 113 | private onReady() { 114 | store.state.loading = false; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Validation.ts: -------------------------------------------------------------------------------- 1 | import VeeValidate from 'vee-validate'; 2 | import Vue from 'vue'; 3 | 4 | 5 | /** 6 | * Describes vee-validate plugin configuration. 7 | */ 8 | export default class Validation { 9 | 10 | /** 11 | * Initialize vee-validate plugin with predefined configuration. 12 | */ 13 | public static init() { 14 | Vue.use(VeeValidate, this.config as any); 15 | } 16 | 17 | /** 18 | * Configuration options of vee-validate plugins. 19 | * 20 | * More info: http://vee-validate.logaretm.com/index.html#configuration 21 | */ 22 | private static readonly config = { 23 | errorBagName: 'validationErrors', 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/api/Users.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosResponse } from 'axios'; 2 | 3 | import API from 'api'; 4 | 5 | 6 | /** 7 | * Implementation of /users API endpoint. 8 | * 9 | * Note, that passwords are sent and received in plain format, 10 | * even in GET requests. It's because of default json-server configuration, that 11 | * don't support POST requests for filtering data. 12 | * It's very not secure, but it's OK for PoC app. 13 | */ 14 | export default class Users { 15 | 16 | /** 17 | * Fetches all users. 18 | * 19 | * @return AxiosPromise with all users array. 20 | */ 21 | public static getAll(): Promise { 22 | return API.get('/users').then((response: AxiosResponse) => { 23 | return response.data; 24 | }); 25 | } 26 | 27 | /** 28 | * Registers new user in the system. 29 | * It also checks if user with same email already exists. 30 | * 31 | * @param user User object with all info, required for registration. 32 | * 33 | * @return Resolved AxiosPromise with created user object on success, 34 | * or rejected Promise with "1" error code, if user with given 35 | * email already exists. 36 | */ 37 | public static register(user: any): Promise { 38 | return API.get('/users', { 39 | params: { 40 | email: user.email, 41 | }, 42 | }).then((response: AxiosResponse) => { 43 | const users = response.data; 44 | 45 | if (users.length > 0) { 46 | return Promise.reject(1); 47 | } 48 | 49 | return API.post('/users', user) 50 | .then((postResponse: AxiosResponse) => { 51 | return postResponse.data; 52 | }); 53 | }); 54 | } 55 | 56 | /** 57 | * Does login action, that checks if user with given email/password exists 58 | * in the system. 59 | * 60 | * @param email User email, that does login. 61 | * @param password User password, that does login. 62 | * 63 | * @return Resolved AxiosPromise with user object on success, 64 | * or rejected Promise with "1" error code, 65 | * if login was failed. 66 | */ 67 | public static login(email: string, password: string): Promise { 68 | return API.get('/users', { 69 | params: { 70 | email, 71 | password, 72 | }, 73 | }).then((response: AxiosResponse) => { 74 | const users = response.data; 75 | 76 | if (users.length === 0) { 77 | return Promise.reject(1); 78 | } 79 | 80 | return users[0]; 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios'; 2 | 3 | import store from 'store'; 4 | 5 | 6 | /** 7 | * Axios instance with pre-configured base API url, 8 | * that will make requests to the API server. 9 | */ 10 | export const instance: AxiosInstance = axios.create({ 11 | baseURL: process.env.API_URL, 12 | }); 13 | 14 | instance.interceptors.request.use((config) => { 15 | store.state.loading = true; 16 | return config; 17 | }, (error) => { 18 | return Promise.reject(error); 19 | }); 20 | 21 | instance.interceptors.response.use((response) => { 22 | store.state.loading = false; 23 | return response; 24 | }, (error) => { 25 | return Promise.reject(error); 26 | }); 27 | 28 | export default instance; 29 | -------------------------------------------------------------------------------- /src/api/users.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosResponse } from 'axios'; 2 | 3 | import API from 'api'; 4 | 5 | 6 | /** 7 | * Implementation of /users API endpoint. 8 | * 9 | * Note, that passwords are sent and received in plain format, 10 | * even in GET requests. It's because of default json-server configuration, that 11 | * don't support POST requests for filtering data. 12 | * It's very not secure, but it's OK for PoC app. 13 | */ 14 | export default class Users { 15 | 16 | /** 17 | * Fetches all users. 18 | * 19 | * @return AxiosPromise with all users array. 20 | */ 21 | public static getAll(): Promise { 22 | return API.get('/users').then((response: AxiosResponse) => { 23 | return response.data; 24 | }); 25 | } 26 | 27 | /** 28 | * Registers new user in the system. 29 | * It also checks if user with same email already exists. 30 | * 31 | * @param user User object with all info, required for registration. 32 | * 33 | * @return Resolved AxiosPromise with created user object on success, 34 | * or rejected Promise with "1" error code, if user with given 35 | * email already exists. 36 | */ 37 | public static register(user: any): Promise { 38 | return API.get('/users', { 39 | params: { 40 | email: user.email, 41 | }, 42 | }).then((response: AxiosResponse) => { 43 | const users = response.data; 44 | 45 | if (users.length > 0) { 46 | return Promise.reject(1); 47 | } 48 | 49 | return API.post('/users', user) 50 | .then((postResponse: AxiosResponse) => { 51 | return postResponse.data; 52 | }); 53 | }); 54 | } 55 | 56 | /** 57 | * Does login action, that checks if user with given email/password exists 58 | * in the system. 59 | * 60 | * @param email User email, that does login. 61 | * @param password User password, that does login. 62 | * 63 | * @return Resolved AxiosPromise with user object on success, 64 | * or rejected Promise with "1" error code, 65 | * if login was failed. 66 | */ 67 | public static login(email: string, password: string): Promise { 68 | return API.get('/users', { 69 | params: { 70 | email, 71 | password, 72 | }, 73 | }).then((response: AxiosResponse) => { 74 | const users = response.data; 75 | 76 | if (users.length === 0) { 77 | return Promise.reject(1); 78 | } 79 | 80 | return users[0]; 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/class-component/decorators.ts: -------------------------------------------------------------------------------- 1 | import Vue, { ComponentOptions, ComputedOptions } from 'vue'; 2 | import { createDecorator } from 'vue-class-component'; 3 | 4 | 5 | /** 6 | * Custom @NoCache decorator for Vue component properties. 7 | * It adds support of 'cache' parameter to the computed properties. 8 | * 9 | * More info about customer decorators: 10 | * https://github.com/vuejs/vue-hot-reload-api 11 | * 12 | * More info about caching computed properties: 13 | * https://vuejs.org/v2/guide/computed.html#Computed-Caching-vs-Methods 14 | */ 15 | export const NoCache = createDecorator(( 16 | options: ComponentOptions, 17 | key: string, 18 | ) => { 19 | if (!options.computed) { 20 | return; 21 | } 22 | (options.computed[key] as ComputedOptions).cache = false; 23 | }); 24 | -------------------------------------------------------------------------------- /src/class-component/hooks.ts: -------------------------------------------------------------------------------- 1 | import Component from 'vue-class-component'; 2 | 3 | 4 | /** 5 | * Custom hooks, that will be available in Vue components. 6 | */ 7 | export const supportedHooks: string[] = [ 8 | 'preFetch', 9 | 'metaInfo', 10 | ]; 11 | 12 | Component.registerHooks(supportedHooks); 13 | -------------------------------------------------------------------------------- /src/components/app/App.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | import { Store } from 'vuex'; 4 | import { Action, Getter, namespace } from 'vuex-class'; 5 | 6 | import { FETCH_ALL } from 'store/modules/user/actions'; 7 | import { TOTAL_COUNT } from 'store/modules/user/getters'; 8 | import RootState from 'store/root/state'; 9 | 10 | import Navbar from 'components/navbar/Navbar.vue'; 11 | 12 | 13 | const UserGetter = namespace('user', Getter); 14 | const UserAction = namespace('user', Action); 15 | 16 | /** 17 | * Describes base application component, that contains general properties 18 | * of all views and components. 19 | * 20 | * It also specifies base application template. 21 | */ 22 | @Component({ 23 | components: { 24 | Navbar, 25 | }, 26 | }) 27 | export default class App extends Vue { 28 | 29 | /** 30 | * Returns total count of users from global Vuex store. 31 | * 32 | * It uses root Vuex getter under the hood. 33 | */ 34 | @UserGetter(TOTAL_COUNT) 35 | public usersTotalCount: number; 36 | 37 | /** 38 | * Executes fetch all users action of the root Vuex store. 39 | */ 40 | @UserAction(FETCH_ALL) 41 | public fetchAllUsers: 42 | /** 43 | * Executes fetch all users action of the root Vuex store. 44 | * 45 | * @return Resolved promise with array of all users, that exist 46 | * in the system. 47 | */ 48 | () => Promise; 49 | 50 | /** 51 | * Vue component 'created' hook, that executes when component instance 52 | * was created. 53 | */ 54 | public created(): void { 55 | this.fetchAllUsers(); 56 | } 57 | 58 | /** 59 | * Vue component 'preFetch' hook, that is used in SSR to do required 60 | * things with given Vuex store before page rendering. 61 | * 62 | * @param store Vuex store, to perform actions on. 63 | * 64 | * @return Resolved promise with array of all users, that exist 65 | * in the system. 66 | */ 67 | public preFetch(store: Store): Promise { 68 | return store.dispatch('user/' + FETCH_ALL); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/app/App.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/app/app.styl: -------------------------------------------------------------------------------- 1 | @require '~themes/default/main' 2 | 3 | main 4 | padding-top: 51px 5 | padding-bottom: 20px 6 | > section 7 | width: 400px 8 | margin: 0 auto 9 | text-align: center 10 | 11 | footer 12 | position: absolute 13 | bottom: 0 14 | height: 52px 15 | width: 100% 16 | padding: 10px 17 | border-top: 1px solid #e7e7e7 18 | background-color: #f8f8f8 19 | line-height: 32px 20 | 21 | .fade-enter-active, 22 | .fade-leave-active 23 | transition: opacity .5s 24 | 25 | .fade-enter, 26 | .fade-leave-to 27 | opacity: 0 28 | -------------------------------------------------------------------------------- /src/components/error-block/ErrorBlock.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | 4 | 5 | /** 6 | * Describes error block component with error message, that visible 7 | * when error message is not empty. 8 | */ 9 | @Component({ 10 | props: { 11 | error: '', 12 | }, 13 | }) 14 | export default class ErrorBlock extends Vue { 15 | // tslint:disable-line 16 | } 17 | -------------------------------------------------------------------------------- /src/components/error-block/ErrorBlock.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/language-switcher/LanguageSwitcher.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | import { Mutation } from 'vuex-class'; 4 | 5 | import I18n from 'I18n'; 6 | import { SET_LOCALE } from 'store/root/mutations'; 7 | 8 | 9 | /** 10 | * Describes component, that displays language switcher bar. 11 | * Properties: 12 | * - containerClass: CSS class(es), that will be added to the 13 | * switcher container. 14 | */ 15 | @Component({ 16 | props: { 17 | containerClass: '', 18 | }, 19 | }) 20 | export default class LanguageSwitcher extends Vue { 21 | 22 | /** 23 | * Commits chancing locale mutation of the root Vuex store. 24 | */ 25 | @Mutation(SET_LOCALE) 26 | public setAppLocale: 27 | /** 28 | * @param locale Locale key, that application state will be set for. 29 | */ 30 | (locale: string) => void; 31 | 32 | /** 33 | * Returns list of locales, supported by application. 34 | * 35 | * @return Array of locales, supported by application. 36 | */ 37 | public get locales(): string[] { 38 | return I18n.locales; 39 | } 40 | 41 | /** 42 | * Calculates, if given locale is equals to the current 43 | * application locale. 44 | * 45 | * @param locale Locale, that will be used in check condition. 46 | * 47 | * @return Flag, that indicates if given locale is equals to the 48 | * current application locale. 49 | */ 50 | public isActive(locale: string): boolean { 51 | return (locale === this.$i18n.locale); 52 | } 53 | 54 | /** 55 | * Fires changing application locale by loading given locale dictionary 56 | * and updating all locale dependent components configuration. 57 | * 58 | * @param locale Locale, which application locale will be changed to. 59 | */ 60 | public change(locale: string): void { 61 | if (this.$i18n.locale === locale) { 62 | return; 63 | } 64 | 65 | I18n.loadLocaleData(locale).then(() => { 66 | this.$i18n.locale = locale; 67 | this.$validator.setLocale(locale); 68 | this.setAppLocale(locale); 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/language-switcher/LanguageSwitcher.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/language-switcher/language-switcher.styl: -------------------------------------------------------------------------------- 1 | ul 2 | > li 3 | > a 4 | text-transform: uppercase 5 | -------------------------------------------------------------------------------- /src/components/loading-spinner/LoadingSpinner.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | import { Getter } from 'vuex-class'; 4 | 5 | import { LOADING } from 'store/root/getters'; 6 | 7 | 8 | /** 9 | * Describes component, that shows loading indicator, when global 10 | * application loading state is activated. 11 | */ 12 | @Component 13 | export default class LoadingSpinner extends Vue { 14 | 15 | /** 16 | * Returns application loading state. 17 | * 18 | * It uses root Vuex getter under the hood. 19 | */ 20 | @Getter(LOADING) 21 | public isLoading: boolean; 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/components/loading-spinner/LoadingSpinner.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/navbar/Navbar.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | 4 | import HMRApi from 'HMRApi'; 5 | 6 | import LanguageSwitcher from 'components/language-switcher/LanguageSwitcher.vue'; // tslint:disable-line 7 | import LoadingSpinner from 'components/loading-spinner/LoadingSpinner.vue'; 8 | 9 | 10 | /** 11 | * Describes Bootstrap navigation bar. 12 | */ 13 | @Component({ 14 | components: { 15 | LanguageSwitcher, 16 | LoadingSpinner, 17 | }, 18 | }) 19 | export default class Navbar extends Vue { 20 | 21 | /** 22 | * Checks for HMR updates and automatically applies them. 23 | */ 24 | public checkHotUpdates(): void { 25 | try { 26 | module.hot.check(true, (err, updatedModules) => { 27 | // console.log('check err', err); 28 | // console.log('check modules', updatedModules); 29 | }); 30 | } catch (e) { 31 | // console.log('check catch', e.toString()); 32 | } 33 | } 34 | } 35 | 36 | if (HMRApi && module.hot) { 37 | HMRApi.install(Vue); 38 | if (!module.hot.data) { 39 | HMRApi.createRecord('Header', (Navbar as any).options); 40 | } else { 41 | HMRApi.reload('Header', (Navbar as any).options); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/navbar/Navbar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/navbar/navbar.styl: -------------------------------------------------------------------------------- 1 | .navbar-brand 2 | padding: 5px 3 | img 4 | height: 40px 5 | display: inline-block 6 | -------------------------------------------------------------------------------- /src/components/pages/profile/Profile.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | import { Action, Getter, namespace } from 'vuex-class'; 4 | 5 | import HMRApi from 'HMRApi'; 6 | import { RESET_AUTHORIZATION } from 'store/modules/user/actions'; 7 | import { AUTHORIZED } from 'store/modules/user/getters'; 8 | 9 | 10 | const UserGetter = namespace('user', Getter); 11 | const UserAction = namespace('user', Action); 12 | 13 | /** 14 | * Describes user profile page. 15 | */ 16 | @Component 17 | export default class Profile extends Vue { 18 | 19 | /** 20 | * Returns global application authorization state. 21 | * 22 | * It uses root Vuex getter under the hood. 23 | */ 24 | @UserGetter(AUTHORIZED) 25 | public user: any; 26 | 27 | /** 28 | * Resets global application authorization state. 29 | * 30 | * It executes root Vuex action under the hood. 31 | */ 32 | @UserAction(RESET_AUTHORIZATION) 33 | public resetAuthorization: () => void; 34 | 35 | /** 36 | * Returns meta information of page, such as: 37 | * title, meta tags content etc. 38 | * 39 | * @return Object, that contains page meta info. 40 | */ 41 | public metaInfo(): any { 42 | return {title: this.$t('profile.title')}; 43 | } 44 | 45 | /** 46 | * Vue component 'created' hook, that executes when component instance 47 | * was created. 48 | */ 49 | public created(): void { 50 | if (!this.user.image) { 51 | this.user.image = require('~assets/img/default_user_photo.jpg'); 52 | } 53 | } 54 | 55 | /** 56 | * Resets user authorization state and redirects to the login page. 57 | */ 58 | public logout(): void { 59 | this.resetAuthorization(); 60 | this.$router.push('/login'); 61 | } 62 | } 63 | 64 | if (HMRApi && module.hot) { 65 | HMRApi.install(Vue); 66 | if (!module.hot.data) { 67 | HMRApi.createRecord('Profile', (Profile as any).options); 68 | } else { 69 | HMRApi.reload('Profile', (Profile as any).options); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/pages/profile/Profile.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/pages/sign-in/SignIn.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | import { Action, namespace } from 'vuex-class'; 4 | 5 | import HMRApi from 'HMRApi'; 6 | import { LOGIN } from 'store/modules/user/actions'; 7 | 8 | import ErrorBlock from 'components/error-block/ErrorBlock.vue'; 9 | 10 | 11 | const UserAction = namespace('user', Action); 12 | 13 | /** 14 | * Describes user authorization page. 15 | */ 16 | @Component({ 17 | components: { 18 | ErrorBlock, 19 | }, 20 | }) 21 | export default class SignIn extends Vue { 22 | 23 | /** 24 | * Executes user login action of the root Vuex store. 25 | */ 26 | @UserAction(LOGIN) 27 | public login: 28 | /** 29 | * @param data User login info with required email and password 30 | * properties. 31 | * 32 | * @return User object with authorized user info. 33 | */ 34 | (data: {email: string, password: string}) => Promise; 35 | 36 | /** 37 | * Email of the user, that is signing in. 38 | * 39 | * It binds to the email input, of the sign in form. 40 | */ 41 | public email: string = ''; 42 | 43 | /** 44 | * Password of the user, that is signing in. 45 | * 46 | * It binds to the password input, of the sign in form. 47 | */ 48 | public password: string = ''; 49 | 50 | /** 51 | * Error message, that will be shown if entered user credentials 52 | * were invalid. 53 | */ 54 | public error: string = ''; 55 | 56 | /** 57 | * Returns meta information of page, such as: 58 | * title, meta tags content etc. 59 | * 60 | * @return Object, that contains page meta info. 61 | */ 62 | public metaInfo(): any { 63 | return {title: this.$t('sign_in.title')}; 64 | } 65 | 66 | /** 67 | * Sign in form submit event handler. 68 | * 69 | * Executes login function and redirects to the profile page on 70 | * success. Otherwise, it shows localized error message. 71 | */ 72 | public onSubmit(): void { 73 | this.error = ''; 74 | this.login({ 75 | email: this.email, 76 | password: this.password, 77 | }).then(() => { 78 | this.$router.push('/profile'); 79 | }).catch((error) => { 80 | let errorMsg: string = this.$t('errors.common').toString(); 81 | switch (error) { 82 | case 1: 83 | errorMsg = this.$t('errors.access_denied').toString(); 84 | break; 85 | } 86 | this.error = errorMsg; 87 | }); 88 | } 89 | } 90 | 91 | if (HMRApi && module.hot) { 92 | HMRApi.install(Vue); 93 | if (!module.hot.data) { 94 | HMRApi.createRecord('SignIn', (SignIn as any).options); 95 | } else { 96 | HMRApi.reload('SignIn', (SignIn as any).options); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/pages/sign-in/SignIn.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/components/pages/sign-up/SignUp.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Component from 'vue-class-component'; 3 | import { Action, namespace } from 'vuex-class'; 4 | 5 | import HMRApi from 'HMRApi'; 6 | import { SIGN_UP } from 'store/modules/user/actions'; 7 | 8 | import ErrorBlock from 'components/error-block/ErrorBlock.vue'; 9 | 10 | 11 | const UserAction = namespace('user', Action); 12 | 13 | /** 14 | * Describes user registration page. 15 | */ 16 | @Component({ 17 | components: { 18 | ErrorBlock, 19 | }, 20 | }) 21 | export default class SignUp extends Vue { 22 | 23 | /** 24 | * Executes user sign up action of the root Vuex store. 25 | */ 26 | @UserAction(SIGN_UP) 27 | public signUp: (user) => Promise; 28 | 29 | /** 30 | * User object, that contains all information, specified by user. 31 | */ 32 | public user = { 33 | email: '', 34 | image: '', 35 | name: '', 36 | password: '', 37 | password_confirm: '', 38 | }; 39 | 40 | /** 41 | * Error message, that will be shown if user with specified email 42 | * already exists. 43 | */ 44 | public error: string = ''; 45 | 46 | /** 47 | * Returns meta information of page, such as: 48 | * title, meta tags content etc. 49 | * 50 | * @return Object, that contains page meta info. 51 | */ 52 | public metaInfo(): any { 53 | return {title: this.$t('sign_up.title')}; 54 | } 55 | 56 | /** 57 | * Sign up form user image change event handler. 58 | * 59 | * It takes image, specified by user and converts it to the base64 60 | * hash, which stores to the 'user' property. 61 | */ 62 | public onImageChange(changeEvent) { 63 | const files = (changeEvent.target.files 64 | || changeEvent.dataTransfer.files); 65 | if (!files.length) { 66 | return; 67 | } 68 | 69 | const reader = new FileReader(); 70 | const component = this; 71 | 72 | reader.onload = (loadedEvent) => { 73 | component.user.image = (loadedEvent.target as FileReader).result; 74 | }; 75 | reader.readAsDataURL(files[0]); 76 | } 77 | 78 | /** 79 | * Sign up form submit event handler. 80 | * 81 | * Executes sign up form validation, sign up function and redirects 82 | * to the profile page. 83 | * If validation was failed or user with specified email already exists, 84 | * it prints localized error message. 85 | */ 86 | public onSubmit(): void { 87 | this.error = ''; 88 | this.$validator.validateAll().then(() => { 89 | if (!this.validationErrors.count()) { 90 | this.signUp(this.user).then(() => { 91 | this.$router.push('/profile'); 92 | }).catch((error) => { 93 | let errorMsg: string = this.$t('errors.common').toString(); 94 | switch (error) { 95 | case 1: 96 | errorMsg = this.$t('errors.email_already_taken') 97 | .toString(); 98 | break; 99 | } 100 | this.error = errorMsg; 101 | }); 102 | } 103 | }); 104 | } 105 | } 106 | 107 | if (HMRApi && module.hot) { 108 | HMRApi.install(Vue); 109 | if (!module.hot.data) { 110 | HMRApi.createRecord('SignUp', (SignUp as any).options); 111 | } else { 112 | HMRApi.reload('SignUp', (SignUp as any).options); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/components/pages/sign-up/SignUp.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/entry/client.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import I18n from 'I18n'; 4 | import params from 'main'; 5 | import store from 'store'; 6 | 7 | 8 | I18n.init([ 9 | store.state.locale, 10 | navigator.language, 11 | ]).then((i18n) => { 12 | params.i18n = i18n; 13 | new Vue(params).$mount('#app'); 14 | }); 15 | 16 | if (module.hot) { 17 | module.hot.status((status) => { 18 | // console.log('status', status); 19 | if (status === 'abort') { 20 | alert('hot checking aborted, reloading...'); 21 | window.location.reload(); 22 | } 23 | }); 24 | 25 | module.hot.accept((errHandler) => { 26 | // console.log('main accept error', errHandler); 27 | }); 28 | 29 | module.hot.dispose(() => { 30 | // console.log('main disposed'); 31 | // console.log(module.hot.data); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/entry/server.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import { Store } from 'vuex'; 4 | 5 | import 'class-component/hooks'; // must be imported first 6 | import I18n from 'I18n'; 7 | import params from 'main'; 8 | 9 | import App from 'components/app/App.vue'; 10 | 11 | 12 | export default (context: any) => { 13 | return new Promise((resolve, reject) => { 14 | if (!params.router || !params.store) { 15 | return reject(); 16 | } 17 | 18 | const router: VueRouter = params.router; 19 | const store: Store = params.store; 20 | 21 | router.push(context.url); 22 | 23 | router.onReady(() => { 24 | const matchedComponents = router.getMatchedComponents(); 25 | 26 | if (!matchedComponents.length) { 27 | reject({ code: 404 }); 28 | } 29 | 30 | Promise.all(matchedComponents.map((component: any | Vue) => { 31 | return (component.options.preFetch 32 | && component.options.preFetch(store)); 33 | })).then(() => { 34 | return (App as any).options.preFetch(store); 35 | }).then(() => { 36 | return I18n.init(context.accept_languages).then((i18n) => { 37 | params.i18n = i18n; 38 | }); 39 | }).then(() => { 40 | const app = new Vue(params); 41 | 42 | context.state = store.state; 43 | context.meta = app.$meta(); 44 | 45 | resolve(app); 46 | }).catch(reject); 47 | }); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue, {ComponentOptions} from 'vue'; 2 | 3 | import 'class-component/hooks'; // must be imported first 4 | import Router from 'Router'; 5 | import store from 'store'; 6 | import Validation from 'Validation'; 7 | 8 | import App from 'components/app/App.vue'; 9 | 10 | 11 | Validation.init(); 12 | 13 | /** 14 | * Initial app params, that can be used for initializing Vue app instance in 15 | * all environments: client, server, test. 16 | */ 17 | export const params: ComponentOptions = { 18 | render: (h) => h(App), 19 | router: new Router().instance, 20 | store, 21 | }; 22 | 23 | export default params; 24 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { ActionTree, GetterTree, MutationTree, Store } from 'vuex'; 3 | import createPersistedState from 'vuex-persistedstate'; 4 | 5 | import UserModule from 'store/modules/user'; 6 | import actions from 'store/root/actions'; 7 | import getters from 'store/root/getters'; 8 | import mutations from 'store/root/mutations'; 9 | import RootState from 'store/root/state'; 10 | 11 | 12 | Vue.use(Vuex); 13 | 14 | const plugins: any[] = []; 15 | 16 | if (process.browser) { 17 | plugins.push(createPersistedState({ 18 | paths: [ 19 | 'locale', 20 | 'user.authorized', 21 | ], 22 | })); 23 | } 24 | 25 | /** 26 | * Vuex store instance, initialized with required root store, 27 | * modules and plugins. 28 | */ 29 | export const store: Store = new Store({ 30 | actions, 31 | getters, 32 | modules: { 33 | user: new UserModule(), 34 | }, 35 | mutations, 36 | plugins, 37 | state: new RootState(), 38 | }); 39 | 40 | if (module.hot) { 41 | module.hot.accept([ 42 | 'store/root/actions', 43 | 'store/root/getters', 44 | 'store/root/mutations', 45 | 'store/modules/user', 46 | ], (updatedDependencies) => { 47 | // console.log('store hot updated!'); 48 | const HotUserModule = require('store/modules/user').default; 49 | store.hotUpdate({ 50 | actions: 51 | require('store/root/actions') as ActionTree, 52 | getters: 53 | require('store/root/getters') as GetterTree, 54 | modules: { 55 | users: new HotUserModule(), 56 | }, 57 | mutations: 58 | require('store/root/mutations') as MutationTree, 59 | }); 60 | }); 61 | 62 | module.hot.dispose(() => { 63 | // console.log('store hot disposed!'); 64 | }); 65 | } 66 | 67 | export default store; 68 | -------------------------------------------------------------------------------- /src/store/modules/user/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionContext, ActionTree } from 'vuex'; 2 | 3 | import UsersApi from 'api/Users'; 4 | import * as mutations from 'store/modules/user/mutations'; 5 | import UserState from 'store/modules/user/state'; 6 | import RootState from 'store/root/state'; 7 | 8 | 9 | /** 10 | * The name of fetch all action. 11 | */ 12 | export const FETCH_ALL: string = 'fetchAll'; 13 | 14 | /** 15 | * The name of login action. 16 | */ 17 | export const LOGIN: string = 'login'; 18 | 19 | /** 20 | * The name of reset authorization action. 21 | */ 22 | export const RESET_AUTHORIZATION: string = 'resetAuthorization'; 23 | 24 | /** 25 | * The name of registration action. 26 | */ 27 | export const SIGN_UP: string = 'signUp'; 28 | 29 | /** 30 | * Fetches all users from API and commit them to the store. 31 | * 32 | * @param store User Vuex store. 33 | * 34 | * @return Resolved promise with array of fetched users. 35 | */ 36 | export function fetchAll( 37 | store: ActionContext, 38 | ): Promise { 39 | return UsersApi.getAll().then((users) => { 40 | store.commit(mutations.SET_LIST, users); 41 | return users as any; 42 | }); 43 | } 44 | 45 | /** 46 | * Call API login action and commit authorized user to the store. 47 | * 48 | * @param store User Vuex store. 49 | * @param user User info with email and password to do login action. 50 | * 51 | * @return Resolved promise with authorized user object, 52 | * or rejected promise with error code: 53 | * - 1: if user with given email/password doesn't exist. 54 | */ 55 | export function login( 56 | store: ActionContext, 57 | user: { email: string, password: string }, 58 | ): Promise { 59 | return UsersApi.login(user.email, user.password).then((authorizedUser) => { 60 | store.commit(mutations.SET_AUTHORIZED, authorizedUser); 61 | return authorizedUser; 62 | }); 63 | } 64 | 65 | /** 66 | * Resets user authorization info in the store by committing null. 67 | * 68 | * @param store User Vuex store. 69 | */ 70 | export function resetAuthorization( 71 | store: ActionContext, 72 | ): void { 73 | store.commit(mutations.SET_AUTHORIZED, null); 74 | } 75 | 76 | /** 77 | * Call API register action and commit created user to the store. 78 | * 79 | * @param store User Vuex store. 80 | * @param user User object with all required info for registration. 81 | * 82 | * @return Resolved promise with new created user object, 83 | * or rejected promise with error code: 84 | * - 1: if user with given email is already exists. 85 | */ 86 | export function signUp( 87 | store: ActionContext, 88 | user, 89 | ): Promise { 90 | return UsersApi.register(user).then((addedUser) => { 91 | store.commit(mutations.ADD, addedUser); 92 | store.commit(mutations.SET_AUTHORIZED, user); 93 | return addedUser; 94 | }); 95 | } 96 | 97 | export default { 98 | fetchAll, 99 | login, 100 | resetAuthorization, 101 | signUp, 102 | } as ActionTree; 103 | -------------------------------------------------------------------------------- /src/store/modules/user/getters.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree } from 'vuex'; 2 | 3 | import UserState from 'store/modules/user/state'; 4 | import RootState from 'store/root/state'; 5 | 6 | 7 | /** 8 | * The name of authorized getter. 9 | */ 10 | export const AUTHORIZED: string = 'authorized'; 11 | 12 | /** 13 | * The name of total count getter. 14 | */ 15 | export const TOTAL_COUNT: string = 'totalCount'; 16 | 17 | /** 18 | * Returns authorized state from users store. 19 | * 20 | * @param state User Vuex state. 21 | * 22 | * @return Authorized user object. 23 | */ 24 | export function authorized(state: UserState) { 25 | return state.authorized; 26 | } 27 | 28 | /** 29 | * Returns total users count state from users store. 30 | * 31 | * @param state User Vuex state. 32 | * 33 | * @return Number of users in state. 34 | */ 35 | export function totalCount(state: UserState) { 36 | return state.all.length; 37 | } 38 | 39 | export default { 40 | authorized, 41 | totalCount, 42 | } as GetterTree; 43 | -------------------------------------------------------------------------------- /src/store/modules/user/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionTree, GetterTree, Module, MutationTree } from 'vuex'; 2 | 3 | import actions from 'store/modules/user/actions'; 4 | import getters from 'store/modules/user/getters'; 5 | import mutations from 'store/modules/user/mutations'; 6 | import UserState from 'store/modules/user/state'; 7 | import RootState from 'store/root/state'; 8 | 9 | 10 | /** 11 | * Represents user Vuex store module and implements its own state, getters, 12 | * actions and mutations. 13 | */ 14 | export default class User implements Module { 15 | 16 | /** 17 | * Specifies if module is self-contained or registered 18 | * under the global namespace. 19 | * 20 | * More info: https://vuex.vuejs.org/en/modules.html ("Namespacing" section) 21 | */ 22 | public namespaced: boolean = true; 23 | 24 | /** 25 | * Specifies user module level state. 26 | */ 27 | public state: UserState; 28 | 29 | /** 30 | * Specifies getters of user module. 31 | */ 32 | public getters: GetterTree = getters; 33 | 34 | /** 35 | * Specifies actions of user module. 36 | */ 37 | public actions: ActionTree = actions; 38 | 39 | /** 40 | * Specifies mutations of user module. 41 | */ 42 | public mutations: MutationTree = mutations; 43 | 44 | /** 45 | * Creates user Vuex module, based on predefined class properties. 46 | */ 47 | public constructor() { 48 | this.state = new UserState(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/store/modules/user/mutations.ts: -------------------------------------------------------------------------------- 1 | import { MutationTree } from 'vuex'; 2 | 3 | import UserState from 'store/modules/user/state'; 4 | 5 | 6 | /** 7 | * The name of add user mutation. 8 | */ 9 | export const ADD: string = 'add'; 10 | 11 | /** 12 | * The name of set user authorization mutation. 13 | */ 14 | export const SET_AUTHORIZED: string = 'setAuthorized'; 15 | 16 | /** 17 | * The name of set users list mutation. 18 | */ 19 | export const SET_LIST: string = 'setList'; 20 | 21 | /** 22 | * Add given user to all users state. 23 | * 24 | * @param state User Vuex state. 25 | * @param user User, that will be added. 26 | */ 27 | export function add(state: UserState, user: object) { 28 | state.all.push(user); 29 | } 30 | 31 | /** 32 | * Sets user authorization state. 33 | * 34 | * @param state User Vuex state. 35 | * @param user User object, that will be set as authorization info. 36 | * It also can be null, then authorization state will be reset. 37 | */ 38 | 39 | export function setAuthorized(state: UserState, user) { 40 | if (user && user.password) { 41 | delete user.password; 42 | } 43 | state.authorized = user; 44 | } 45 | 46 | /** 47 | * Sets list of all users in the state. 48 | * Note, that this action will replace already existing users in list. 49 | * 50 | * @param state User Vuex state. 51 | * @param users Array of users, that will be set as users list in the state. 52 | */ 53 | export function setList(state: UserState, users) { 54 | state.all = users; 55 | } 56 | 57 | export default { 58 | add, 59 | setAuthorized, 60 | setList, 61 | } as MutationTree; 62 | -------------------------------------------------------------------------------- /src/store/modules/user/state.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes user module state. 3 | */ 4 | export default class UserState { 5 | 6 | /** 7 | * List of all users, that exists in system. 8 | */ 9 | public all: object[]; 10 | 11 | /** 12 | * User authorization state. 13 | */ 14 | public authorized: object | null; 15 | 16 | /** 17 | * Creates initial user module state. 18 | */ 19 | constructor() { 20 | this.all = []; 21 | this.authorized = null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/store/root/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionContext, ActionTree } from 'vuex'; 2 | 3 | import * as mutations from 'store/root/mutations'; 4 | import RootState from 'store/root/state'; 5 | 6 | 7 | /** 8 | * The name of start loading action. 9 | */ 10 | export const START_LOADING: string = 'startLoading'; 11 | 12 | /** 13 | * The name of stop loading action. 14 | */ 15 | export const STOP_LOADING: string = 'stopLoading'; 16 | 17 | /** 18 | * Commits "true" to the store's loading state. 19 | * 20 | * @param store Root Vuex store. 21 | */ 22 | export function startLoading(store: ActionContext) { 23 | store.commit(mutations.SET_LOADING, true); 24 | } 25 | /** 26 | * Commits "false" to the store's loading state. 27 | * 28 | * @param store Root Vuex store. 29 | */ 30 | export function stopLoading(store: ActionContext) { 31 | store.commit(mutations.SET_LOADING, false); 32 | } 33 | 34 | export default { 35 | startLoading, 36 | stopLoading, 37 | } as ActionTree; 38 | -------------------------------------------------------------------------------- /src/store/root/getters.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree } from 'vuex'; 2 | 3 | import RootState from 'store/root/state'; 4 | 5 | 6 | /** 7 | * The name of loading getter. 8 | */ 9 | export const LOADING: string = 'loading'; 10 | 11 | /** 12 | * Returns loading state from root store. 13 | * 14 | * @param state Root Vuex state. 15 | * 16 | * @return Loading state. 17 | */ 18 | export function loading(state: RootState) { 19 | return state.loading; 20 | } 21 | 22 | export default { 23 | loading, 24 | } as GetterTree; 25 | -------------------------------------------------------------------------------- /src/store/root/mutations.ts: -------------------------------------------------------------------------------- 1 | import { MutationTree } from 'vuex'; 2 | 3 | import RootState from 'store/root/state'; 4 | 5 | 6 | /** 7 | * The name of set loading mutation. 8 | */ 9 | export const SET_LOADING: string = 'setLoading'; 10 | 11 | /** 12 | * The name of set locale mutation. 13 | */ 14 | export const SET_LOCALE: string = 'setLocale'; 15 | 16 | /** 17 | * Sets application loading state. 18 | * 19 | * @param state Root Vuex state. 20 | * @param isLoading Loading state, that will be set to the state. 21 | */ 22 | export function setLoading(state: RootState, isLoading: boolean) { 23 | state.loading = isLoading; 24 | } 25 | 26 | /** 27 | * Sets application locale state. 28 | * 29 | * @param state Root Vuex state. 30 | * @param locale Locale key string, that will be set to the state. 31 | */ 32 | export function setLocale(state: RootState, locale: string) { 33 | state.locale = locale; 34 | } 35 | 36 | export default { 37 | setLoading, 38 | setLocale, 39 | } as MutationTree; 40 | -------------------------------------------------------------------------------- /src/store/root/state.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes root Vuex state. 3 | */ 4 | export default class RootState { 5 | 6 | /** 7 | * Application loading flag. 8 | */ 9 | public loading: boolean; 10 | 11 | /** 12 | * Application locale. 13 | */ 14 | public locale: string; 15 | 16 | /** 17 | * Creates initial root store state. 18 | */ 19 | constructor() { 20 | this.loading = false; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/templates/index.server.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #{metaInfo} 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/themes/default/main.styl: -------------------------------------------------------------------------------- 1 | html 2 | position: relative 3 | min-height: 100% 4 | 5 | body 6 | margin-bottom: 51px 7 | -------------------------------------------------------------------------------- /test/docker/suite.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | 4 | @test "contains node" { 5 | run docker run --rm --entrypoint sh $IMAGE -c 'which node' 6 | [ "$status" -eq 0 ] 7 | } 8 | 9 | @test "node runs ok" { 10 | run docker run --rm --entrypoint sh $IMAGE -c 'node -v' 11 | [ "$status" -eq 0 ] 12 | } 13 | 14 | 15 | @test "SHARE_APP=1 copies all files from /app/ to /shared/" { 16 | run docker run --rm -e SHARE_APP=0 $IMAGE sh -c \ 17 | 'cd /app && find . | sort' 18 | [ "$status" -eq 0 ] 19 | expected="$output" 20 | 21 | run docker run --rm -e SHARE_APP=1 $IMAGE sh -c \ 22 | 'cd /app && find . | sort' 23 | [ "$status" -eq 0 ] 24 | preserved="$output" 25 | 26 | run docker run --rm -e SHARE_APP=1 $IMAGE sh -c \ 27 | 'cd /shared && find . | sort' 28 | [ "$status" -eq 0 ] 29 | actual="$output" 30 | 31 | [ "$actual" == "$expected" ] 32 | [ "$preserved" == "$expected" ] 33 | } 34 | 35 | 36 | @test "PID 1 runs under 'node' user, not 'root'" { 37 | run docker run --rm $IMAGE 'stat -c "%U" /proc/1' 38 | [ "$status" -eq 0 ] 39 | [ "$output" == "node" ] 40 | } 41 | -------------------------------------------------------------------------------- /test/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | -------------------------------------------------------------------------------- /test/e2e/Helper.ts: -------------------------------------------------------------------------------- 1 | import { NightWatchBrowser } from '../../types/nightwatch'; 2 | 3 | 4 | /** 5 | * Helper class for e2e tests. 6 | */ 7 | export default class Helper { 8 | 9 | /** 10 | * The maximum number of milliseconds to wait, until application is loaded. 11 | * After timeout, Nightwatch returns an error. 12 | */ 13 | public static readonly maxLoadingTime = 3000; 14 | 15 | /** 16 | * Executes before each spec and does required things of initiating 17 | * local storage etc. 18 | * 19 | * @param browser NightWatch browser instance, that future tests 20 | * would work with. 21 | * @param done Callback, that must be called after all required 22 | * actions were completed. 23 | */ 24 | public static beforeEach( 25 | browser: NightWatchBrowser, 26 | done: () => void, 27 | ): void { 28 | browser 29 | .url(browser.launch_url) 30 | .execute((initialStorage) => { 31 | for (const key in initialStorage) { 32 | if (!initialStorage.hasOwnProperty(key)) { 33 | continue; 34 | } 35 | localStorage.setItem( 36 | key, JSON.stringify(initialStorage[key]), 37 | ); 38 | } 39 | }, [browser.globals.localStorage], () => { 40 | done(); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/e2e/Sizes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Set of all available screens and elements sizes, used in the project. 3 | */ 4 | export default class Sizes { 5 | 6 | /** 7 | * Sizes of the brand image. 8 | */ 9 | public static readonly brandImg = { 10 | height: 40, 11 | }; 12 | 13 | /** 14 | * Sizes of the footer. 15 | */ 16 | public static readonly footer = { 17 | height: 52, 18 | }; 19 | 20 | /** 21 | * Sizes of the main section. 22 | */ 23 | public static readonly mainSection = { 24 | width: 400, 25 | }; 26 | 27 | } 28 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | require('babel-register'); 2 | 3 | module.exports = { 4 | src_folders: ['test/e2e/_build/specs'], 5 | output_folder: false, 6 | page_objects_path: 'test/e2e/_build/pages', 7 | detailed_output: false, 8 | selenium: { 9 | start_process: true, 10 | server_path: require('selenium-server').path, 11 | port: 4444, 12 | cli_args: { 13 | 'webdriver.chrome.driver': require('chromedriver').path, 14 | 'webdriver.gecko.driver': require('geckodriver').path, 15 | }, 16 | }, 17 | test_settings: { 18 | default: { 19 | launch_url: 'http://vue-app-example.localhost', 20 | selenium_port: 4444, 21 | selenium_host: 'localhost', 22 | silent: true, 23 | globals: { 24 | localeData: require('../../assets/i18n/en.json'), 25 | localStorage: { 26 | 'vuex': { 27 | locale: 'en', 28 | }, 29 | }, 30 | }, 31 | desiredCapabilities: { 32 | browserName: 'chrome', 33 | }, 34 | }, 35 | chrome: {}, 36 | firefox: { 37 | // If Selenium server is not started by Nightwatch, then we assume 38 | // that Firefox Selenium is listening on another port. 39 | selenium_port: process.env.NOT_START_SELENIUM ? 4445 : 4444, 40 | desiredCapabilities: { 41 | browserName: 'firefox', 42 | }, 43 | }, 44 | }, 45 | }; 46 | 47 | if (process.env.NOT_START_SELENIUM) { 48 | delete module.exports.selenium; 49 | } 50 | -------------------------------------------------------------------------------- /test/e2e/pages/login.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | export = { 4 | url() { 5 | global.console.log('login url ' + this.api.launchUrl + '/login'); 6 | return this.api.launchUrl + '/login'; 7 | }, 8 | elements: { 9 | brandImg: '.navbar-brand img', 10 | emailInput: 'input[name=email]', 11 | error: 'p.error', 12 | footer: 'footer', 13 | form: '#signInForm', 14 | langSwitchLink: 'ul.navbar-right li:not(.active) a', 15 | langActiveLink: 'ul.navbar-right li.active a', 16 | mainSection: 'main > section', 17 | passwordInput: 'input[name=password]', 18 | title: 'h1.title', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /test/e2e/pages/profile.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | export = { 4 | sections: { 5 | main: { 6 | selector: '#profile', 7 | elements: { 8 | userEmail: 'p#userEmail', 9 | userName: 'p#userName', 10 | }, 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /test/e2e/pages/sign_up.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | export = { 4 | url() { 5 | global.console.log('sign up url ' + this.api.launchUrl + '/sign_up'); 6 | return this.api.launchUrl + '/sign_up'; 7 | }, 8 | elements: { 9 | emailError: '#emailError', 10 | emailInput: 'input[name=email]', 11 | error: 'p.error', 12 | form: '#signUpForm', 13 | loadingSpinner: 'div.navbar-header > img', 14 | nameError: '#nameError', 15 | nameInput: 'input[name=name]', 16 | passwordError: '#passwordError', 17 | passwordInput: 'input[name=password]', 18 | passwordConfirmError: '#passwordConfirmError', 19 | passwordConfirmInput: 'input[name=password_confirm]', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /test/e2e/specs/components/App.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../types/nightwatch'; // tslint:disable-line 4 | import Helper from '../../Helper'; 5 | import Sizes from '../../Sizes'; 6 | 7 | 8 | const ANIMATION_DURATION = 500; 9 | 10 | export = { 11 | 12 | 13 | '@tags': ['components'], 14 | 15 | 'beforeEach': (browser: NightWatchBrowser, done: () => void) => { 16 | Helper.beforeEach(browser, done); 17 | }, 18 | 19 | 20 | 'Main section width is 400px': 21 | (client: NightWatchClient) => { 22 | const loginPage: NightWatchPage = client.page.login(); 23 | 24 | loginPage.navigate() 25 | .waitForElementVisible('#app', Helper.maxLoadingTime); 26 | 27 | loginPage.getElementSize( 28 | '@mainSection', 29 | (result) => { 30 | client.assert.equal( 31 | result.value.width, 32 | Sizes.mainSection.width, 33 | ); 34 | }, 35 | ); 36 | client.end(); 37 | }, 38 | 39 | 40 | 'Footer height is 52px': 41 | (client: NightWatchClient) => { 42 | const loginPage: NightWatchPage = client.page.login(); 43 | 44 | loginPage.navigate() 45 | .waitForElementVisible('#app', Helper.maxLoadingTime); 46 | 47 | loginPage.getElementSize( 48 | '@footer', 49 | (result) => { 50 | client.assert.equal( 51 | result.value.height, 52 | Sizes.footer.height, 53 | ); 54 | }, 55 | ); 56 | client.end(); 57 | }, 58 | 59 | 60 | 'Animation start on navigation': 61 | (client: NightWatchClient) => { 62 | const loginPage: NightWatchPage = client.page.login(); 63 | const signUpPage: NightWatchPage = client.page.sign_up(); 64 | 65 | signUpPage.navigate() 66 | .waitForElementVisible('#app', Helper.maxLoadingTime); 67 | client.pause(ANIMATION_DURATION); 68 | let opacityBeforeLoad: number; 69 | let opacityOnLoad: number; 70 | let opacityAfterLoad: number; 71 | loginPage.getCssProperty( 72 | '@mainSection', 73 | 'opacity', 74 | (result) => { 75 | opacityBeforeLoad = result.value; 76 | client.assert.equal(opacityBeforeLoad, 1); 77 | }, 78 | ); 79 | loginPage.navigate() 80 | .waitForElementVisible('#app', Helper.maxLoadingTime); 81 | client.pause(ANIMATION_DURATION * 0.1); 82 | loginPage.getCssProperty( 83 | '@mainSection', 84 | 'opacity', 85 | (result) => { 86 | opacityOnLoad = result.value; 87 | client.assert.notEqual(opacityOnLoad, opacityBeforeLoad); 88 | }, 89 | ); 90 | client.pause(ANIMATION_DURATION); 91 | loginPage.getCssProperty( 92 | '@mainSection', 93 | 'opacity', 94 | (result) => { 95 | opacityAfterLoad = result.value; 96 | client.assert.equal(opacityAfterLoad, opacityBeforeLoad); 97 | }, 98 | ); 99 | client.end(); 100 | }, 101 | 102 | 103 | }; 104 | -------------------------------------------------------------------------------- /test/e2e/specs/components/ErrorBlock.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../types/nightwatch'; // tslint:disable-line 4 | import Helper from '../../Helper'; 5 | 6 | 7 | export = { 8 | 9 | 10 | '@tags': ['components'], 11 | 12 | 'beforeEach': (browser: NightWatchBrowser, done: () => void) => { 13 | Helper.beforeEach(browser, done); 14 | }, 15 | 16 | 17 | 'Error message is not present default': 18 | (client: NightWatchClient) => { 19 | const loginPage: NightWatchPage = client.page.login(); 20 | 21 | loginPage.navigate() 22 | .waitForElementVisible('#app', Helper.maxLoadingTime) 23 | .assert.hidden('@error'); 24 | client.end(); 25 | }, 26 | 27 | 28 | }; 29 | -------------------------------------------------------------------------------- /test/e2e/specs/components/LanguageSwitcher.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../types/nightwatch'; // tslint:disable-line 4 | import Helper from '../../Helper'; 5 | 6 | 7 | export = { 8 | 9 | 10 | '@tags': ['components'], 11 | 12 | 'beforeEach': (browser: NightWatchBrowser, done: () => void) => { 13 | Helper.beforeEach(browser, done); 14 | }, 15 | 16 | 17 | 'Change page lang on click other lang': 18 | (client: NightWatchClient) => { 19 | const loginPage: NightWatchPage = client.page.login(); 20 | 21 | let prevTitle: string; 22 | let clickedLang: string; 23 | loginPage.getText( 24 | '@langSwitchLink', 25 | (result) => clickedLang = result.value, 26 | ); 27 | loginPage.navigate() 28 | .waitForElementVisible('#app', Helper.maxLoadingTime) 29 | .getText('@title', (result) => prevTitle = result.value) 30 | .assert.containsText( 31 | '@title', 32 | client.globals.localeData.sign_in.title, 33 | ) 34 | .click('@langSwitchLink', () => { 35 | (loginPage.expect.element('@title') as any) 36 | .text.to.not.equal(prevTitle); 37 | }) 38 | .getText( 39 | '@langActiveLink', 40 | (result) => client.assert.equal(result.value, clickedLang), 41 | ); 42 | client.end(); 43 | }, 44 | 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /test/e2e/specs/components/LoadingSpinner.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../types/nightwatch'; // tslint:disable-line 4 | import Helper from '../../Helper'; 5 | 6 | 7 | export = { 8 | 9 | 10 | '@tags': ['components'], 11 | 12 | 'beforeEach': (browser: NightWatchBrowser, done: () => void) => { 13 | Helper.beforeEach(browser, done); 14 | }, 15 | 16 | 17 | 'Show loading spinner when load new page': 18 | (client: NightWatchClient) => { 19 | const loginPage: NightWatchPage = client.page.login(); 20 | const signUpPage: NightWatchPage = client.page.sign_up(); 21 | 22 | loginPage.navigate() 23 | .waitForElementVisible('#app', Helper.maxLoadingTime); 24 | signUpPage.navigate() 25 | .waitForElementVisible('@loadingSpinner', Helper.maxLoadingTime) 26 | .waitForElementNotVisible( 27 | '@loadingSpinner', 28 | Helper.maxLoadingTime, 29 | ); 30 | 31 | client.end(); 32 | }, 33 | 34 | 35 | }; 36 | -------------------------------------------------------------------------------- /test/e2e/specs/components/Navbar.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | 3 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../types/nightwatch'; // tslint:disable-line 4 | import Helper from '../../Helper'; 5 | import Sizes from '../../Sizes'; 6 | 7 | 8 | export = { 9 | 10 | 11 | '@tags': ['components'], 12 | 13 | 'beforeEach': (browser: NightWatchBrowser, done: () => void) => { 14 | Helper.beforeEach(browser, done); 15 | }, 16 | 17 | 18 | 'Brand image height is 40px': 19 | (client: NightWatchClient) => { 20 | const loginPage: NightWatchPage = client.page.login(); 21 | 22 | loginPage.navigate() 23 | .waitForElementVisible('#app', Helper.maxLoadingTime); 24 | 25 | loginPage.getElementSize( 26 | '@brandImg', 27 | (result) => { 28 | client.assert.equal( 29 | result.value.height, 30 | Sizes.brandImg.height, 31 | ); 32 | }, 33 | ); 34 | client.end(); 35 | }, 36 | 37 | 38 | }; 39 | -------------------------------------------------------------------------------- /test/e2e/specs/components/pages/login.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../../types/nightwatch'; // tslint:disable-line 3 | import Helper from '../../../Helper'; 4 | 5 | 6 | export = { 7 | 8 | 9 | '@tags': ['pages'], 10 | 11 | 'beforeEach': (browser: NightWatchBrowser, done: () => void) => { 12 | Helper.beforeEach(browser, done); 13 | }, 14 | 15 | 16 | 'Authorization with valid credentials': (client: NightWatchClient) => { 17 | const loginPage: NightWatchPage = client.page.login(); 18 | const profilePage: NightWatchPage = client.page.profile(); 19 | 20 | loginPage.navigate() 21 | .waitForElementVisible('#app', Helper.maxLoadingTime) 22 | .setValue('@emailInput', 'test@gmail.com') 23 | .setValue('@passwordInput', '123123') 24 | .submitForm('@form') 25 | .waitForElementVisible( 26 | profilePage.section.main.selector, 27 | Helper.maxLoadingTime, 28 | ) 29 | .assert.containsText( 30 | profilePage.section.main.elements.userName.selector, 31 | 'Test User', 32 | ) 33 | .assert.containsText( 34 | profilePage.section.main.elements.userEmail.selector, 35 | 'test@gmail.com', 36 | ); 37 | 38 | client.end(); 39 | }, 40 | 41 | 42 | 'Authorization with wrong credentials': (client: NightWatchClient) => { 43 | const loginPage: NightWatchPage = client.page.login(); 44 | 45 | loginPage.navigate() 46 | .waitForElementVisible('#app', Helper.maxLoadingTime) 47 | .setValue('@emailInput', 'wronguser@gmail.com') 48 | .setValue('@passwordInput', '123123') 49 | .submitForm('@form') 50 | .waitForElementVisible('@error', Helper.maxLoadingTime) 51 | .assert.containsText( 52 | '@error', client.globals.localeData.errors.access_denied, 53 | ); 54 | 55 | client.end(); 56 | }, 57 | 58 | 59 | }; 60 | -------------------------------------------------------------------------------- /test/e2e/specs/components/pages/sign_up.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:object-literal-sort-keys */ 2 | import { NightWatchBrowser, NightWatchClient, NightWatchPage } from '../../../../../types/nightwatch'; // tslint:disable-line 3 | import Helper from '../../../Helper'; 4 | 5 | 6 | export = { 7 | 8 | 9 | '@tags': ['pages'], 10 | 11 | 'beforeEach': (browser: NightWatchBrowser, done: () => void): void => { 12 | Helper.beforeEach(browser, done); 13 | }, 14 | 15 | 16 | 'Registration with already taken email': ( 17 | client: NightWatchClient, 18 | ): void => { 19 | const signUpPage: NightWatchPage = client.page.sign_up(); 20 | 21 | signUpPage.navigate() 22 | .waitForElementVisible('#app', Helper.maxLoadingTime) 23 | .setValue('@nameInput', 'Test User') 24 | .setValue('@emailInput', 'test@gmail.com') 25 | .setValue('@passwordInput', '123123') 26 | .setValue('@passwordConfirmInput', '123123') 27 | .submitForm('@form') 28 | .waitForElementVisible('@error', Helper.maxLoadingTime) 29 | .assert.containsText( 30 | '@error', client.globals.localeData.errors.email_already_taken, 31 | ); 32 | 33 | client.end(); 34 | }, 35 | 36 | 37 | 'Validation during registration': (client: NightWatchClient): void => { 38 | const signUpPage: NightWatchPage = client.page.sign_up(); 39 | const requiredMessage: string = 40 | client.globals.localeData.validation.messages.required; 41 | const emailMessage: string = 42 | client.globals.localeData.validation.messages.email; 43 | const minMessage: string = 44 | client.globals.localeData.validation.messages.min; 45 | const confirmedMessage: string = 46 | client.globals.localeData.validation.messages.confirmed; 47 | 48 | signUpPage.navigate() 49 | .waitForElementVisible('#app', Helper.maxLoadingTime); 50 | 51 | signUpPage 52 | .setValue('@nameInput', '') 53 | .submitForm('@form') 54 | .assert.containsText( 55 | signUpPage.elements.nameError.selector, requiredMessage, 56 | ); 57 | 58 | signUpPage 59 | .setValue('@emailInput', 'wrong_email') 60 | .submitForm('@form') 61 | .assert.containsText( 62 | signUpPage.elements.emailError.selector, emailMessage, 63 | ); 64 | 65 | signUpPage 66 | .setValue('@passwordInput', '123') 67 | .submitForm('@form') 68 | .assert.containsText( 69 | signUpPage.elements.passwordError.selector, 70 | minMessage.replace('{value}', '6'), 71 | ); 72 | 73 | signUpPage 74 | .setValue('@passwordInput', '123456') 75 | .setValue('@passwordConfirmInput', '1234567') 76 | .submitForm('@form') 77 | .assert.containsText( 78 | signUpPage.elements.passwordConfirmError.selector, 79 | confirmedMessage, 80 | ); 81 | 82 | client.end(); 83 | }, 84 | 85 | 86 | }; 87 | -------------------------------------------------------------------------------- /test/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "removeComments": true, 4 | "outDir": "./_build" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/unit/Helper.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | import I18n from 'I18n'; 4 | import params from 'main'; 5 | 6 | 7 | /** 8 | * Describes helper class, that contains helper and common functions, 9 | * required fot unit specs. 10 | */ 11 | export default class Helper { 12 | 13 | /** 14 | * Initializes Vue application instance with rendered given component 15 | * and locale. 16 | * 17 | * @param component Vue component that will be mounted and rendered 18 | * at the app root level. 19 | * @param mount Specifies, if component must be mounted after 20 | * the app instance was created. 21 | * If set to "true", the component will be mounted, 22 | * and only then app instance will be returned. 23 | * @param locale Optional. Specifies locale, which will be used 24 | * during rendering. Default - en. 25 | * 26 | * @return Resolved promise with Vue application instance. 27 | */ 28 | public static initApp( 29 | component: any, 30 | mount: boolean = true, 31 | locale: string = 'en', 32 | ): Promise { 33 | params.render = (h) => h(component); 34 | 35 | return I18n.init([locale]).then((i18n) => { 36 | params.i18n = i18n; 37 | const app = new Vue(params); 38 | 39 | return (mount ? app.$mount() : app); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/unit/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | 4 | Vue.config.productionTip = false; 5 | 6 | /** 7 | * Collects all unit spec files from specs folder by pattern. 8 | * Resulted context will be used by Karma launcher to run unit tests. 9 | */ 10 | const testsContext: __WebpackModuleApi.RequireContext = 11 | require.context('unit/specs', true, /\.spec\.ts$/); 12 | 13 | testsContext.keys().forEach(testsContext); 14 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('../../webpack/test.config.js'); 2 | 3 | module.exports = function(config) { 4 | config.set({ 5 | browsers: ['PhantomJS'], 6 | frameworks: ['mocha', 'chai'], 7 | reporters: ['spec'], 8 | files: [ 9 | '../../node_modules/babel-polyfill/dist/polyfill.js', 10 | './index.ts', 11 | ], 12 | preprocessors: { 13 | './index.ts': ['webpack'], 14 | }, 15 | webpack: webpackConfig, 16 | webpackMiddleware: { 17 | noInfo: true, 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /test/unit/specs/I18n.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import I18n from 'I18n'; 4 | 5 | 6 | describe('I18n.ts', () => { 7 | 8 | 9 | describe('init()', () => { 10 | 11 | it('correctly initializes vue-i18n plugin with english language', 12 | () => { 13 | return I18n.init(['en']).then((i18n) => { 14 | return expect(Object.keys(i18n.getLocaleMessage('en'))) 15 | .to.be.an('array') 16 | .and.not.be.empty; 17 | }); 18 | }); 19 | 20 | it('correctly initializes vue-i18n plugin with default language ' + 21 | 'on empty priority list', 22 | () => { 23 | return I18n.init([]).then((i18n) => { 24 | return expect( 25 | Object.keys(i18n.getLocaleMessage(I18n.defaultLocale)), 26 | ) 27 | .to.be.an('array') 28 | .and.not.be.empty; 29 | }); 30 | }); 31 | 32 | it('correctly initializes vue-i18n plugin with default language ' + 33 | 'on not existing locale', 34 | () => { 35 | return I18n.init(['not_existing_language']).then((i18n) => { 36 | return expect( 37 | Object.keys(i18n.getLocaleMessage(I18n.defaultLocale)), 38 | ) 39 | .to.be.an('array') 40 | .and.not.be.empty; 41 | }); 42 | }); 43 | 44 | }); 45 | 46 | 47 | describe('loadLocaleData()', () => { 48 | 49 | it('correctly loads existing locale data', () => { 50 | return I18n.loadLocaleData('en').then((data) => { 51 | return expect(data) 52 | .to.have.deep.property('validation.messages'); 53 | }); 54 | }); 55 | 56 | }); 57 | 58 | 59 | }); 60 | -------------------------------------------------------------------------------- /test/unit/specs/Router.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import Router from 'Router'; 4 | 5 | 6 | describe('Router.ts', () => { 7 | 8 | 9 | describe('constructor()', () => { 10 | 11 | it('correctly initializes vue-router plugin', () => { 12 | const router = new Router().instance; 13 | expect(router) 14 | .to.have.deep.property('options.routes'); 15 | }); 16 | 17 | }); 18 | 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /test/unit/specs/components/LanguageSwitcher.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Vue from 'vue'; 3 | 4 | import LanguageSwitcher from 'components/language-switcher/LanguageSwitcher.vue'; // tslint:disable-line 5 | 6 | import Helper from 'unit/Helper'; 7 | 8 | 9 | describe('components/partial/LanguageSwitcher.vue', () => { 10 | 11 | 12 | let app: Vue; 13 | let component: any; 14 | 15 | before(() => { 16 | return Helper.initApp(LanguageSwitcher).then((vm: Vue) => { 17 | app = vm; 18 | component = app.$children[0] as any; 19 | }); 20 | }); 21 | 22 | 23 | describe('locales()', () => { 24 | 25 | it('returns valid languages list', () => { 26 | return expect(component.locales) 27 | .to.be.an('array') 28 | .and.not.be.empty; 29 | }); 30 | 31 | }); 32 | 33 | 34 | describe('isActive()', () => { 35 | 36 | it('returns "true" for current app language', () => { 37 | return expect(component.isActive(app.$i18n.locale)) 38 | .to.be.true; 39 | }); 40 | 41 | }); 42 | 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /test/unit/specs/components/pages/Profile.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Vue from 'vue'; 3 | 4 | import Profile from 'components/pages/profile/Profile.vue'; 5 | 6 | import Helper from 'unit/Helper'; 7 | 8 | 9 | describe('components/pages/Profile.vue', () => { 10 | 11 | 12 | const testUser = { 13 | email: 'test@gmail.com', 14 | id: 1, 15 | name: 'Test', 16 | }; 17 | let app: Vue; 18 | let component: any; 19 | 20 | before(() => { 21 | return Helper.initApp(Profile, false).then((vm: Vue) => { 22 | app = vm; 23 | app.$store.state.user.authorized = testUser; 24 | app.$mount(); 25 | 26 | component = app.$children[0] as any; 27 | }); 28 | }); 29 | 30 | 31 | describe('metaInfo()', () => { 32 | 33 | it('returns correct page title', () => { 34 | expect(component.$metaInfo.title) 35 | .to.equal(app.$i18n.t('profile.title')); 36 | }); 37 | 38 | }); 39 | 40 | 41 | it('renders correct section title', () => { 42 | expect((app.$el.querySelector('h1.title') as any).textContent) 43 | .to.equal(app.$i18n.t('profile.title')); 44 | }); 45 | 46 | 47 | it('renders correct user name', () => { 48 | expect((app.$el.querySelector('#userName') as any).textContent) 49 | .to.equal(testUser.name); 50 | }); 51 | 52 | 53 | }); 54 | -------------------------------------------------------------------------------- /test/unit/specs/components/pages/SignIn.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Vue from 'vue'; 3 | 4 | import SignIn from 'components/pages/sign-in/SignIn.vue'; 5 | 6 | import Helper from 'unit/Helper'; 7 | 8 | 9 | describe('components/pages/SignIn.vue', () => { 10 | 11 | 12 | let app: Vue; 13 | let component: any; 14 | 15 | before(() => { 16 | return Helper.initApp(SignIn).then((vm: Vue) => { 17 | app = vm; 18 | component = app.$children[0] as any; 19 | }); 20 | }); 21 | 22 | 23 | describe('metaInfo()', () => { 24 | 25 | it('returns correct page title', () => { 26 | expect(component.metaInfo().title) 27 | .to.equal(app.$i18n.t('sign_in.title')); 28 | }); 29 | 30 | }); 31 | 32 | 33 | it('renders correct section title', () => { 34 | expect((app.$el.querySelector('h1.title') as any).textContent) 35 | .to.equal(app.$i18n.t('sign_in.title')); 36 | }); 37 | 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /test/unit/specs/components/pages/SignUp.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Vue from 'vue'; 3 | 4 | import SignUp from 'components/pages/sign-up/SignUp.vue'; 5 | 6 | import Helper from 'unit/Helper'; 7 | 8 | 9 | describe('components/pages/SignUp.vue', () => { 10 | 11 | 12 | let app: Vue; 13 | let component: any; 14 | 15 | before(() => { 16 | return Helper.initApp(SignUp).then((vm: Vue) => { 17 | app = vm; 18 | component = app.$children[0] as any; 19 | }); 20 | }); 21 | 22 | 23 | describe('metaInfo()', () => { 24 | 25 | it('returns correct page title', () => { 26 | expect(component.metaInfo().title) 27 | .to.equal(app.$i18n.t('sign_up.title')); 28 | }); 29 | 30 | }); 31 | 32 | 33 | it('renders correct section title', () => { 34 | expect((app.$el.querySelector('h1.title') as any).textContent) 35 | .to.equal(app.$i18n.t('sign_up.title')); 36 | }); 37 | 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "*": [ 6 | "*", 7 | "src/*", 8 | "test/*" 9 | ] 10 | }, 11 | "noImplicitAny": false, 12 | "strict": true, 13 | "noImplicitReturns": true, 14 | "module": "es6", 15 | "moduleResolution": "node", 16 | "allowSyntheticDefaultImports": true, 17 | "experimentalDecorators": true, 18 | "target": "es6", 19 | "lib": [ 20 | "dom", 21 | "es5", 22 | "es6", 23 | "es2015", 24 | "es2016" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "_cache", 30 | "_dist" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended" 4 | ], 5 | "rules": { 6 | "quotemark": [true, "single"], 7 | "interface-name": [true, "never-prefix"], 8 | "max-line-length": [true, 80], 9 | "no-consecutive-blank-lines": [true, 2] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /types/node-process.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | 3 | declare namespace NodeJS { 4 | interface Process { 5 | browser: boolean; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /types/system-import.d.ts: -------------------------------------------------------------------------------- 1 | interface System { 2 | import(module: string): Promise; 3 | } 4 | 5 | declare const System: System; 6 | -------------------------------------------------------------------------------- /types/vue-file-shims.d.ts: -------------------------------------------------------------------------------- 1 | // This is necessary so that TypeScript doesn't 2 | // give an error (from .ts files) when importing a .vue file. 3 | // This *may* be removable in the future pending language service plugins 4 | // for Vue. 5 | declare module '*.vue' { 6 | import Vue from 'vue'; 7 | 8 | const component: typeof Vue; 9 | export default component; 10 | } 11 | -------------------------------------------------------------------------------- /types/vue.d.ts: -------------------------------------------------------------------------------- 1 | import 'vue'; 2 | 3 | declare module 'vue/types/vue' { 4 | interface Vue { 5 | validationErrors: any; 6 | $meta(): any; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /webpack/base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const stylusLoader = require('stylus-loader'); 4 | 5 | const isProd = (process.env.NODE_ENV === 'production'); 6 | 7 | module.exports = { 8 | output: { 9 | filename: 'build.js', 10 | }, 11 | recordsInputPath: path.resolve(__dirname, '../webpack/records.json'), 12 | recordsOutputPath: 13 | path.resolve(__dirname, '../webpack.records.json'), 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.ts$/, 18 | exclude: /node_modules|vue\/src/, 19 | use: [ 20 | { 21 | loader: 'babel-loader', 22 | }, 23 | { 24 | loader: 'ts-loader', 25 | options: { 26 | appendTsSuffixTo: [/\.vue$/], 27 | compilerOptions: { 28 | isolatedModules: true, 29 | }, 30 | transpileOnly: true, 31 | }, 32 | }, 33 | ], 34 | }, 35 | { 36 | test: /\.vue$/, 37 | use: [ 38 | { 39 | loader: 'vue-loader', 40 | options: { 41 | // esModule: true, 42 | loaders: { 43 | ts: 'babel-loader!ts-loader', 44 | }, 45 | }, 46 | }, 47 | ], 48 | }, 49 | { 50 | test: /\.(png|jpg|gif|svg)$/, 51 | loader: 'file-loader', 52 | options: { 53 | name: 'img/[name].[ext]?[hash]', 54 | }, 55 | }, 56 | { 57 | test: /\.(eot|svg|ttf|woff|woff2)$/, 58 | loader: 'file-loader?name=fonts/[name].[ext]', 59 | }, 60 | { 61 | test: /\.css$/, 62 | use: ['vue-style-loader', 'css-loader'], 63 | }, 64 | { 65 | test: /\.styl/, 66 | use: ['vue-style-loader', 'stylus-loader'], 67 | }, 68 | ], 69 | }, 70 | resolve: { 71 | modules: [ 72 | path.join(__dirname, '../src'), 73 | 'node_modules', 74 | ], 75 | alias: { 76 | 'assets': path.join(__dirname, '../assets'), 77 | '~assets': path.join(__dirname, '../assets'), 78 | }, 79 | extensions: ['.tsx', '.ts', '.js'], 80 | }, 81 | performance: { 82 | hints: false, 83 | }, 84 | devtool: '#cheap-module-eval-source-map', 85 | plugins: [ 86 | new webpack.DefinePlugin({ 87 | 'process.env': { 88 | NODE_ENV: JSON.stringify(process.env.NODE_ENV), 89 | }, 90 | }), 91 | new stylusLoader.OptionsPlugin({ 92 | default: { 93 | preferPathResolver: 'webpack', 94 | }, 95 | }), 96 | ], 97 | }; 98 | 99 | if (isProd) { 100 | delete module.exports.recordsOutputPath; 101 | module.exports.devtool = false; 102 | module.exports.plugins = (module.exports.plugins || []).concat([ 103 | new webpack.optimize.UglifyJsPlugin({ 104 | sourceMap: true, 105 | compress: { 106 | warnings: false, 107 | }, 108 | }), 109 | new webpack.LoaderOptionsPlugin({ 110 | minimize: true, 111 | }), 112 | ]); 113 | } 114 | -------------------------------------------------------------------------------- /webpack/client.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const merge = require('webpack-merge'); 6 | 7 | const base = require('./base.config'); 8 | const isProd = (process.env.NODE_ENV === 'production'); 9 | 10 | module.exports = merge(base, { 11 | entry: './src/entry/client.ts', 12 | output: { 13 | path: path.resolve(__dirname, '../public'), 14 | hotUpdateChunkFilename: 'hot/[id].[hash].hot-update.js', 15 | hotUpdateMainFilename: 'hot/[hash].hot-update.json', 16 | }, 17 | node: { 18 | fs: 'empty', 19 | }, 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env': { 23 | VUE_ENV: '"client"', 24 | API_URL: JSON.stringify(process.env.CLIENT_API_URL), 25 | }, 26 | }), 27 | new HtmlWebpackPlugin({ 28 | template: 'src/templates/index.html', 29 | minify: isProd 30 | ? { 31 | removeComments: true, 32 | collapseWhitespace: true, 33 | removeAttributeQuotes: true, 34 | } 35 | : undefined, 36 | }), 37 | new CopyWebpackPlugin([ 38 | {from: 'assets/i18n', to: 'i18n'}, 39 | ]), 40 | new webpack.HotModuleReplacementPlugin(), 41 | new webpack.NamedModulesPlugin(), 42 | ], 43 | }); 44 | -------------------------------------------------------------------------------- /webpack/docs.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const merge = require('webpack-merge'); 3 | const TypedocWebpackPlugin = require('typedoc-webpack-plugin'); 4 | 5 | const base = require('./base.config'); 6 | const client = require('./client.config'); 7 | 8 | module.exports = merge(base, { 9 | entry: client.entry, 10 | output: { 11 | path: path.resolve(__dirname, '../_docs'), 12 | }, 13 | node: client.node, 14 | plugins: [ 15 | new TypedocWebpackPlugin({ 16 | mode: 'modules', 17 | module: 'es6', 18 | target: 'es6', 19 | out: './', 20 | exclude: '**/{node_modules,entry}/**/*.*', 21 | experimentalDecorators: true, 22 | excludeExternals: true, 23 | ignoreCompilerErrors: true, 24 | moduleResolution: 'node', 25 | includeDeclarations: false, 26 | externalPattern: '**/*.d.ts', 27 | emitDecoratorMetadata: true, 28 | preserveConstEnums: true, 29 | stripInternal: true, 30 | suppressExcessPropertyErrors: true, 31 | suppressImplicitAnyIndexErrors: true, 32 | allowSyntheticDefaultImports: true, 33 | paths: { 34 | '*': [ 35 | 'src/*', 36 | 'test/*', 37 | ], 38 | }, 39 | }, ['./src', './test']), 40 | ], 41 | }); 42 | 43 | delete module.exports.recordsOutputPath; 44 | -------------------------------------------------------------------------------- /webpack/server.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const VueSSRPlugin = require('vue-ssr-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | const merge = require('webpack-merge'); 6 | const nodeExternals = require('webpack-node-externals'); 7 | 8 | const base = require('./base.config'); 9 | const isProd = (process.env.NODE_ENV === 'production'); 10 | 11 | module.exports = merge(base, { 12 | target: 'node', 13 | entry: './src/entry/server.ts', 14 | output: { 15 | path: path.resolve(__dirname, '../'), 16 | libraryTarget: 'commonjs2', 17 | }, 18 | externals: nodeExternals({}), 19 | plugins: [ 20 | new webpack.DefinePlugin({ 21 | 'process.env': { 22 | VUE_ENV: '"server"', 23 | API_URL: JSON.stringify(process.env.SERVER_API_URL), 24 | }, 25 | }), 26 | new VueSSRPlugin(), 27 | new HtmlWebpackPlugin({ 28 | template: 'src/templates/index.server.html', 29 | filename: 'index.server.html', 30 | minify: isProd 31 | ? { 32 | removeComments: false, 33 | collapseWhitespace: true, 34 | removeAttributeQuotes: true, 35 | } 36 | : undefined, 37 | }), 38 | ], 39 | }); 40 | -------------------------------------------------------------------------------- /webpack/test.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const merge = require('webpack-merge'); 3 | 4 | const base = require('./base.config'); 5 | 6 | module.exports = merge(base, { 7 | node: { 8 | fs: 'empty', 9 | }, 10 | resolve: { 11 | modules: [ 12 | path.join(__dirname, '../test'), 13 | ], 14 | alias: { 15 | 'vue$': 'vue/dist/vue.esm.js', 16 | }, 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------