├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.vue ├── common │ ├── api.service.js │ ├── config.js │ ├── date.filter.js │ ├── error.filter.js │ └── jwt.service.js ├── components │ ├── ArticleActions.vue │ ├── ArticleList.vue │ ├── ArticleMeta.vue │ ├── Comment.vue │ ├── CommentEditor.vue │ ├── ListErrors.vue │ ├── TagList.vue │ ├── TheFooter.vue │ ├── TheHeader.vue │ ├── VArticlePreview.vue │ ├── VPagination.vue │ └── VTag.vue ├── main.js ├── registerServiceWorker.js ├── router │ └── index.js ├── store │ ├── actions.type.js │ ├── article.module.js │ ├── auth.module.js │ ├── home.module.js │ ├── index.js │ ├── mutations.type.js │ ├── profile.module.js │ └── settings.module.js └── views │ ├── Article.vue │ ├── ArticleEdit.vue │ ├── Home.vue │ ├── HomeGlobal.vue │ ├── HomeMyFeed.vue │ ├── HomeTag.vue │ ├── Login.vue │ ├── Profile.vue │ ├── ProfileArticles.vue │ ├── ProfileFavorited.vue │ ├── Register.vue │ └── Settings.vue ├── static └── rwv-logo.png ├── tests └── unit │ ├── .eslintrc.js │ ├── components │ ├── ListError.spec.js │ ├── VPagination.spec.js │ └── VTag.spec.js │ ├── example.spec.js │ └── store │ ├── __snapshots__ │ └── article.module.spec.js.snap │ └── article.module.spec.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ["plugin:vue/essential", "@vue/prettier"], 7 | rules: { 8 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 9 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off" 10 | }, 11 | parserOptions: { 12 | parser: "babel-eslint" 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | 23 | # Coverage Reports 24 | coverage 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present all contributors listed here https://github.com/gothinkster/vue-realworld-example-app/graphs/contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![RealWorld Frontend](https://img.shields.io/badge/realworld-frontend-%23783578.svg)](http://realworld.io) 2 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 3 | 4 | ---- 5 | ##New Maintainers wanted## 6 | Anyone up for the challenge of maintaining this repo? 7 | Reach out on twitter @vilsbole 8 | ---- 9 | 10 | 11 | 12 | # ![RealWorld Example App](./static/rwv-logo.png) 13 | 14 | > Vue.js codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 15 | 16 | Project demo is available at https://vue-vuex-realworld.netlify.app/ 17 | 18 | This codebase was created to demonstrate a fully fledged fullstack application built with **Vue.js** including CRUD operations, authentication, routing, pagination, and more. 19 | 20 | We've gone to great lengths to adhere to the **Vue.js** community styleguides & best practices. 21 | 22 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 23 | 24 | ## Getting started 25 | 26 | Before contributing please read the following: 27 | 28 | 1. [RealWorld guidelines](https://github.com/gothinkster/realworld/tree/master/spec) for implementing a new framework, 29 | 2. [RealWorld frontend instructions](https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md) 30 | 3. [Realworld API endpoints](https://github.com/gothinkster/realworld/tree/master/api) 31 | 4. [Vue.js styleguide](https://vuejs.org/v2/style-guide/index.html). Priority A and B categories must be respected. 32 | 5. [Editorconfig setup](https://editorconfig.org/#download). Most of the common editors support editorconfig by default (check the editorconfig download link for your ide), but editorconfig npm package have to installed globally for it to work, 33 | 34 | ```bash 35 | # install editorconfig globally 36 | > npm install -g editorconfig 37 | ``` 38 | 39 | The stack is built using [vue-cli webpack](https://github.com/vuejs-templates/webpack) so to get started all you have to do is: 40 | 41 | ``` bash 42 | # install dependencies 43 | > yarn install 44 | # serve with hot reload at localhost:8080 45 | > yarn serve 46 | ``` 47 | 48 | Other commands available are: 49 | 50 | ``` bash 51 | # build for production with minification 52 | yarn run build 53 | 54 | # run unit tests 55 | yarn test 56 | ``` 57 | 58 | ## To know 59 | 60 | Current arbitrary choices are: 61 | 62 | - Vuex modules for store 63 | - Vue-axios for ajax requests 64 | - 'rwv' as prefix for components 65 | 66 | These can be changed when the contributors reach a consensus. 67 | 68 | ## FAQ 69 | 70 |

71 | Where can I find the service worker file? 72 | 73 | The service worker file is generated automatically. The implementation can be found under [`src/registerServiceWorker.js`](https://github.com/gothinkster/vue-realworld-example-app/blob/eeaeb34fa440d00cd400545301ea203bd2a59284/src/registerServiceWorker.js). You can find the dependencies implementation in this repo: [yyx990803/register-service-worker](https://github.com/yyx990803/register-service-worker#readme). 74 | 75 | Also, Google provided a good documentation on how to register a service worker: https://developers.google.com/web/fundamentals/primers/service-workers/registration 76 |

77 | 78 |

79 | Vue.js Function API / Migration to Vue.js 3 80 | 81 | Related resources: 82 | 83 | - [Vue.js Function API RFC](https://github.com/vuejs/rfcs/blob/function-apis/active-rfcs/0000-function-api.md) 84 | - [`vue-function-api` plugin](https://github.com/vuejs/vue-function-api) 85 | 86 | Vue.js 3 will likely introduce breaking changes on how Vue.js applications will look like. For example, the Vue.js Function API might be introduced. This would cause a lot of our components to change in the overall structure. The changes would be minimal though. With the `vue-function-api` plugin, these changes could be applied already. The problem is that multiple integrations are not working with the plugin. There are intentions to make this work, but for the time being, we should rather focus on different areas. If you still want to be experimental with it, we are happy to get a Pull Request with some experimental feature implementations. 87 |

88 | 89 | ## Connect 90 | 91 | Join us on [Discord](https://discord.gg/NE2jNmg) 92 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | dev: { 4 | presets: ["@vue/app"] 5 | }, 6 | test: { 7 | presets: ["@babel/preset-env"] 8 | } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["js", "jsx", "json", "vue"], 3 | transform: { 4 | "^.+\\.vue$": "vue-jest", 5 | ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": 6 | "jest-transform-stub", 7 | "^.+\\.(js|jsx)?$": "babel-jest" 8 | }, 9 | moduleNameMapper: { 10 | "^@/(.*)$": "/src/$1" 11 | }, 12 | snapshotSerializers: ["jest-serializer-vue"], 13 | testMatch: [ 14 | "/(tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))" 15 | ], 16 | testURL: "http://localhost/", 17 | transformIgnorePatterns: ["/node_modules/"] 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Emmanuel Vilsbol ", 3 | "dependencies": { 4 | "axios": "^0.19.0", 5 | "date-fns": "^1.30.1", 6 | "marked": "^0.7.0", 7 | "register-service-worker": "^1.6.2", 8 | "vue": "^2.6.10", 9 | "vue-axios": "^2.1.4", 10 | "vue-router": "^3.1.3", 11 | "vuex": "^3.1.1" 12 | }, 13 | "description": "TodoMVC for the RealWorld™", 14 | "devDependencies": { 15 | "@babel/core": "^7.6.2", 16 | "@babel/preset-env": "^7.6.2", 17 | "@vue/cli-plugin-babel": "^3.12.0", 18 | "@vue/cli-plugin-eslint": "^3.10.0", 19 | "@vue/cli-plugin-pwa": "^3.10.0", 20 | "@vue/cli-plugin-unit-jest": "^3.10.0", 21 | "@vue/cli-service": "^3.10.0", 22 | "@vue/eslint-config-prettier": "^4.0.1", 23 | "@vue/test-utils": "^1.0.0-beta.29", 24 | "babel-core": "7.0.0-bridge.0", 25 | "babel-jest": "^24.9.0", 26 | "cross-env": "^6.0.3", 27 | "lint-staged": "^8.2.1", 28 | "node-sass": "^4.12.0", 29 | "nyc": "^14.1.1", 30 | "sass-loader": "^7.1.0", 31 | "vue-template-compiler": "^2.6.10" 32 | }, 33 | "gitHooks": { 34 | "pre-commit": "lint-staged" 35 | }, 36 | "lint-staged": { 37 | "*.js": [ 38 | "vue-cli-service lint", 39 | "git add" 40 | ], 41 | "*.vue": [ 42 | "vue-cli-service lint", 43 | "git add" 44 | ] 45 | }, 46 | "name": "realworld-vue", 47 | "scripts": { 48 | "build": "cross-env BABEL_ENV=dev vue-cli-service build", 49 | "lint": "vue-cli-service lint", 50 | "serve": "cross-env BABEL_ENV=dev vue-cli-service serve", 51 | "test": "cross-env BABEL_ENV=test jest --coverage" 52 | }, 53 | "version": "0.1.0" 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Conduit 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-realworld-example-app", 3 | "short_name": "vue-realworld-example-app", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/index.html", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/common/api.service.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import axios from "axios"; 3 | import VueAxios from "vue-axios"; 4 | import JwtService from "@/common/jwt.service"; 5 | import { API_URL } from "@/common/config"; 6 | 7 | const ApiService = { 8 | init() { 9 | Vue.use(VueAxios, axios); 10 | Vue.axios.defaults.baseURL = API_URL; 11 | }, 12 | 13 | setHeader() { 14 | Vue.axios.defaults.headers.common[ 15 | "Authorization" 16 | ] = `Token ${JwtService.getToken()}`; 17 | }, 18 | 19 | query(resource, params) { 20 | return Vue.axios.get(resource, params).catch(error => { 21 | throw new Error(`[RWV] ApiService ${error}`); 22 | }); 23 | }, 24 | 25 | get(resource, slug = "") { 26 | return Vue.axios.get(`${resource}/${slug}`).catch(error => { 27 | throw new Error(`[RWV] ApiService ${error}`); 28 | }); 29 | }, 30 | 31 | post(resource, params) { 32 | return Vue.axios.post(`${resource}`, params); 33 | }, 34 | 35 | update(resource, slug, params) { 36 | return Vue.axios.put(`${resource}/${slug}`, params); 37 | }, 38 | 39 | put(resource, params) { 40 | return Vue.axios.put(`${resource}`, params); 41 | }, 42 | 43 | delete(resource) { 44 | return Vue.axios.delete(resource).catch(error => { 45 | throw new Error(`[RWV] ApiService ${error}`); 46 | }); 47 | } 48 | }; 49 | 50 | export default ApiService; 51 | 52 | export const TagsService = { 53 | get() { 54 | return ApiService.get("tags"); 55 | } 56 | }; 57 | 58 | export const ArticlesService = { 59 | query(type, params) { 60 | return ApiService.query("articles" + (type === "feed" ? "/feed" : ""), { 61 | params: params 62 | }); 63 | }, 64 | get(slug) { 65 | return ApiService.get("articles", slug); 66 | }, 67 | create(params) { 68 | return ApiService.post("articles", { article: params }); 69 | }, 70 | update(slug, params) { 71 | return ApiService.update("articles", slug, { article: params }); 72 | }, 73 | destroy(slug) { 74 | return ApiService.delete(`articles/${slug}`); 75 | } 76 | }; 77 | 78 | export const CommentsService = { 79 | get(slug) { 80 | if (typeof slug !== "string") { 81 | throw new Error( 82 | "[RWV] CommentsService.get() article slug required to fetch comments" 83 | ); 84 | } 85 | return ApiService.get("articles", `${slug}/comments`); 86 | }, 87 | 88 | post(slug, payload) { 89 | return ApiService.post(`articles/${slug}/comments`, { 90 | comment: { body: payload } 91 | }); 92 | }, 93 | 94 | destroy(slug, commentId) { 95 | return ApiService.delete(`articles/${slug}/comments/${commentId}`); 96 | } 97 | }; 98 | 99 | export const FavoriteService = { 100 | add(slug) { 101 | return ApiService.post(`articles/${slug}/favorite`); 102 | }, 103 | remove(slug) { 104 | return ApiService.delete(`articles/${slug}/favorite`); 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /src/common/config.js: -------------------------------------------------------------------------------- 1 | export const API_URL = "https://conduit.productionready.io/api"; 2 | export default API_URL; 3 | -------------------------------------------------------------------------------- /src/common/date.filter.js: -------------------------------------------------------------------------------- 1 | import { default as format } from "date-fns/format"; 2 | 3 | export default date => { 4 | return format(new Date(date), "MMMM D, YYYY"); 5 | }; 6 | -------------------------------------------------------------------------------- /src/common/error.filter.js: -------------------------------------------------------------------------------- 1 | export default errorValue => { 2 | return `${errorValue[0]}`; 3 | }; 4 | -------------------------------------------------------------------------------- /src/common/jwt.service.js: -------------------------------------------------------------------------------- 1 | const ID_TOKEN_KEY = "id_token"; 2 | 3 | export const getToken = () => { 4 | return window.localStorage.getItem(ID_TOKEN_KEY); 5 | }; 6 | 7 | export const saveToken = token => { 8 | window.localStorage.setItem(ID_TOKEN_KEY, token); 9 | }; 10 | 11 | export const destroyToken = () => { 12 | window.localStorage.removeItem(ID_TOKEN_KEY); 13 | }; 14 | 15 | export default { getToken, saveToken, destroyToken }; 16 | -------------------------------------------------------------------------------- /src/components/ArticleActions.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 102 | -------------------------------------------------------------------------------- /src/components/ArticleList.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 126 | -------------------------------------------------------------------------------- /src/components/ArticleMeta.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 79 | -------------------------------------------------------------------------------- /src/components/Comment.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 50 | -------------------------------------------------------------------------------- /src/components/CommentEditor.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 55 | -------------------------------------------------------------------------------- /src/components/ListErrors.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /src/components/TagList.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 21 | -------------------------------------------------------------------------------- /src/components/TheFooter.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /src/components/TheHeader.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 97 | -------------------------------------------------------------------------------- /src/components/VArticlePreview.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | -------------------------------------------------------------------------------- /src/components/VPagination.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 44 | -------------------------------------------------------------------------------- /src/components/VTag.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import store from "./store"; 5 | import "./registerServiceWorker"; 6 | 7 | import { CHECK_AUTH } from "./store/actions.type"; 8 | import ApiService from "./common/api.service"; 9 | import DateFilter from "./common/date.filter"; 10 | import ErrorFilter from "./common/error.filter"; 11 | 12 | Vue.config.productionTip = false; 13 | Vue.filter("date", DateFilter); 14 | Vue.filter("error", ErrorFilter); 15 | 16 | ApiService.init(); 17 | 18 | // Ensure we checked auth before each page load. 19 | router.beforeEach((to, from, next) => 20 | Promise.all([store.dispatch(CHECK_AUTH)]).then(next) 21 | ); 22 | 23 | new Vue({ 24 | router, 25 | store, 26 | render: h => h(App) 27 | }).$mount("#app"); 28 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from "register-service-worker"; 4 | 5 | if (process.env.NODE_ENV === "production") { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | "App is being served from cache by a service worker.\n" + 10 | "For more details, visit https://goo.gl/AFskqB" 11 | ); 12 | }, 13 | cached() { 14 | console.log("Content has been cached for offline use."); 15 | }, 16 | updated() { 17 | console.log("New content is available; please refresh."); 18 | }, 19 | offline() { 20 | console.log( 21 | "No internet connection found. App is running in offline mode." 22 | ); 23 | }, 24 | error(error) { 25 | console.error("Error during service worker registration:", error); 26 | } 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Router from "vue-router"; 3 | 4 | Vue.use(Router); 5 | 6 | export default new Router({ 7 | routes: [ 8 | { 9 | path: "/", 10 | component: () => import("@/views/Home"), 11 | children: [ 12 | { 13 | path: "", 14 | name: "home", 15 | component: () => import("@/views/HomeGlobal") 16 | }, 17 | { 18 | path: "my-feed", 19 | name: "home-my-feed", 20 | component: () => import("@/views/HomeMyFeed") 21 | }, 22 | { 23 | path: "tag/:tag", 24 | name: "home-tag", 25 | component: () => import("@/views/HomeTag") 26 | } 27 | ] 28 | }, 29 | { 30 | name: "login", 31 | path: "/login", 32 | component: () => import("@/views/Login") 33 | }, 34 | { 35 | name: "register", 36 | path: "/register", 37 | component: () => import("@/views/Register") 38 | }, 39 | { 40 | name: "settings", 41 | path: "/settings", 42 | component: () => import("@/views/Settings") 43 | }, 44 | // Handle child routes with a default, by giving the name to the 45 | // child. 46 | // SO: https://github.com/vuejs/vue-router/issues/777 47 | { 48 | path: "/@:username", 49 | component: () => import("@/views/Profile"), 50 | children: [ 51 | { 52 | path: "", 53 | name: "profile", 54 | component: () => import("@/views/ProfileArticles") 55 | }, 56 | { 57 | name: "profile-favorites", 58 | path: "favorites", 59 | component: () => import("@/views/ProfileFavorited") 60 | } 61 | ] 62 | }, 63 | { 64 | name: "article", 65 | path: "/articles/:slug", 66 | component: () => import("@/views/Article"), 67 | props: true 68 | }, 69 | { 70 | name: "article-edit", 71 | path: "/editor/:slug?", 72 | props: true, 73 | component: () => import("@/views/ArticleEdit") 74 | } 75 | ] 76 | }); 77 | -------------------------------------------------------------------------------- /src/store/actions.type.js: -------------------------------------------------------------------------------- 1 | export const ARTICLE_PUBLISH = "publishArticle"; 2 | export const ARTICLE_DELETE = "deleteArticle"; 3 | export const ARTICLE_EDIT = "editArticle"; 4 | export const ARTICLE_EDIT_ADD_TAG = "addTagToArticle"; 5 | export const ARTICLE_EDIT_REMOVE_TAG = "removeTagFromArticle"; 6 | export const ARTICLE_RESET_STATE = "resetArticleState"; 7 | export const CHECK_AUTH = "checkAuth"; 8 | export const COMMENT_CREATE = "createComment"; 9 | export const COMMENT_DESTROY = "destroyComment"; 10 | export const FAVORITE_ADD = "addFavorite"; 11 | export const FAVORITE_REMOVE = "removeFavorite"; 12 | export const FETCH_ARTICLE = "fetchArticle"; 13 | export const FETCH_ARTICLES = "fetchArticles"; 14 | export const FETCH_COMMENTS = "fetchComments"; 15 | export const FETCH_PROFILE = "fetchProfile"; 16 | export const FETCH_PROFILE_FOLLOW = "fetchProfileFollow"; 17 | export const FETCH_PROFILE_UNFOLLOW = "fetchProfileUnfollow"; 18 | export const FETCH_TAGS = "fetchTags"; 19 | export const LOGIN = "login"; 20 | export const LOGOUT = "logout"; 21 | export const REGISTER = "register"; 22 | export const UPDATE_USER = "updateUser"; 23 | -------------------------------------------------------------------------------- /src/store/article.module.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { 3 | ArticlesService, 4 | CommentsService, 5 | FavoriteService 6 | } from "@/common/api.service"; 7 | import { 8 | FETCH_ARTICLE, 9 | FETCH_COMMENTS, 10 | COMMENT_CREATE, 11 | COMMENT_DESTROY, 12 | FAVORITE_ADD, 13 | FAVORITE_REMOVE, 14 | ARTICLE_PUBLISH, 15 | ARTICLE_EDIT, 16 | ARTICLE_EDIT_ADD_TAG, 17 | ARTICLE_EDIT_REMOVE_TAG, 18 | ARTICLE_DELETE, 19 | ARTICLE_RESET_STATE 20 | } from "./actions.type"; 21 | import { 22 | RESET_STATE, 23 | SET_ARTICLE, 24 | SET_COMMENTS, 25 | TAG_ADD, 26 | TAG_REMOVE, 27 | UPDATE_ARTICLE_IN_LIST 28 | } from "./mutations.type"; 29 | 30 | const initialState = { 31 | article: { 32 | author: {}, 33 | title: "", 34 | description: "", 35 | body: "", 36 | tagList: [] 37 | }, 38 | comments: [] 39 | }; 40 | 41 | export const state = { ...initialState }; 42 | 43 | export const actions = { 44 | async [FETCH_ARTICLE](context, articleSlug, prevArticle) { 45 | // avoid extronuous network call if article exists 46 | if (prevArticle !== undefined) { 47 | return context.commit(SET_ARTICLE, prevArticle); 48 | } 49 | const { data } = await ArticlesService.get(articleSlug); 50 | context.commit(SET_ARTICLE, data.article); 51 | return data; 52 | }, 53 | async [FETCH_COMMENTS](context, articleSlug) { 54 | const { data } = await CommentsService.get(articleSlug); 55 | context.commit(SET_COMMENTS, data.comments); 56 | return data.comments; 57 | }, 58 | async [COMMENT_CREATE](context, payload) { 59 | await CommentsService.post(payload.slug, payload.comment); 60 | context.dispatch(FETCH_COMMENTS, payload.slug); 61 | }, 62 | async [COMMENT_DESTROY](context, payload) { 63 | await CommentsService.destroy(payload.slug, payload.commentId); 64 | context.dispatch(FETCH_COMMENTS, payload.slug); 65 | }, 66 | async [FAVORITE_ADD](context, slug) { 67 | const { data } = await FavoriteService.add(slug); 68 | context.commit(UPDATE_ARTICLE_IN_LIST, data.article, { root: true }); 69 | context.commit(SET_ARTICLE, data.article); 70 | }, 71 | async [FAVORITE_REMOVE](context, slug) { 72 | const { data } = await FavoriteService.remove(slug); 73 | // Update list as well. This allows us to favorite an article in the Home view. 74 | context.commit(UPDATE_ARTICLE_IN_LIST, data.article, { root: true }); 75 | context.commit(SET_ARTICLE, data.article); 76 | }, 77 | [ARTICLE_PUBLISH]({ state }) { 78 | return ArticlesService.create(state.article); 79 | }, 80 | [ARTICLE_DELETE](context, slug) { 81 | return ArticlesService.destroy(slug); 82 | }, 83 | [ARTICLE_EDIT]({ state }) { 84 | return ArticlesService.update(state.article.slug, state.article); 85 | }, 86 | [ARTICLE_EDIT_ADD_TAG](context, tag) { 87 | context.commit(TAG_ADD, tag); 88 | }, 89 | [ARTICLE_EDIT_REMOVE_TAG](context, tag) { 90 | context.commit(TAG_REMOVE, tag); 91 | }, 92 | [ARTICLE_RESET_STATE]({ commit }) { 93 | commit(RESET_STATE); 94 | } 95 | }; 96 | 97 | /* eslint no-param-reassign: ["error", { "props": false }] */ 98 | export const mutations = { 99 | [SET_ARTICLE](state, article) { 100 | state.article = article; 101 | }, 102 | [SET_COMMENTS](state, comments) { 103 | state.comments = comments; 104 | }, 105 | [TAG_ADD](state, tag) { 106 | state.article.tagList = state.article.tagList.concat([tag]); 107 | }, 108 | [TAG_REMOVE](state, tag) { 109 | state.article.tagList = state.article.tagList.filter(t => t !== tag); 110 | }, 111 | [RESET_STATE]() { 112 | for (let f in state) { 113 | Vue.set(state, f, initialState[f]); 114 | } 115 | } 116 | }; 117 | 118 | const getters = { 119 | article(state) { 120 | return state.article; 121 | }, 122 | comments(state) { 123 | return state.comments; 124 | } 125 | }; 126 | 127 | export default { 128 | state, 129 | actions, 130 | mutations, 131 | getters 132 | }; 133 | -------------------------------------------------------------------------------- /src/store/auth.module.js: -------------------------------------------------------------------------------- 1 | import ApiService from "@/common/api.service"; 2 | import JwtService from "@/common/jwt.service"; 3 | import { 4 | LOGIN, 5 | LOGOUT, 6 | REGISTER, 7 | CHECK_AUTH, 8 | UPDATE_USER 9 | } from "./actions.type"; 10 | import { SET_AUTH, PURGE_AUTH, SET_ERROR } from "./mutations.type"; 11 | 12 | const state = { 13 | errors: null, 14 | user: {}, 15 | isAuthenticated: !!JwtService.getToken() 16 | }; 17 | 18 | const getters = { 19 | currentUser(state) { 20 | return state.user; 21 | }, 22 | isAuthenticated(state) { 23 | return state.isAuthenticated; 24 | } 25 | }; 26 | 27 | const actions = { 28 | [LOGIN](context, credentials) { 29 | return new Promise(resolve => { 30 | ApiService.post("users/login", { user: credentials }) 31 | .then(({ data }) => { 32 | context.commit(SET_AUTH, data.user); 33 | resolve(data); 34 | }) 35 | .catch(({ response }) => { 36 | context.commit(SET_ERROR, response.data.errors); 37 | }); 38 | }); 39 | }, 40 | [LOGOUT](context) { 41 | context.commit(PURGE_AUTH); 42 | }, 43 | [REGISTER](context, credentials) { 44 | return new Promise((resolve, reject) => { 45 | ApiService.post("users", { user: credentials }) 46 | .then(({ data }) => { 47 | context.commit(SET_AUTH, data.user); 48 | resolve(data); 49 | }) 50 | .catch(({ response }) => { 51 | context.commit(SET_ERROR, response.data.errors); 52 | reject(response); 53 | }); 54 | }); 55 | }, 56 | [CHECK_AUTH](context) { 57 | if (JwtService.getToken()) { 58 | ApiService.setHeader(); 59 | ApiService.get("user") 60 | .then(({ data }) => { 61 | context.commit(SET_AUTH, data.user); 62 | }) 63 | .catch(({ response }) => { 64 | context.commit(SET_ERROR, response.data.errors); 65 | }); 66 | } else { 67 | context.commit(PURGE_AUTH); 68 | } 69 | }, 70 | [UPDATE_USER](context, payload) { 71 | const { email, username, password, image, bio } = payload; 72 | const user = { 73 | email, 74 | username, 75 | bio, 76 | image 77 | }; 78 | if (password) { 79 | user.password = password; 80 | } 81 | 82 | return ApiService.put("user", user).then(({ data }) => { 83 | context.commit(SET_AUTH, data.user); 84 | return data; 85 | }); 86 | } 87 | }; 88 | 89 | const mutations = { 90 | [SET_ERROR](state, error) { 91 | state.errors = error; 92 | }, 93 | [SET_AUTH](state, user) { 94 | state.isAuthenticated = true; 95 | state.user = user; 96 | state.errors = {}; 97 | JwtService.saveToken(state.user.token); 98 | }, 99 | [PURGE_AUTH](state) { 100 | state.isAuthenticated = false; 101 | state.user = {}; 102 | state.errors = {}; 103 | JwtService.destroyToken(); 104 | } 105 | }; 106 | 107 | export default { 108 | state, 109 | actions, 110 | mutations, 111 | getters 112 | }; 113 | -------------------------------------------------------------------------------- /src/store/home.module.js: -------------------------------------------------------------------------------- 1 | import { TagsService, ArticlesService } from "@/common/api.service"; 2 | import { FETCH_ARTICLES, FETCH_TAGS } from "./actions.type"; 3 | import { 4 | FETCH_START, 5 | FETCH_END, 6 | SET_TAGS, 7 | UPDATE_ARTICLE_IN_LIST 8 | } from "./mutations.type"; 9 | 10 | const state = { 11 | tags: [], 12 | articles: [], 13 | isLoading: true, 14 | articlesCount: 0 15 | }; 16 | 17 | const getters = { 18 | articlesCount(state) { 19 | return state.articlesCount; 20 | }, 21 | articles(state) { 22 | return state.articles; 23 | }, 24 | isLoading(state) { 25 | return state.isLoading; 26 | }, 27 | tags(state) { 28 | return state.tags; 29 | } 30 | }; 31 | 32 | const actions = { 33 | [FETCH_ARTICLES]({ commit }, params) { 34 | commit(FETCH_START); 35 | return ArticlesService.query(params.type, params.filters) 36 | .then(({ data }) => { 37 | commit(FETCH_END, data); 38 | }) 39 | .catch(error => { 40 | throw new Error(error); 41 | }); 42 | }, 43 | [FETCH_TAGS]({ commit }) { 44 | return TagsService.get() 45 | .then(({ data }) => { 46 | commit(SET_TAGS, data.tags); 47 | }) 48 | .catch(error => { 49 | throw new Error(error); 50 | }); 51 | } 52 | }; 53 | 54 | /* eslint no-param-reassign: ["error", { "props": false }] */ 55 | const mutations = { 56 | [FETCH_START](state) { 57 | state.isLoading = true; 58 | }, 59 | [FETCH_END](state, { articles, articlesCount }) { 60 | state.articles = articles; 61 | state.articlesCount = articlesCount; 62 | state.isLoading = false; 63 | }, 64 | [SET_TAGS](state, tags) { 65 | state.tags = tags; 66 | }, 67 | [UPDATE_ARTICLE_IN_LIST](state, data) { 68 | state.articles = state.articles.map(article => { 69 | if (article.slug !== data.slug) { 70 | return article; 71 | } 72 | // We could just return data, but it seems dangerous to 73 | // mix the results of different api calls, so we 74 | // protect ourselves by copying the information. 75 | article.favorited = data.favorited; 76 | article.favoritesCount = data.favoritesCount; 77 | return article; 78 | }); 79 | } 80 | }; 81 | 82 | export default { 83 | state, 84 | getters, 85 | actions, 86 | mutations 87 | }; 88 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | 4 | import home from "./home.module"; 5 | import auth from "./auth.module"; 6 | import article from "./article.module"; 7 | import profile from "./profile.module"; 8 | 9 | Vue.use(Vuex); 10 | 11 | export default new Vuex.Store({ 12 | modules: { 13 | home, 14 | auth, 15 | article, 16 | profile 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/store/mutations.type.js: -------------------------------------------------------------------------------- 1 | export const FETCH_END = "setArticles"; 2 | export const FETCH_START = "setLoading"; 3 | export const PURGE_AUTH = "logOut"; 4 | export const SET_ARTICLE = "setArticle"; 5 | export const SET_AUTH = "setUser"; 6 | export const SET_COMMENTS = "setComments"; 7 | export const SET_ERROR = "setError"; 8 | export const SET_PROFILE = "setProfile"; 9 | export const SET_TAGS = "setTags"; 10 | export const TAG_ADD = "addTag"; 11 | export const TAG_REMOVE = "removeTag"; 12 | export const UPDATE_ARTICLE_IN_LIST = "updateArticleInList"; 13 | export const RESET_STATE = "resetModuleState"; 14 | -------------------------------------------------------------------------------- /src/store/profile.module.js: -------------------------------------------------------------------------------- 1 | import ApiService from "@/common/api.service"; 2 | import { 3 | FETCH_PROFILE, 4 | FETCH_PROFILE_FOLLOW, 5 | FETCH_PROFILE_UNFOLLOW 6 | } from "./actions.type"; 7 | import { SET_PROFILE } from "./mutations.type"; 8 | 9 | const state = { 10 | errors: {}, 11 | profile: {} 12 | }; 13 | 14 | const getters = { 15 | profile(state) { 16 | return state.profile; 17 | } 18 | }; 19 | 20 | const actions = { 21 | [FETCH_PROFILE](context, payload) { 22 | const { username } = payload; 23 | return ApiService.get("profiles", username) 24 | .then(({ data }) => { 25 | context.commit(SET_PROFILE, data.profile); 26 | return data; 27 | }) 28 | .catch(() => { 29 | // #todo SET_ERROR cannot work in multiple states 30 | // context.commit(SET_ERROR, response.data.errors) 31 | }); 32 | }, 33 | [FETCH_PROFILE_FOLLOW](context, payload) { 34 | const { username } = payload; 35 | return ApiService.post(`profiles/${username}/follow`) 36 | .then(({ data }) => { 37 | context.commit(SET_PROFILE, data.profile); 38 | return data; 39 | }) 40 | .catch(() => { 41 | // #todo SET_ERROR cannot work in multiple states 42 | // context.commit(SET_ERROR, response.data.errors) 43 | }); 44 | }, 45 | [FETCH_PROFILE_UNFOLLOW](context, payload) { 46 | const { username } = payload; 47 | return ApiService.delete(`profiles/${username}/follow`) 48 | .then(({ data }) => { 49 | context.commit(SET_PROFILE, data.profile); 50 | return data; 51 | }) 52 | .catch(() => { 53 | // #todo SET_ERROR cannot work in multiple states 54 | // context.commit(SET_ERROR, response.data.errors) 55 | }); 56 | } 57 | }; 58 | 59 | const mutations = { 60 | // [SET_ERROR] (state, error) { 61 | // state.errors = error 62 | // }, 63 | [SET_PROFILE](state, profile) { 64 | state.profile = profile; 65 | state.errors = {}; 66 | } 67 | }; 68 | 69 | export default { 70 | state, 71 | actions, 72 | mutations, 73 | getters 74 | }; 75 | -------------------------------------------------------------------------------- /src/store/settings.module.js: -------------------------------------------------------------------------------- 1 | import { ArticlesService, CommentsService } from "@/common/api.service"; 2 | import { FETCH_ARTICLE, FETCH_COMMENTS } from "./actions.type"; 3 | import { SET_ARTICLE, SET_COMMENTS } from "./mutations.type"; 4 | 5 | export const state = { 6 | article: {}, 7 | comments: [] 8 | }; 9 | 10 | export const actions = { 11 | [FETCH_ARTICLE](context, articleSlug) { 12 | return ArticlesService.get(articleSlug) 13 | .then(({ data }) => { 14 | context.commit(SET_ARTICLE, data.article); 15 | }) 16 | .catch(error => { 17 | throw new Error(error); 18 | }); 19 | }, 20 | [FETCH_COMMENTS](context, articleSlug) { 21 | return CommentsService.get(articleSlug) 22 | .then(({ data }) => { 23 | context.commit(SET_COMMENTS, data.comments); 24 | }) 25 | .catch(error => { 26 | throw new Error(error); 27 | }); 28 | } 29 | }; 30 | 31 | /* eslint no-param-reassign: ["error", { "props": false }] */ 32 | export const mutations = { 33 | [SET_ARTICLE](state, article) { 34 | state.article = article; 35 | }, 36 | [SET_COMMENTS](state, comments) { 37 | state.comments = comments; 38 | } 39 | }; 40 | 41 | export default { 42 | state, 43 | actions, 44 | mutations 45 | }; 46 | -------------------------------------------------------------------------------- /src/views/Article.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 96 | -------------------------------------------------------------------------------- /src/views/ArticleEdit.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 151 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 81 | -------------------------------------------------------------------------------- /src/views/HomeGlobal.vue: -------------------------------------------------------------------------------- 1 | 4 | 14 | -------------------------------------------------------------------------------- /src/views/HomeMyFeed.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /src/views/HomeTag.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 68 | -------------------------------------------------------------------------------- /src/views/Profile.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 113 | -------------------------------------------------------------------------------- /src/views/ProfileArticles.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | -------------------------------------------------------------------------------- /src/views/ProfileFavorited.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 23 | -------------------------------------------------------------------------------- /src/views/Register.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 81 | -------------------------------------------------------------------------------- /src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 89 | -------------------------------------------------------------------------------- /static/rwv-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gothinkster/vue-realworld-example-app/3df3773b6be7181aadb513572458fba049fe66f9/static/rwv-logo.png -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /tests/unit/components/ListError.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import ListErrors from "../../../src/components/ListErrors.vue"; 4 | 5 | const createWrapper = ({ errors }) => { 6 | return mount(ListErrors, { 7 | propsData: { 8 | errors 9 | } 10 | }); 11 | }; 12 | 13 | describe("ListErrors", () => { 14 | let errors; 15 | 16 | beforeEach(() => { 17 | errors = { 18 | title: ["Title Error"], 19 | body: ["can't be blank"], 20 | description: ["can't be blank"] 21 | }; 22 | }); 23 | 24 | it("should display the correct error messages based on object from props", () => { 25 | const wrapper = createWrapper({ errors }); 26 | 27 | const errorMessages = wrapper.findAll("li"); 28 | expect(errorMessages.length).toEqual(3); 29 | expect(errorMessages.at(0).text()).toContain(errors.title); 30 | expect(errorMessages.at(1).text()).toContain(errors.body); 31 | expect(errorMessages.at(2).text()).toContain(errors.description); 32 | }); 33 | 34 | it("should have props with errors as type object", () => { 35 | const wrapper = createWrapper({ errors }); 36 | expect(typeof wrapper.props().errors).toBe("object"); 37 | }); 38 | 39 | it("should have no errors if no errors are passed into the props", () => { 40 | errors = {}; 41 | 42 | const wrapper = createWrapper({ errors }); 43 | 44 | const errorMessages = wrapper.findAll("li"); 45 | expect(errorMessages.length).toEqual(0); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/unit/components/VPagination.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | 3 | import VPagination from "../../../src/components/VPagination.vue"; 4 | 5 | const createWrapper = ({ currentPage = 1 }) => { 6 | return mount(VPagination, { 7 | propsData: { 8 | pages: [1, 2, 3, 4], 9 | currentPage 10 | } 11 | }); 12 | }; 13 | 14 | describe("VPagination", () => { 15 | it("should render active class to right element", () => { 16 | const wrapper = createWrapper({ currentPage: 2 }); 17 | const activeItem = wrapper.find(".active"); 18 | expect(activeItem.text()).toBe("2"); 19 | }); 20 | 21 | it("should emit an event if page is clicked which is not active", () => { 22 | const wrapper = createWrapper({ currentPage: 1 }); 23 | const pageItem = wrapper.find('[data-test="page-link-2"]'); 24 | pageItem.trigger("click"); 25 | expect(wrapper.emitted("update:currentPage")).toBeTruthy(); 26 | }); 27 | 28 | it("should have the right payload when event is emitted", () => { 29 | const wrapper = createWrapper({ currentPage: 1 }); 30 | const pageItem = wrapper.find('[data-test="page-link-2"]'); 31 | pageItem.trigger("click"); 32 | expect(wrapper.emitted("update:currentPage")[0][0]).toBe(2); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/unit/components/VTag.spec.js: -------------------------------------------------------------------------------- 1 | import { mount, createLocalVue } from "@vue/test-utils"; 2 | 3 | import router from "../../../src/router/index"; 4 | import VTag from "../../../src/components/VTag"; 5 | 6 | const localVue = createLocalVue(); 7 | const createWrapper = () => { 8 | return mount(VTag, { 9 | localVue, 10 | router, 11 | propsData: { 12 | name: "Foo" 13 | } 14 | }); 15 | }; 16 | 17 | describe("VTag", () => { 18 | it("should update the route on click", async () => { 19 | const wrapper = createWrapper(); 20 | const routerBefore = wrapper.vm.$route.path; 21 | wrapper.find("a").trigger("click"); 22 | await localVue.nextTick(); 23 | expect(wrapper.vm.$route.path).not.toBe(routerBefore); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/unit/example.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, mount } from "@vue/test-utils"; 2 | import Vuex from "vuex"; 3 | import VueRouter from "vue-router"; 4 | 5 | import Comment from "../../src/components/Comment.vue"; 6 | import DateFilter from "../../src/common/date.filter"; 7 | 8 | const localVue = createLocalVue(); 9 | localVue.filter("date", DateFilter); 10 | localVue.use(Vuex); 11 | localVue.use(VueRouter); 12 | 13 | describe("Comment", () => { 14 | it("should render correct contents", () => { 15 | const router = new VueRouter({ 16 | routes: [ 17 | { 18 | name: "profile", 19 | path: "/profile", 20 | component: null 21 | } 22 | ] 23 | }); 24 | let store = new Vuex.Store({ 25 | getters: { 26 | currentUser: () => ({ 27 | username: "user-3518518" 28 | }) 29 | } 30 | }); 31 | 32 | const wrapper = mount(Comment, { 33 | localVue, 34 | store, 35 | router, 36 | propsData: { 37 | slug: "super-cool-comment-slug-1245781274", 38 | comment: { 39 | body: "body of comment", 40 | author: { 41 | image: "https://vuejs.org/images/logo.png", 42 | username: "user-3518518" 43 | }, 44 | createdAt: "", 45 | id: 1245781274 46 | } 47 | } 48 | }); 49 | expect(wrapper.isVueInstance()).toBeTruthy(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/unit/store/__snapshots__/article.module.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Vuex Article Module should return the data of the api call when calling the function 1`] = ` 4 | Object { 5 | "article": Object { 6 | "author": Object {}, 7 | "body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 8 | "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 9 | "tagList": Array [ 10 | "lorem", 11 | "ipsum", 12 | "javascript", 13 | "vue", 14 | ], 15 | "title": "Lorem ipsum dolor sit amet", 16 | }, 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /tests/unit/store/article.module.spec.js: -------------------------------------------------------------------------------- 1 | import { actions } from "../../../src/store/article.module"; 2 | import { 3 | FETCH_ARTICLE, 4 | FETCH_COMMENTS, 5 | COMMENT_CREATE, 6 | COMMENT_DESTROY, 7 | FAVORITE_ADD, 8 | FAVORITE_REMOVE 9 | } from "../../../src/store/actions.type"; 10 | 11 | jest.mock("vue", () => { 12 | return { 13 | axios: { 14 | get: jest.fn().mockImplementation(async articleSlug => { 15 | if (articleSlug.includes("8371b051-cffc-4ff0-887c-2c477615a28e")) { 16 | return { 17 | data: { 18 | article: { 19 | author: {}, 20 | title: "Lorem ipsum dolor sit amet", 21 | description: 22 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 23 | body: 24 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 25 | tagList: ["lorem", "ipsum", "javascript", "vue"] 26 | } 27 | } 28 | }; 29 | } 30 | if (articleSlug.includes("f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c")) { 31 | return { 32 | data: { 33 | comments: [ 34 | { 35 | id: 1, 36 | createdAt: "2018-12-01T15:43:41.235Z", 37 | updatedAt: "2018-12-01T15:43:41.235Z", 38 | body: "Lorem ipsum dolor sit amet.", 39 | author: { 40 | username: "dccf649a-5e7b-4040-b8c3-ecf74598eba2", 41 | bio: null, 42 | image: "https://via.placeholder.com/350x150", 43 | following: false 44 | } 45 | }, 46 | { 47 | id: 2, 48 | createdAt: "2018-12-01T15:43:39.077Z", 49 | updatedAt: "2018-12-01T15:43:39.077Z", 50 | body: 51 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse aliquet.", 52 | author: { 53 | username: "8568a50a-9656-4d55-a023-632029513a2d", 54 | bio: null, 55 | image: "https://via.placeholder.com/350x150", 56 | following: false 57 | } 58 | } 59 | ] 60 | } 61 | }; 62 | } 63 | throw new Error("Article not existing"); 64 | }), 65 | post: jest.fn().mockImplementation(async articleSlug => { 66 | if (articleSlug.includes("582e1e46-6b8b-4f4d-8848-f07b57e015a0")) { 67 | return null; 68 | } 69 | if (articleSlug.includes("5611ee1b-0b95-417f-a917-86687176a627")) { 70 | return { 71 | data: { 72 | article: { 73 | author: {}, 74 | title: "Lorem ipsum dolor sit amet", 75 | description: 76 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 77 | body: 78 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 79 | tagList: ["lorem", "ipsum", "javascript", "vue"] 80 | } 81 | } 82 | }; 83 | } 84 | throw new Error("Article not existing"); 85 | }), 86 | delete: jest.fn().mockImplementation(async articleSlug => { 87 | if (articleSlug.includes("657a6075-d269-4aec-83fa-b14f579a3e78")) { 88 | return null; 89 | } 90 | if (articleSlug.includes("480fdaf8-027c-43b1-8952-8403f90dcdab")) { 91 | return { 92 | data: { 93 | article: { 94 | author: {}, 95 | title: "Lorem ipsum dolor sit amet", 96 | description: 97 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 98 | body: 99 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 100 | tagList: ["lorem", "ipsum", "javascript", "vue"] 101 | } 102 | } 103 | }; 104 | } 105 | throw new Error("Article not existing"); 106 | }) 107 | } 108 | }; 109 | }); 110 | 111 | describe("Vuex Article Module", () => { 112 | it("should commit the previous article if it is given", async () => { 113 | const commitFunction = jest.fn(); 114 | const context = { commit: commitFunction }; 115 | const articleSlug = "8371b051-cffc-4ff0-887c-2c477615a28e"; 116 | const prevArticle = { 117 | author: {}, 118 | title: "Aye up, she's a reight bobby dazzler", 119 | description: 120 | "Yer flummoxed. Fair t' middlin, this is. Off f'r a sup down t'pub, to'neet. Ee bye ecky thump!", 121 | body: 122 | "Tha's better bi careful, lass - yer on a Scarborough warning! Tha meks a better door than a winder. Do I 'eckers like, You're in luck m'boy! Am proper knackered, aye I am that is I say.", 123 | tagList: ["aye", "ipsum", "javascript", "vue"] 124 | }; 125 | await actions[FETCH_ARTICLE](context, articleSlug, prevArticle); 126 | expect(commitFunction.mock.calls[0][0]).toBe("setArticle"); 127 | expect(commitFunction.mock.calls[0][1]).toBe(prevArticle); 128 | }); 129 | 130 | it("should return the data of the api call when calling the function", async () => { 131 | const context = { commit: () => {} }; 132 | const articleSlug = "8371b051-cffc-4ff0-887c-2c477615a28e"; 133 | const prevArticle = undefined; 134 | const actionCall = await actions[FETCH_ARTICLE]( 135 | context, 136 | articleSlug, 137 | prevArticle 138 | ); 139 | expect(actionCall).toMatchSnapshot(); 140 | }); 141 | 142 | it("should commit the right name when fetching comments for an existing article", async () => { 143 | const commitFunction = jest.fn(); 144 | const context = { commit: commitFunction }; 145 | const articleSlug = "f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c"; 146 | await actions[FETCH_COMMENTS](context, articleSlug); 147 | expect(commitFunction.mock.calls[0][0]).toBe("setComments"); 148 | }); 149 | 150 | it("should commit the exact size of comments", async () => { 151 | const commitFunction = jest.fn(); 152 | const context = { commit: commitFunction }; 153 | const articleSlug = "f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c"; 154 | await actions[FETCH_COMMENTS](context, articleSlug); 155 | expect(commitFunction.mock.calls[0][1]).toHaveLength(2); 156 | }); 157 | 158 | it("should return the comments from the fetch comments action", async () => { 159 | const context = { commit: () => {} }; 160 | const articleSlug = "f986b3d6-95c2-4c4f-a6b9-fbbf79d8cb0c"; 161 | const comments = await actions[FETCH_COMMENTS](context, articleSlug); 162 | expect(comments).toHaveLength(2); 163 | }); 164 | 165 | it("should dispatch a fetching comment action after successfully creating a comment", async () => { 166 | const dispatchFunction = jest.fn(); 167 | const context = { dispatch: dispatchFunction }; 168 | const payload = { 169 | slug: "582e1e46-6b8b-4f4d-8848-f07b57e015a0", 170 | comment: "Lorem Ipsum" 171 | }; 172 | await actions[COMMENT_CREATE](context, payload); 173 | expect(dispatchFunction).toHaveBeenLastCalledWith( 174 | "fetchComments", 175 | "582e1e46-6b8b-4f4d-8848-f07b57e015a0" 176 | ); 177 | }); 178 | 179 | it("should dispatch a fetching comment action after successfully deleting a comment", async () => { 180 | const dispatchFunction = jest.fn(); 181 | const context = { dispatch: dispatchFunction }; 182 | const payload = { 183 | slug: "657a6075-d269-4aec-83fa-b14f579a3e78", 184 | commentId: 1 185 | }; 186 | await actions[COMMENT_DESTROY](context, payload); 187 | expect(dispatchFunction).toHaveBeenLastCalledWith( 188 | "fetchComments", 189 | "657a6075-d269-4aec-83fa-b14f579a3e78" 190 | ); 191 | }); 192 | 193 | it("should commit updating the article in the list action favorize an article", async () => { 194 | const commitFunction = jest.fn(); 195 | const context = { commit: commitFunction }; 196 | const payload = "5611ee1b-0b95-417f-a917-86687176a627"; 197 | await actions[FAVORITE_ADD](context, payload); 198 | expect(commitFunction.mock.calls[0][0]).toBe("updateArticleInList"); 199 | expect(commitFunction.mock.calls[0][1]).toEqual({ 200 | author: {}, 201 | title: "Lorem ipsum dolor sit amet", 202 | description: 203 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 204 | body: 205 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 206 | tagList: ["lorem", "ipsum", "javascript", "vue"] 207 | }); 208 | }); 209 | 210 | it("should commit setting the article", async () => { 211 | const commitFunction = jest.fn(); 212 | const context = { commit: commitFunction }; 213 | const payload = "5611ee1b-0b95-417f-a917-86687176a627"; 214 | await actions[FAVORITE_ADD](context, payload); 215 | expect(commitFunction.mock.calls[1][0]).toBe("setArticle"); 216 | expect(commitFunction.mock.calls[1][1]).toEqual({ 217 | author: {}, 218 | title: "Lorem ipsum dolor sit amet", 219 | description: 220 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 221 | body: 222 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 223 | tagList: ["lorem", "ipsum", "javascript", "vue"] 224 | }); 225 | }); 226 | 227 | it("should commit updating the article in the list action favorize an article", async () => { 228 | const commitFunction = jest.fn(); 229 | const context = { commit: commitFunction }; 230 | const payload = "480fdaf8-027c-43b1-8952-8403f90dcdab"; 231 | await actions[FAVORITE_REMOVE](context, payload); 232 | expect(commitFunction.mock.calls[0][0]).toBe("updateArticleInList"); 233 | expect(commitFunction.mock.calls[0][1]).toEqual({ 234 | author: {}, 235 | title: "Lorem ipsum dolor sit amet", 236 | description: 237 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 238 | body: 239 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 240 | tagList: ["lorem", "ipsum", "javascript", "vue"] 241 | }); 242 | }); 243 | 244 | it("should commit setting the article", async () => { 245 | const commitFunction = jest.fn(); 246 | const context = { commit: commitFunction }; 247 | const payload = "480fdaf8-027c-43b1-8952-8403f90dcdab"; 248 | await actions[FAVORITE_REMOVE](context, payload); 249 | expect(commitFunction.mock.calls[1][0]).toBe("setArticle"); 250 | expect(commitFunction.mock.calls[1][1]).toEqual({ 251 | author: {}, 252 | title: "Lorem ipsum dolor sit amet", 253 | description: 254 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sed cursus nisl. Morbi pulvinar nisl urna, tincidunt mattis tortor sollicitudin eget. Nulla viverra justo quis.", 255 | body: 256 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dictum efficitur justo, nec aliquam quam rutrum in. Pellentesque vulputate augue quis vulputate finibus. Phasellus auctor semper sapien sit amet interdum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas placerat auctor metus. Integer blandit lacinia volutpat.", 257 | tagList: ["lorem", "ipsum", "javascript", "vue"] 258 | }); 259 | }); 260 | }); 261 | --------------------------------------------------------------------------------