first commit
This commit is contained in:
2
ui/.browserslistrc
Normal file
2
ui/.browserslistrc
Normal file
@@ -0,0 +1,2 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
2
ui/.dockerignore
Normal file
2
ui/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
2
ui/.eslintignore
Normal file
2
ui/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
/tests/unit/coverage/
|
||||
81
ui/.eslintrc.js
Normal file
81
ui/.eslintrc.js
Normal file
@@ -0,0 +1,81 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
},
|
||||
extends: [
|
||||
// https://github.com/vuejs/eslint-plugin-vue#bulb-rules
|
||||
'plugin:vue/recommended',
|
||||
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
|
||||
'standard',
|
||||
// https://github.com/prettier/eslint-config-prettier
|
||||
'prettier',
|
||||
'prettier/standard',
|
||||
'prettier/vue',
|
||||
],
|
||||
rules: {
|
||||
// Only allow debugger in development
|
||||
'no-debugger': process.env.PRE_COMMIT ? 'error' : 'off',
|
||||
// Only allow `console.log` in development
|
||||
'no-console': process.env.PRE_COMMIT
|
||||
? ['error', { allow: ['warn', 'error'] }]
|
||||
: 'off',
|
||||
'import/no-relative-parent-imports': 'error',
|
||||
'import/order': 'error',
|
||||
'vue/array-bracket-spacing': 'error',
|
||||
'vue/arrow-spacing': 'error',
|
||||
'vue/block-spacing': 'error',
|
||||
'vue/brace-style': 'error',
|
||||
'vue/camelcase': 'error',
|
||||
'vue/comma-dangle': ['error', 'always-multiline'],
|
||||
'vue/component-name-in-template-casing': 'error',
|
||||
'vue/dot-location': ['error', 'property'],
|
||||
'vue/eqeqeq': 'error',
|
||||
'vue/key-spacing': 'error',
|
||||
'vue/keyword-spacing': 'error',
|
||||
'vue/no-boolean-default': ['error', 'default-false'],
|
||||
'vue/no-deprecated-scope-attribute': 'error',
|
||||
'vue/no-empty-pattern': 'error',
|
||||
'vue/object-curly-spacing': ['error', 'always'],
|
||||
'vue/padding-line-between-blocks': 'error',
|
||||
'vue/space-infix-ops': 'error',
|
||||
'vue/space-unary-ops': 'error',
|
||||
'vue/v-on-function-call': 'error',
|
||||
'vue/v-slot-style': [
|
||||
'error',
|
||||
{
|
||||
atComponent: 'v-slot',
|
||||
default: 'v-slot',
|
||||
named: 'longform',
|
||||
},
|
||||
],
|
||||
'vue/valid-v-slot': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['src/**/*', 'tests/unit/**/*', 'tests/e2e/**/*'],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module',
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.unit.js'],
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
sourceType: 'module',
|
||||
},
|
||||
env: { jest: true },
|
||||
globals: {
|
||||
mount: false,
|
||||
shallowMount: false,
|
||||
shallowMountView: false,
|
||||
createComponentMocks: false,
|
||||
createModuleStore: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
44
ui/.gitattributes
vendored
Normal file
44
ui/.gitattributes
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Fix end-of-lines in Git versions older than 2.10
|
||||
# https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248
|
||||
* text=auto eol=lf
|
||||
|
||||
# ===
|
||||
# Binary Files (don't diff, don't fix line endings)
|
||||
# ===
|
||||
|
||||
# Images
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.tiff binary
|
||||
|
||||
# Fonts
|
||||
*.oft binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# Videos
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.ogg binary
|
||||
*.mpg binary
|
||||
*.3gp binary
|
||||
*.avi binary
|
||||
*.wmv binary
|
||||
*.flv binary
|
||||
*.asf binary
|
||||
|
||||
# Audio
|
||||
*.mp3 binary
|
||||
*.wav binary
|
||||
*.flac binary
|
||||
|
||||
# Compressed
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
31
ui/.gitignore
vendored
Normal file
31
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# OS Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Dev/Build Artifacts
|
||||
/dist/
|
||||
/tests/e2e/videos/
|
||||
/tests/e2e/screenshots/
|
||||
/tests/unit/coverage/
|
||||
jsconfig.json
|
||||
|
||||
# Local Env Files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Unconfigured Editors
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
17
ui/.markdownlint.yml
Normal file
17
ui/.markdownlint.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
default: true
|
||||
|
||||
# ===
|
||||
# Rule customizations for markdownlint go here
|
||||
# https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md
|
||||
# ===
|
||||
|
||||
# Disable line length restrictions, because editor soft-wrapping is being
|
||||
# used instead.
|
||||
line-length: false
|
||||
|
||||
# ===
|
||||
# Prettier overrides
|
||||
# ===
|
||||
|
||||
no-multiple-blanks: false
|
||||
list-marker-space: false
|
||||
5
ui/.postcssrc.js
Normal file
5
ui/.postcssrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
3
ui/.prettierignore
Normal file
3
ui/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
/node_modules/**
|
||||
/dist/**
|
||||
/tests/unit/coverage/**
|
||||
17
ui/.prettierrc.js
Normal file
17
ui/.prettierrc.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
arrowParens: 'always',
|
||||
bracketSpacing: true,
|
||||
endOfLine: 'lf',
|
||||
htmlWhitespaceSensitivity: 'strict',
|
||||
jsxBracketSameLine: false,
|
||||
jsxSingleQuote: true,
|
||||
printWidth: 150,
|
||||
proseWrap: 'never',
|
||||
quoteProps: 'as-needed',
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'es5',
|
||||
useTabs: false,
|
||||
vueIndentScriptAndStyle: false,
|
||||
}
|
||||
30
ui/.vscode/_components.code-snippets
vendored
Normal file
30
ui/.vscode/_components.code-snippets
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"BaseButton": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseButton",
|
||||
"body": ["<BaseButton>", "\t${3}", "</BaseButton>"],
|
||||
"description": "<BaseButton>"
|
||||
},
|
||||
"BaseIcon": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseIcon",
|
||||
"body": ["<BaseIcon name=\"${1}\">", "\t${2}", "</BaseIcon>"],
|
||||
"description": "<BaseIcon>"
|
||||
},
|
||||
"BaseInputText": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseInputText",
|
||||
"body": ["<BaseInputText ${1}/>"],
|
||||
"description": "<BaseInputText>"
|
||||
},
|
||||
"BaseLink": {
|
||||
"scope": "vue-html",
|
||||
"prefix": "BaseLink",
|
||||
"body": [
|
||||
"<BaseLink ${1|name,:to,href|}=\"${2:route}\">",
|
||||
"\t${3}",
|
||||
"</BaseLink>"
|
||||
],
|
||||
"description": "<BaseLink>"
|
||||
}
|
||||
}
|
||||
26
ui/.vscode/_sfc-blocks.code-snippets
vendored
Normal file
26
ui/.vscode/_sfc-blocks.code-snippets
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"script": {
|
||||
"scope": "vue",
|
||||
"prefix": "script",
|
||||
"body": ["<script>", "export default {", "\t${0}", "}", "</script>"],
|
||||
"description": "<script>"
|
||||
},
|
||||
"template": {
|
||||
"scope": "vue",
|
||||
"prefix": "template",
|
||||
"body": ["<template>", "\t${0}", "</template>"],
|
||||
"description": "<template>"
|
||||
},
|
||||
"style": {
|
||||
"scope": "vue",
|
||||
"prefix": "style",
|
||||
"body": [
|
||||
"<style lang=\"scss\" module>",
|
||||
"@import '@design';",
|
||||
"",
|
||||
"${0}",
|
||||
"</style>"
|
||||
],
|
||||
"description": "<style lang=\"scss\" module>"
|
||||
}
|
||||
}
|
||||
37
ui/.vscode/extensions.json
vendored
Normal file
37
ui/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
// Syntax highlighting and more for .vue files
|
||||
// https://github.com/vuejs/vetur
|
||||
"octref.vetur",
|
||||
|
||||
// Peek and go-to-definition for .vue files
|
||||
// https://github.com/fuzinato/vscode-vue-peek
|
||||
"dariofuzinato.vue-peek",
|
||||
|
||||
// Lint-on-save with ESLint
|
||||
// https://github.com/microsoft/vscode-eslint
|
||||
"dbaeumer.vscode-eslint",
|
||||
|
||||
// Lint-on-save with Stylelint
|
||||
// https://github.com/stylelint/vscode-stylelint
|
||||
"stylelint.vscode-stylelint",
|
||||
|
||||
// Lint-on-save markdown in README files
|
||||
// https://github.com/DavidAnson/vscode-markdownlint
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
|
||||
// Format-on-save with Prettier
|
||||
// https://github.com/prettier/prettier-vscode
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
// SCSS intellisense
|
||||
// https://github.com/mrmlnc/vscode-scss
|
||||
"mrmlnc.vscode-scss",
|
||||
|
||||
// Test `.unit.js` files on save with Jest
|
||||
// https://github.com/jest-community/vscode-jest
|
||||
"Orta.vscode-jest"
|
||||
]
|
||||
}
|
||||
93
ui/.vscode/settings.json
vendored
Normal file
93
ui/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
// ===
|
||||
// Spacing
|
||||
// ===
|
||||
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.trimAutoWhitespace": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.eol": "\n",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
|
||||
// ===
|
||||
// Files
|
||||
// ===
|
||||
|
||||
"files.exclude": {
|
||||
"**/*.log": true,
|
||||
"**/*.log*": true,
|
||||
"**/dist": true,
|
||||
"**/coverage": true
|
||||
},
|
||||
"files.associations": {
|
||||
".markdownlintrc": "jsonc"
|
||||
},
|
||||
|
||||
// ===
|
||||
// Event Triggers
|
||||
// ===
|
||||
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true,
|
||||
"source.fixAll.stylelint": true,
|
||||
"source.fixAll.markdownlint": true
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"vue",
|
||||
"vue-html",
|
||||
"html"
|
||||
],
|
||||
"vetur.format.enable": false,
|
||||
"vetur.completion.scaffoldSnippetSources": {
|
||||
"user": "🗒️",
|
||||
"workspace": "💼",
|
||||
"vetur": ""
|
||||
},
|
||||
"prettier.disableLanguages": [],
|
||||
|
||||
// ===
|
||||
// HTML
|
||||
// ===
|
||||
|
||||
"html.format.enable": false,
|
||||
"vetur.validation.template": false,
|
||||
"emmet.triggerExpansionOnTab": true,
|
||||
"emmet.includeLanguages": {
|
||||
"vue-html": "html"
|
||||
},
|
||||
"vetur.completion.tagCasing": "initial",
|
||||
|
||||
// ===
|
||||
// JS(ON)
|
||||
// ===
|
||||
|
||||
"jest.autoEnable": false,
|
||||
"jest.enableCodeLens": false,
|
||||
"javascript.format.enable": false,
|
||||
"json.format.enable": false,
|
||||
"vetur.validation.script": false,
|
||||
|
||||
// ===
|
||||
// CSS
|
||||
// ===
|
||||
|
||||
"stylelint.enable": true,
|
||||
"css.validate": false,
|
||||
"scss.validate": false,
|
||||
"vetur.validation.style": false,
|
||||
|
||||
// ===
|
||||
// MARKDOWN
|
||||
// ===
|
||||
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "wordWrapColumn",
|
||||
"editor.wordWrapColumn": 80
|
||||
}
|
||||
}
|
||||
21
ui/.vuepress/config.js
Normal file
21
ui/.vuepress/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const appConfig = require('../src/app.config')
|
||||
|
||||
module.exports = {
|
||||
title: appConfig.title + ' Docs',
|
||||
description: appConfig.description,
|
||||
themeConfig: {
|
||||
sidebar: [
|
||||
['/', 'Introduction'],
|
||||
'/docs/development',
|
||||
'/docs/architecture',
|
||||
'/docs/tech',
|
||||
'/docs/routing',
|
||||
'/docs/state',
|
||||
'/docs/tests',
|
||||
'/docs/linting',
|
||||
'/docs/editors',
|
||||
'/docs/production',
|
||||
'/docs/troubleshooting',
|
||||
],
|
||||
},
|
||||
}
|
||||
12
ui/Dockerfile
Normal file
12
ui/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:latest as build-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# production stage
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
191
ui/README.md
Normal file
191
ui/README.md
Normal file
@@ -0,0 +1,191 @@
|
||||
[![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] [![Issues][issues-shield]][issues-url] [![MIT License][license-shield]][license-url] [![LinkedIn][linkedin-shield]][linkedin-url]
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<p align="center">
|
||||
<!-- <a href="https://github.com/akhilrex/hammond">
|
||||
<img src="images/logo.png" alt="Logo" width="80" height="80">
|
||||
</a> -->
|
||||
|
||||
<h1 align="center" style="margin-bottom:0">Hammond</h1>
|
||||
<p align="center">Current Version - 2021.05.07</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted vehicle expense tracking system with support for multiple users.
|
||||
<br />
|
||||
<a href="https://github.com/akhilrex/hammond"><strong>Explore the docs »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
<!-- <a href="https://github.com/akhilrex/hammond">View Demo</a>
|
||||
· -->
|
||||
<a href="https://github.com/akhilrex/hammond/issues">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/akhilrex/hammond/issues">Request Feature</a>
|
||||
·
|
||||
<a href="Screenshots.md">Screenshots</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [About the Project](#about-the-project)
|
||||
- [Motivation](#motivation)
|
||||
- [Built With](#built-with)
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [License](#license)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Contact](#contact)
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
|
||||
## About The Project
|
||||
|
||||
Hammond is a self hosted vehicle management system to track fuel and other expenses related to all of your vehicles. It supports multiple users sharing multiple vehicles. It is the logical successor to Clarkson which has not been updated for quite some time now.
|
||||
|
||||
_Developers Note: This project is under active development which means I release new updates very frequently. It is recommended that you use something like [watchtower](https://github.com/containrrr/watchtower) which will automatically update your containers whenever I release a new version or periodically rebuild the container with the latest image manually._
|
||||
|
||||
### Motivation and Developer Notes
|
||||
|
||||
I was looking for a fuel tracking system and stumbled upon Clarkson. Although it did most of what I needed it has not been updated for quite a lot of time. Since I had some bandwidth available as my previous open source project [Podgrab](http://github.com/akhilrex/podgrab) had become quite stable now, my first thought was to contribute to the Clarkson project only. I soon realized that the architecture that Clarkson had used was not really be that extensible now and would warrant a complete rewrite only. So I decided to build Hammond - The successor to Clarkson.
|
||||
|
||||
The current version of Hammond is written using GO for backend and Vuejs for the front end. Originally I had thought of using the same tech stack for both frontend and the backend so that it became easier for users and other developers to use, deploy and contribute. Which is why the first version of Hammond has a NestJS backend complete with all the bells and whistles (GraphQL, Prisma and what nots). But I eventually decided to rebuild the backend in GO just to keep the container size small. No matter how much you can optimize the sheer size of the node_modules will always add bulk to your containers. I host all my tools on my Raspberry Pi. It only makes sense to keep the container size as small as possible.
|
||||
|
||||
Also I had initially thought of a 2 container approach (1 for backend and 1 for the frontend) so that they can be independently maintained and updated. I eventually decided against this idea for the sake of simplicity. Although it is safe to assume that most self-hosters are fairly tech capable it still is much better to have a single container that you can fire and forget.
|
||||
|
||||
![Product Name Screen Shot][product-screenshot] [More Screenshots](Screenshots.md)
|
||||
|
||||
### Built With
|
||||
|
||||
- [Go](https://golang.org/)
|
||||
- [Go-Gin](https://github.com/gin-gonic/gin)
|
||||
- [GORM](https://github.com/go-gorm/gorm)
|
||||
- [SQLite](https://www.sqlite.org/index.html)
|
||||
- [VueJS](https://vuejs.org/)
|
||||
- [Vuex](https://vuex.vuejs.org/)
|
||||
- [Buefy](https://buefy.org/)
|
||||
|
||||
### Features
|
||||
|
||||
- Migrate data from Clarkson
|
||||
- Add/Manage multiple vehicles
|
||||
- Add/Manage multiple users
|
||||
- Track fuel and other expenses
|
||||
- Share vehicles across multiple users
|
||||
- Quick Entries (take a photo of a receipt or pump screen to make entry later)
|
||||
- Vehicle level and overall reporting
|
||||
|
||||
## Installation
|
||||
|
||||
The easiest way to run Hammond is to run it as a docker container.
|
||||
|
||||
### Using Docker
|
||||
|
||||
Simple setup without mounted volumes (for testing and evaluation)
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 --name=hammond akhilrex/hammond
|
||||
```
|
||||
|
||||
Binding local volumes to the container
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:8080 --name=hammond -v "/host/path/to/assets:/assets" -v "/host/path/to/config:/config" akhilrex/hammond
|
||||
```
|
||||
|
||||
### Using Docker-Compose
|
||||
|
||||
Modify the docker compose file provided [here](https://github.com/akhilrex/hammond/blob/master/docker-compose.yml) to update the volume and port binding and run the following command
|
||||
|
||||
```yaml
|
||||
version: '2.1'
|
||||
services:
|
||||
hammond:
|
||||
image: akhilrex/hammond
|
||||
container_name: hammond
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/data:/assets
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
<!-- ### Build from Source / Ubuntu Installation
|
||||
|
||||
Although personally I feel that using the docker container is the best way of using and enjoying something like hammond, a lot of people in the community are still not comfortable with using Docker and wanted to host it natively on their Linux servers. Follow the link below to get a guide on how to build hammond from source.
|
||||
|
||||
[Build from source / Ubuntu Guide](docs/ubuntu-install.md) -->
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Name | Description | Default |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| PORT | Change the internal port of the application. If you change this you might have to change your docker configuration as well | (empty) |
|
||||
|
||||
### Setup
|
||||
|
||||
When you open Hammond for the first time after a fresh install, you will be presented with the option to either import data from an existing Clarkson instance or setup a fresh instance.
|
||||
|
||||
#### Migration from Clarkson
|
||||
|
||||
You will have to ensure that the Clarkson database is accessible from the Hammond deployment. In case it is not directly possible, you can always take a backup of the Clarkson database and host it somewhere accessible to Hammond using a temporary container. If the access problem is sorted, you will have to enter the connection string the Clarkson database in the following format.
|
||||
|
||||
```
|
||||
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
```
|
||||
|
||||
You can check the connectivity from the screen as well.
|
||||
|
||||
Note: All the users migrated from Clarkson will have their passwords changed to `hammond`
|
||||
|
||||
#### Fresh setup
|
||||
|
||||
You will have to provide your name, email and password so that an admin user can be created for you.
|
||||
|
||||
Once done you will be taken to the login page.
|
||||
|
||||
Go through the settings page once and change relevant settings before you start adding vehicles and expenses.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the GPL-3.0 License. See `LICENSE` for more information.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] More reports
|
||||
- [ ] Vehicle specific reminders (servicing etc)
|
||||
- [ ] Native installer for Windows/Linux/MacOS
|
||||
|
||||
<!-- CONTACT -->
|
||||
|
||||
## Contact
|
||||
|
||||
Akhil Gupta - [@akhilrex](https://twitter.com/akhilrex)
|
||||
|
||||
Project Link: [https://github.com/akhilrex/hammond](https://github.com/akhilrex/hammond)
|
||||
|
||||
<a href="https://www.buymeacoffee.com/akhilrex" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="width: 217px !important;height: 60px !important;" ></a>
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
|
||||
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/akhilrex/hammond.svg?style=flat-square
|
||||
[contributors-url]: https://github.com/akhilrex/hammond/graphs/contributors
|
||||
[forks-shield]: https://img.shields.io/github/forks/akhilrex/hammond.svg?style=flat-square
|
||||
[forks-url]: https://github.com/akhilrex/hammond/network/members
|
||||
[stars-shield]: https://img.shields.io/github/stars/akhilrex/hammond.svg?style=flat-square
|
||||
[stars-url]: https://github.com/akhilrex/hammond/stargazers
|
||||
[issues-shield]: https://img.shields.io/github/issues/akhilrex/hammond.svg?style=flat-square
|
||||
[issues-url]: https://github.com/akhilrex/hammond/issues
|
||||
[license-shield]: https://img.shields.io/github/license/akhilrex/hammond.svg?style=flat-square
|
||||
[license-url]: https://github.com/akhilrex/hammond/blob/master/LICENSE.txt
|
||||
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
|
||||
[linkedin-url]: https://linkedin.com/in/akhilrex
|
||||
[product-screenshot]: images/screenshot.jpg
|
||||
74
ui/aliases.config.js
Normal file
74
ui/aliases.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const prettier = require('prettier')
|
||||
|
||||
const aliases = {
|
||||
'@': '.',
|
||||
'@src': 'src',
|
||||
'@router': 'src/router',
|
||||
'@views': 'src/router/views',
|
||||
'@layouts': 'src/router/layouts',
|
||||
'@components': 'src/components',
|
||||
'@assets': 'src/assets',
|
||||
'@utils': 'src/utils',
|
||||
'@state': 'src/state',
|
||||
'@design': 'src/design/index.scss',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
webpack: {},
|
||||
jest: {},
|
||||
jsconfig: {},
|
||||
}
|
||||
|
||||
for (const alias in aliases) {
|
||||
const aliasTo = aliases[alias]
|
||||
module.exports.webpack[alias] = resolveSrc(aliasTo)
|
||||
const aliasHasExtension = /\.\w+$/.test(aliasTo)
|
||||
module.exports.jest[`^${alias}$`] = aliasHasExtension
|
||||
? `<rootDir>/${aliasTo}`
|
||||
: `<rootDir>/${aliasTo}/index.js`
|
||||
module.exports.jest[`^${alias}/(.*)$`] = `<rootDir>/${aliasTo}/$1`
|
||||
module.exports.jsconfig[alias + '/*'] = [aliasTo + '/*']
|
||||
module.exports.jsconfig[alias] = aliasTo.includes('/index.')
|
||||
? [aliasTo]
|
||||
: [
|
||||
aliasTo + '/index.js',
|
||||
aliasTo + '/index.json',
|
||||
aliasTo + '/index.vue',
|
||||
aliasTo + '/index.scss',
|
||||
aliasTo + '/index.css',
|
||||
]
|
||||
}
|
||||
|
||||
const jsconfigTemplate = require('./jsconfig.template') || {}
|
||||
const jsconfigPath = path.resolve(__dirname, 'jsconfig.json')
|
||||
|
||||
fs.writeFile(
|
||||
jsconfigPath,
|
||||
prettier.format(
|
||||
JSON.stringify({
|
||||
...jsconfigTemplate,
|
||||
compilerOptions: {
|
||||
...(jsconfigTemplate.compilerOptions || {}),
|
||||
paths: module.exports.jsconfig,
|
||||
},
|
||||
}),
|
||||
{
|
||||
...require('./.prettierrc'),
|
||||
parser: 'json',
|
||||
}
|
||||
),
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.error(
|
||||
'Error while creating jsconfig.json from aliases.config.js.'
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function resolveSrc(_path) {
|
||||
return path.resolve(__dirname, _path)
|
||||
}
|
||||
4
ui/babel.config.js
Normal file
4
ui/babel.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
// https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
|
||||
presets: ['@vue/cli-plugin-babel/preset'],
|
||||
}
|
||||
3
ui/cypress.json
Normal file
3
ui/cypress.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pluginsFile": "tests/e2e/plugins/index.js"
|
||||
}
|
||||
16
ui/docker-compose.yml
Normal file
16
ui/docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: '3.7'
|
||||
|
||||
volumes:
|
||||
dependencies:
|
||||
|
||||
services:
|
||||
dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker-dev.dockerfile
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- .:/app
|
||||
- dependencies:/app/node_modules
|
||||
tty: true
|
||||
15
ui/docker-dev.dockerfile
Normal file
15
ui/docker-dev.dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# https://github.com/cypress-io/cypress-docker-images/tree/master/base
|
||||
FROM cypress/base:10.16.0
|
||||
|
||||
# Make the `app` folder the current working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency-related files
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
# Install project dependencies
|
||||
RUN yarn install
|
||||
|
||||
# Expose ports 8080, which the dev server will be bound to
|
||||
EXPOSE 8080
|
||||
93
ui/docs/architecture.md
Normal file
93
ui/docs/architecture.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Architecture
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [`.circleci`](#circleci)
|
||||
- [`.vscode`](#vscode)
|
||||
- [`.vuepress`](#vuepress)
|
||||
- [`docs`](#docs)
|
||||
- [`generators`](#generators)
|
||||
- [`public`](#public)
|
||||
- [`index.html`](#indexhtml)
|
||||
- [`src`](#src)
|
||||
- [`assets`](#assets)
|
||||
- [`components`](#components)
|
||||
- [`design`](#design)
|
||||
- [`router`](#router)
|
||||
- [`state`](#state)
|
||||
- [`utils`](#utils)
|
||||
- [`app.config.json`](#appconfigjson)
|
||||
- [`app.vue`](#appvue)
|
||||
- [`main.js`](#mainjs)
|
||||
- [`tests`](#tests)
|
||||
|
||||
## `.circleci`
|
||||
|
||||
Configuration for continuous integration with [Circle CI](https://circleci.com/). See [the production doc](production.md#from-circle-ci) for more.
|
||||
|
||||
## `.vscode`
|
||||
|
||||
Settings and extensions specific to this project, for Visual Studio Code. See [the editors doc](editors.md#visual-studio-code) for more.
|
||||
|
||||
## `.vuepress`
|
||||
|
||||
[VuePress](https://vuepress.vuejs.org/) configuration for docs static site generation.
|
||||
|
||||
## `docs`
|
||||
|
||||
You found me! :wink:
|
||||
|
||||
## `generators`
|
||||
|
||||
Generator templates to speed up development. See [the development doc](development.md#generators) for more.
|
||||
|
||||
## `public`
|
||||
|
||||
Where you'll keep any static assets, to be added to the `dist` directory without processing from our build system.
|
||||
|
||||
### `index.html`
|
||||
|
||||
This one file actually _does_ get processed by our build system, allowing us to inject some information from Webpack with [EJS](http://ejs.co/), such as the title, then add our JS and CSS.
|
||||
|
||||
## `src`
|
||||
|
||||
Where we keep all our source files.
|
||||
|
||||
### `assets`
|
||||
|
||||
This project manages assets via Vue CLI. Learn more about [its asset handling here](https://cli.vuejs.org/guide/html-and-static-assets.html).
|
||||
|
||||
### `components`
|
||||
|
||||
Where most of the components in our app will live, including our [global base components](development.md#base-components).
|
||||
|
||||
### `design`
|
||||
|
||||
Where we keep our [design variables and tooling](tech.md#design-variables-and-tooling).
|
||||
|
||||
### `router`
|
||||
|
||||
Where the router, routes, and any routing-related components live. See [the routing doc](routing.md) for more.
|
||||
|
||||
### `state`
|
||||
|
||||
Where all our global state management lives. See [the state management doc](state.md) for more.
|
||||
|
||||
### `utils`
|
||||
|
||||
These are utility functions you may want to share between many files in your application. They will always be pure and never have side effects, meaning if you provide a function the same arguments, it will always return the same result. These should also never directly affect the DOM or interface with our Vuex state.
|
||||
|
||||
### `app.config.json`
|
||||
|
||||
Contains app-specific metadata.
|
||||
|
||||
### `app.vue`
|
||||
|
||||
The root Vue component that simply delegates to the router view. This is typically the only component to contain global CSS.
|
||||
|
||||
### `main.js`
|
||||
|
||||
The entry point to our app, were we create our Vue instance and mount it to the DOM.
|
||||
|
||||
## `tests`
|
||||
|
||||
Where all our tests go. See [the tests doc](tests.md) for more.
|
||||
146
ui/docs/development.md
Normal file
146
ui/docs/development.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Setup and development
|
||||
|
||||
- [Setup and development](#setup-and-development)
|
||||
- [First-time setup](#first-time-setup)
|
||||
- [Installation](#installation)
|
||||
- [Dev server](#dev-server)
|
||||
- [Developing with the production API](#developing-with-the-production-api)
|
||||
- [Generators](#generators)
|
||||
- [Aliases](#aliases)
|
||||
- [Globals](#globals)
|
||||
- [Base components](#base-components)
|
||||
- [Docker (optional)](#docker-optional)
|
||||
|
||||
## First-time setup
|
||||
|
||||
Make sure you have the following installed:
|
||||
|
||||
- [Node](https://nodejs.org/en/) (at least the latest LTS)
|
||||
- [Yarn](https://yarnpkg.com/lang/en/docs/install/) (at least 1.0)
|
||||
|
||||
Then update the following files to suit your application:
|
||||
|
||||
- `src/app.config.json` (provides metadata about your app)
|
||||
- `.circleci/config.yml` (assuming you want to automatically [deploy to production](production.md) with continuous integration)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies from package.json
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Dev server
|
||||
|
||||
> Note: If you're on Linux and see an `ENOSPC` error when running the commands below, you must [increase the number of available file watchers](https://stackoverflow.com/questions/22475849/node-js-error-enospc#answer-32600959).
|
||||
|
||||
```bash
|
||||
# Launch the dev server
|
||||
yarn dev
|
||||
|
||||
# Launch the dev server and automatically open it in
|
||||
# your default browser when ready
|
||||
yarn dev --open
|
||||
|
||||
# Launch the dev server with the Cypress client for
|
||||
# test-driven development in a friendly interface
|
||||
yarn dev:e2e
|
||||
```
|
||||
|
||||
### Developing with the production API
|
||||
|
||||
By default, dev and tests filter requests through [the mock API](/docs/tests.md#the-mock-api) in `tests/mock-api`. To test directly against a local/live API instead, run dev and test commands with the `API_BASE_URL` environment variable set. For example:
|
||||
|
||||
```bash
|
||||
# To develop against a local backend server
|
||||
API_BASE_URL=http://localhost:3000 yarn dev
|
||||
|
||||
# To test and develop against a production server
|
||||
API_BASE_URL=https://example.io yarn dev:e2e
|
||||
```
|
||||
|
||||
## Generators
|
||||
|
||||
This project includes generators to speed up common development tasks. Commands include:
|
||||
|
||||
```bash
|
||||
# Generate a new component with adjacent unit test
|
||||
yarn new component
|
||||
|
||||
# Generate a new view component with adjacent unit test
|
||||
yarn new view
|
||||
|
||||
# Generate a new layout component with adjacent unit test
|
||||
yarn new layout
|
||||
|
||||
# Generate a new Vuex module with adjacent unit test
|
||||
yarn new module
|
||||
|
||||
# Generate a new utility function with adjacent unit test
|
||||
yarn new util
|
||||
|
||||
# Generate a new end-to-end test
|
||||
yarn new e2e
|
||||
```
|
||||
|
||||
Update existing or create new generators in the `generators` folder, with help from the [Hygen docs](http://www.hygen.io/).
|
||||
|
||||
## Aliases
|
||||
|
||||
To simplify referencing local modules and refactoring, you can set aliases to be shared between dev and unit tests in `aliases.config.js`. As a convention, this project uses an `@` prefix to denote aliases.
|
||||
|
||||
## Globals
|
||||
|
||||
### Base components
|
||||
|
||||
[Base components](https://vuejs.org/v2/style-guide/#Base-component-names-strongly-recommended) (a.k.a. presentational, dumb, or pure components) that apply app-specific styling and conventions should all begin with the `_base-` prefix. Since these components are typically used in place of raw HTML element (and thus used as frequently), they're automatically globally registered for convenience. This means you don't have to import and locally register them to use them in templates.
|
||||
|
||||
## Docker (optional)
|
||||
|
||||
If you'd prefer to use Docker for development, it's recommended to install and run [Docker Desktop](https://www.docker.com/products/docker-desktop). Once the app is started, you'll be able to run commands like:
|
||||
|
||||
```bash
|
||||
# Build and run a containerized version of your app in the background
|
||||
docker-compose up --detach
|
||||
```
|
||||
|
||||
Once your container has started, you can run any script from `package.json` inside the container by prefixing the command with `yarn docker` instead of just `yarn`. For example:
|
||||
|
||||
```bash
|
||||
# Install dependencies in the container
|
||||
yarn docker install
|
||||
|
||||
# Run the dev environment in the container
|
||||
yarn docker dev
|
||||
|
||||
# Run tests in the container
|
||||
yarn docker test
|
||||
```
|
||||
|
||||
To list your containers and their statuses, you can run:
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
To stop your running containers, run:
|
||||
|
||||
```bash
|
||||
docker-compose stop
|
||||
```
|
||||
|
||||
If ever update the following files:
|
||||
|
||||
- `.dockerignore`
|
||||
- `docker-compose.yml`
|
||||
- `docker-dev.dockerfile`
|
||||
|
||||
Then you'll want to stop and remove all containers, networks, volumes, and images created for your app with:
|
||||
|
||||
```bash
|
||||
docker-compose down --volumes --rmi all --remove-orphans
|
||||
```
|
||||
|
||||
This command can also be useful in case something goes wrong with a container and you'd like to start over. All containers, networks, volumes, and images defined in `docker-compose.yml` will be rebuilt the next time you run `docker-compose up`.
|
||||
|
||||
See the docs for [Docker](https://docs.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) for more information on how to use and configure Docker tooling.
|
||||
32
ui/docs/editors.md
Normal file
32
ui/docs/editors.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Editor integration
|
||||
|
||||
- [Visual Studio Code](#visual-studio-code)
|
||||
- [Configuration](#configuration)
|
||||
- [FAQ](#faq)
|
||||
|
||||
## Visual Studio Code
|
||||
|
||||
This project is best developed in VS Code. With the [recommended extensions](https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions) and settings in `.vscode`, you get:
|
||||
|
||||
- Syntax highlighting for all files
|
||||
- Intellisense for all files
|
||||
- Lint-on-save for all files
|
||||
- In-editor results on save for unit tests
|
||||
|
||||
### Configuration
|
||||
|
||||
To configure
|
||||
|
||||
- `.vscode/extensions.json`
|
||||
- `.vscode/settings.json`
|
||||
|
||||
## FAQ
|
||||
|
||||
**What kinds of editor settings and extensions should be added to the project?**
|
||||
|
||||
All additions must:
|
||||
|
||||
- be specific to this project
|
||||
- not interfere with any team member's workflow
|
||||
|
||||
For example, an extension to add syntax highlighting for an included language will almost certainly be welcome, but a setting to change the editor's color theme wouldn't be appropriate.
|
||||
63
ui/docs/linting.md
Normal file
63
ui/docs/linting.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Linting & formatting
|
||||
|
||||
- [Languages](#languages)
|
||||
- [Scripts](#scripts)
|
||||
- [Terminal](#terminal)
|
||||
- [Pre-commit](#pre-commit)
|
||||
- [Editor](#editor)
|
||||
- [Configuration](#configuration)
|
||||
- [FAQ](#faq)
|
||||
|
||||
This project uses ESLint, Stylelint, Markdownlint, and Prettier to catch errors and avoid bikeshedding by enforcing a common code style.
|
||||
|
||||
## Languages
|
||||
|
||||
- **JavaScript** is linted by ESLint and formatted by Prettier
|
||||
- **HTML** (in templates and JSX) is linted by ESLint
|
||||
- **CSS** is linted by Stylelint and formatted by Prettier
|
||||
- **Markdown** is linted by Markdownlint and formatted by Prettier
|
||||
- **JSON** is formatted by Prettier
|
||||
- **Images** are minified by `imagemin-lint-staged` (only on pre-commit)
|
||||
|
||||
## Scripts
|
||||
|
||||
There are a few different contexts in which the linters run.
|
||||
|
||||
### Terminal
|
||||
|
||||
```bash
|
||||
# Lint all files, fixing many violations automatically
|
||||
yarn lint
|
||||
```
|
||||
|
||||
See `package.json` to update.
|
||||
|
||||
### Pre-commit
|
||||
|
||||
Staged files are automatically linted and tested before each commit. See `lint-staged.config.js` to update. [Yorkie](https://github.com/yyx990803/yorkie) is used by `@vue/cli-plugin-eslint` to install the pre-commit hook.
|
||||
|
||||
### Editor
|
||||
|
||||
In supported editors, all files will be linted and formatted on-save. See [editors.md](editors.md) for details.
|
||||
|
||||
## Configuration
|
||||
|
||||
This boilerplate ships with opinionated defaults, but you can edit each tools configuration in the following config files:
|
||||
|
||||
- [ESLint](https://eslint.org/docs/user-guide/configuring)
|
||||
- `.eslintrc.js`
|
||||
- `.eslintignore`
|
||||
- [Stylelint](https://stylelint.io/user-guide/configuration/)
|
||||
- `stylelint.config.js`
|
||||
- [Markdownlint](https://github.com/markdownlint/markdownlint/blob/master/docs/configuration.md)
|
||||
- `.markdownlintrc`
|
||||
- [Prettier](https://prettier.io/docs/en/configuration.html)
|
||||
- `.prettierrc.js`
|
||||
- `.prettierignore`
|
||||
|
||||
## FAQ
|
||||
|
||||
**So many configuration files! Why not move more of this to `package.json`?**
|
||||
|
||||
- Moving all possible configs to `package.json` can make it _really_ packed, so that quickly navigating to a specific config becomes difficult.
|
||||
- When split out into their own file, many tools provide the option of exporting a config from JS. I do this wherever possible, because dynamic configurations are simply more powerful, able to respond to environment variables and much more.
|
||||
17
ui/docs/production.md
Normal file
17
ui/docs/production.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Building and deploying to production
|
||||
|
||||
- [From the terminal](#from-the-terminal)
|
||||
- [From Circle CI](#from-circle-ci)
|
||||
|
||||
## From the terminal
|
||||
|
||||
```bash
|
||||
# Build for production with minification
|
||||
yarn build
|
||||
```
|
||||
|
||||
This results in your compiled application in a `dist` directory.
|
||||
|
||||
## From Circle CI
|
||||
|
||||
Update `.circleci/config.yml` to automatically deploy to staging and/or production on a successful build. See comments in that file for details.
|
||||
17
ui/docs/routing.md
Normal file
17
ui/docs/routing.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Routing, layouts, and views
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Layouts](#layouts)
|
||||
- [Views](#views)
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses [Vue Router](tech.md#vue-router), which we initialize in `src/router/index.js`, with routes defined in `src/router/routes.js`. Inside the `src/router` folder, there are also two sub-folders, both containing route-specific components: `layouts` and `views`.
|
||||
|
||||
## Layouts
|
||||
|
||||
Every view component must use a layout component as its base and register it as `Layout`, as this convention helps us mock out layout components when testing views. Layouts usually aren't very complex, often containing only shared HTML like headers, footers, and navigation to surround the main content in the view.
|
||||
|
||||
## Views
|
||||
|
||||
Each view component will be used by at least one route in `src/router/routes.js`, to provide a template for the page. They can technically include some additional properties from Vue Router [to control navigation](https://router.vuejs.org/guide/advanced/navigation-guards.html), for example to [fetch data](https://router.vuejs.org/guide/advanced/data-fetching.html#fetching-before-navigation) before creating the component, but I recommend adding these guards to `src/router/routes.js` instead, as that behavior typically has much more to do with the route (and will sometimes be shared between routes) than it does the view component.
|
||||
66
ui/docs/state.md
Normal file
66
ui/docs/state.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# State management
|
||||
|
||||
- [State management](#state-management)
|
||||
- [Modules](#modules)
|
||||
- [Helpers](#helpers)
|
||||
- [Module Nesting](#module-nesting)
|
||||
|
||||
## Modules
|
||||
|
||||
The `src/state/modules` directory is where all shared application state lives. Any JS file added here (apart from unit tests) will be automatically registered in the store as a [namespaced module](https://vuex.vuejs.org/en/modules.html#namespacing).
|
||||
|
||||
Read more in the [Vuex modules](https://vuex.vuejs.org/en/modules.html) docs.
|
||||
|
||||
## Helpers
|
||||
|
||||
The state helpers in `helpers.js` are the components' interface to the Vuex store. Depending on a component's concerns, we can import a subset of these helpers to quickly bring in the data and actions we need.
|
||||
|
||||
You might be thinking, "Why not just automatically inject all of these into every component?" Well, then it would be difficult to figure out where a particular part of state is coming from. As our state becomes increasingly complex, the risk would also increase of accidentally using the same names for internal component state. This way, each component remains traceable, as the necessary `import` will provide a thread back to our helpers file if we ever don't understand where something is coming from.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```js
|
||||
import { authComputed } from '@state/helpers'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...authComputed,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Module Nesting
|
||||
|
||||
Vuex modules can be nested, which sometimes makes sense for organizational purposes. For example, if you created these files:
|
||||
|
||||
```js
|
||||
// @file src/state/modules/dashboard.js
|
||||
|
||||
export const state = {
|
||||
role: 'project-manager',
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// @file src/state/modules/dashboard/videos.js
|
||||
|
||||
export const state = {
|
||||
all: [],
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
favorited(state) {
|
||||
return state.all.filter((video) => video.favorited)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then you'd be able to access those modules with:
|
||||
|
||||
```js
|
||||
store.state.dashboard.role
|
||||
store.state.dashboard.videos.all
|
||||
store.getters['dashboard/videos/favorited']
|
||||
```
|
||||
|
||||
As you can see, placing the `videos` module in a folder called `dashboard` automatically nests it underneath the `dashboard` namespace. This works even if a `dashboard.js` file doesn't exist. You can also have as many levels of nesting as you want.
|
||||
289
ui/docs/tech.md
Normal file
289
ui/docs/tech.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Languages and technologies
|
||||
|
||||
- [Languages and technologies](#languages-and-technologies)
|
||||
- [JavaScript](#javascript)
|
||||
- [Polyfills](#polyfills)
|
||||
- [Vue](#vue)
|
||||
- [Vue Router](#vue-router)
|
||||
- [Vuex (state management)](#vuex-state-management)
|
||||
- [JavaScript FAQ](#javascript-faq)
|
||||
- [HTML](#html)
|
||||
- [Templates](#templates)
|
||||
- [Render functions](#render-functions)
|
||||
- [HTML FAQ](#html-faq)
|
||||
- [CSS](#css)
|
||||
- [SCSS](#scss)
|
||||
- [Importing global modules](#importing-global-modules)
|
||||
- [Referencing aliased asset URLs](#referencing-aliased-asset-urls)
|
||||
- [Design variables and tooling](#design-variables-and-tooling)
|
||||
- [CSS modules](#css-modules)
|
||||
- [Styling subcomponents](#styling-subcomponents)
|
||||
- [Sharing SCSS variables with JavaScript](#sharing-scss-variables-with-javascript)
|
||||
- [Global CSS](#global-css)
|
||||
- [CSS FAQ](#css-faq)
|
||||
|
||||
## JavaScript
|
||||
|
||||
Our JavaScript is compiled by Babel, using the [`@vue/babel-preset-app`](https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/babel-preset-app) as a base configuration. You can update this configuration in `.babelrc.js`.
|
||||
|
||||
If you're new to features such as `const`, `let`, and `=>` (arrow functions), take some time to read about the following features in Babel's ES2015 guide:
|
||||
|
||||
- [Arrow functions](https://babeljs.io/docs/en/learn/#arrows-and-lexical-this)
|
||||
- [Template literals](https://babeljs.io/docs/en/learn/#template-strings)
|
||||
- [Destructuring](https://babeljs.io/docs/en/learn/#destructuring)
|
||||
- [Spread operator](https://babeljs.io/docs/en/learn/#default-rest-spread)
|
||||
- [`let`/`const`](https://babeljs.io/docs/en/learn/#let-const)
|
||||
- [`for`...`of`](https://babeljs.io/docs/en/learn/#iterators-forof)
|
||||
|
||||
Reading these sections alone will get you 99% of the way to mastering Babel code. It's also a good idea to read about Promises, if you don't yet feel comfortable with them. Here's a [good intro](https://developers.google.com/web/fundamentals/getting-started/primers/promises).
|
||||
|
||||
### Polyfills
|
||||
|
||||
This project uses Vue CLI's [modern mode](https://cli.vuejs.org/guide/browser-compatibility.html#modern-mode), which creates two bundles: one modern bundle targeting modern browsers that support [ES modules](https://jakearchibald.com/2017/es-modules-in-browsers/), and one legacy bundle targeting older browsers that do not.
|
||||
|
||||
For each bundle, polyfills for any JavaScript features you use are included based on the target bundle and supported browsers defined by `browserslist` in `package.json`.
|
||||
|
||||
### Vue
|
||||
|
||||
Since Vue is such a huge part of our app, I strongly recommend everyone read through at least the _Essentials_ of [Vue's guide](https://vuejs.org/v2/guide/).
|
||||
|
||||
### Vue Router
|
||||
|
||||
To understand how to manage pages with Vue Router, I recommend reading through the _Essentials_ of [those docs](https://router.vuejs.org/en/essentials/getting-started.html). Then you can read more about [routing in this application](routing.md).
|
||||
|
||||
### Vuex (state management)
|
||||
|
||||
To wrap your head around our state management, I recommend reading through [those docs](https://vuex.vuejs.org/guide), starting at _What is Vuex?_ and stopping before _Application Architecture_. Then skip down and read [_Form Handling_](https://vuex.vuejs.org/en/forms.html) and [_Testing_](https://vuex.vuejs.org/en/testing.html). Finally, read about [state management in this application](state.md).
|
||||
|
||||
### JavaScript FAQ
|
||||
|
||||
**Why not use TypeScript instead of JavaScript? Isn't that more appropriate for enterprise environments?**
|
||||
|
||||
At its current rate of development, I think TypeScript will eventually _become_ the standard, but I don't think it's there yet for application development. Here's my reasoning:
|
||||
|
||||
- The vast majority of bugs I encounter are _not_ due to type violations. The most powerful tools against bugs remain linting, tests, and code reviews - none of which are made easier by TypeScript.
|
||||
- TypeScript doesn't guarantee type safety - that still requires discipline. You can still use hundreds of `any` annotations and libraries without any type definitions.
|
||||
- In Visual Studio Code, users can already get a lot of useful intellisense (including type information) without having to use TypeScript. [JSDoc comments](https://jsdoc.app/about-getting-started.html) can also be added to [serve the same purpose](https://blog.usejournal.com/type-vue-without-typescript-b2b49210f0b) on an as-needed basis.
|
||||
- Despite most bugs having nothing to do with type violations, developers can spend _a lot_ of time working towards full type safety, meaning teams unaccustomed to strongly typed languages may face significant drops in productivity. As I mentioned earlier, I think that time would be better spent on tests and code reviews.
|
||||
- While the next version of Vuex will be designed with TypeScript in mind, the current version can be particularly painful with TypeScript.
|
||||
|
||||
## HTML
|
||||
|
||||
All HTML will exist within [`.vue` files](https://vuejs.org/v2/guide/single-file-components.html), either:
|
||||
|
||||
- in a `<template>`, or
|
||||
- in a [`render` function](https://vuejs.org/v2/guide/render-function.html), optionally using [JSX](https://vuejs.org/v2/guide/render-function.html#JSX).
|
||||
|
||||
### [Templates](https://vuejs.org/v2/guide/syntax.html)
|
||||
|
||||
~95% of HTML will be in `.vue` files. Since Vue has a chance to parse it before the browser does, we can also do a few extra things that normally aren't possible in a browser.
|
||||
|
||||
For example, any element or component can be self-closing:
|
||||
|
||||
```html
|
||||
<span class="fa fa-comment" />
|
||||
```
|
||||
|
||||
The above simply compiles to:
|
||||
|
||||
```html
|
||||
<span class="fa fa-comment"></span>
|
||||
```
|
||||
|
||||
This feature is especially useful when writing components with long names, but no content:
|
||||
|
||||
```html
|
||||
<FileUploader
|
||||
title="Upload any relevant legal documents"
|
||||
description="PDFs or scanned images are preferred"
|
||||
icon="folder-open"
|
||||
/>
|
||||
```
|
||||
|
||||
### [Render functions](https://vuejs.org/v2/guide/render-function.html)
|
||||
|
||||
Render functions are _alternatives_ to templates. Components using render functions will be relatively rare, written only when we need either:
|
||||
|
||||
- the full expressive power of JavaScript, or
|
||||
- better rendering performance through stateless, [functional components](https://vuejs.org/v2/guide/render-function.html#Functional-Components)
|
||||
|
||||
These components can optionally be written using an HTML-like syntax within JavaScript called [JSX](https://vuejs.org/v2/guide/render-function.html#JSX), including support for [some template features](https://github.com/vuejs/babel-preset-vue#supports-event-modifiers).
|
||||
|
||||
### HTML FAQ
|
||||
|
||||
**Why not use a preprocessor like Jade instead of HTML?**
|
||||
|
||||
Jade offers too little convenience (no new features we'd want, just simpler syntax) and would break `eslint-plugin-vue`'s template linting.
|
||||
|
||||
**If using a render function instead of a template, why not use a `.js(x)` file instead of a `.vue` file?**
|
||||
|
||||
There are no advantages to using a JS(X) file, other than not having to use a `<script>` tag. By sticking to `.vue` files, you can:
|
||||
|
||||
- leave out components' `name` property, because `vue-loader` adds a `__filename` property to exported objects as a fallback for Vue's devtools
|
||||
- easily add styles if you later decide to
|
||||
- easily refactor to a template if you later decide to
|
||||
|
||||
## CSS
|
||||
|
||||
For our styles, we're using SCSS and CSS modules, which you can activate by adding the `lang="scss"` and `module` attributes to style tags in Vue components:
|
||||
|
||||
```vue
|
||||
<style lang="scss" module>
|
||||
/* Styles go here */
|
||||
</style>
|
||||
```
|
||||
|
||||
### SCSS
|
||||
|
||||
SCSS is a superset of CSS, meaning any valid CSS is _also_ valid SCSS. This allows you to easily copy properties from other sources, without having to reformat it. It also means you can stick to writing the CSS you're still comfortable with while you're learning to use more advanced SCSS features.
|
||||
|
||||
I specifically recommend reading about:
|
||||
|
||||
- [Variables](http://sass-lang.com/guide#topic-2)
|
||||
- [Nesting](http://sass-lang.com/guide#topic-3)
|
||||
- [Operators](http://sass-lang.com/guide#topic-8)
|
||||
|
||||
Just those features cover at least 95% of use cases.
|
||||
|
||||
### Importing global modules
|
||||
|
||||
To import files from `node_modules`, Webpack's [css-loader](https://github.com/webpack-contrib/css-loader) requires adding `~` to the beginning of a module name to denote that it's a global (not relative) file reference. For example:
|
||||
|
||||
```scss
|
||||
@import '~nprogress/nprogress.css';
|
||||
```
|
||||
|
||||
### Referencing aliased asset URLs
|
||||
|
||||
Similarly to importing global modules, referencing aliased assets in _non_-module CSS also requires the `~` at the beginning of the name. For example:
|
||||
|
||||
```scss
|
||||
background: url('~@assets/images/background.png');
|
||||
```
|
||||
|
||||
### Design variables and tooling
|
||||
|
||||
All our [variables](https://sass-lang.com/guide#topic-2), [placeholder classes](https://sass-lang.com/guide#topic-7), [mixins](https://sass-lang.com/guide#topic-6), and other design tooling are in the `src/design` folder. Each of these files define variables, prefixed with the name of the file, then combined in `src/design/index.scss`. This combined file is aliased as `@design` for convenience and can be imported into SCSS using:
|
||||
|
||||
```scss
|
||||
@import '@design';
|
||||
```
|
||||
|
||||
This makes all our design variables available in your component or SCSS file.
|
||||
|
||||
> NOTE: The `src/design` folder should never contain code that compiles to actual CSS, as that CSS would be duplicated across every component the file is imported into.
|
||||
|
||||
### CSS modules
|
||||
|
||||
As mentioned earlier, every Vue component should be a CSS module. That means the classes you define are not _actually_ classes. When you write:
|
||||
|
||||
```vue
|
||||
<style lang="scss" module>
|
||||
.inputLabel {
|
||||
/* ... */
|
||||
}
|
||||
|
||||
.input {
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
You're actually defining values on a `$style` property of the Vue instance such as:
|
||||
|
||||
```js
|
||||
$style: {
|
||||
inputLabel: 'base-input_inputLabel_dsRsJ',
|
||||
input: 'base-input_input_dsRsJ'
|
||||
}
|
||||
```
|
||||
|
||||
These values contain automatically generated classes with:
|
||||
|
||||
- the file name of the component
|
||||
- the name of the class
|
||||
- a random hash
|
||||
|
||||
Do you know what that means?! You can _never_ accidentally write styles that interfere with another component. You also don't have to come up with clever class names, unique across the entire project. You can use class names like `.input`, `.container`, `.checkbox`, or whatever else makes sense within the isolated scope of the component - just like you would with JavaScript variables.
|
||||
|
||||
#### Styling subcomponents
|
||||
|
||||
To pass a class to a child component, it's usually best to do so as a prop:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BaseInputText :labelClass="$style.label">
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.label {
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
In some cases however, you may want to style a component arbitrarily deep. This should generally be avoided, because overuse can make your CSS very brittle and difficult to maintain, but sometimes it's unavoidable.
|
||||
|
||||
In these cases, you can use an [attribute selector](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) to take advantage of the fact that generated class names will always _start_ with the same characters:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div :class="$style.container"><SomeOtherComponentContainingAnInput /></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container [class^='base-input_inputLabel'] {
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
In the above example, we're applying styles to the `inputLabel` class inside a `base-input` component, but only when inside the element with the `container` class.
|
||||
|
||||
#### Sharing SCSS variables with JavaScript
|
||||
|
||||
If you ever need to expose the value of an SCSS variable to your JavaScript, you _can_ with CSS module exports! For example, assuming you have this variable defined:
|
||||
|
||||
```scss
|
||||
$size-grid-padding: 1.3rem;
|
||||
```
|
||||
|
||||
You could import our design tooling, then use CSS modules' `:export` it:
|
||||
|
||||
```vue
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
|
||||
:export {
|
||||
grid-padding: $size-grid-padding;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Then you can access the value using `this.$style['grid-padding']`.
|
||||
|
||||
If you need access from outside a Vue component (e.g. in a Vuex module), you can do so in `src/design/index.scss`. See that file for specific instructions.
|
||||
|
||||
### Global CSS
|
||||
|
||||
Typically, only [`src/app.vue`](../src/app.vue) should ever contain global CSS and even that should only include base element styles and utility classes (e.g. for grid management).
|
||||
|
||||
### CSS FAQ
|
||||
|
||||
**Why use SCSS instead of plain CSS or another CSS preprocessor?**
|
||||
|
||||
CSS preprocessors offer a lot of additional power - just having a browser-independent way to use variables is invaluable. But SCSS has some other advantages over competing preprocessors:
|
||||
|
||||
- SCSS it a superset of CSS, which means:
|
||||
- You can copy and paste valid CSS into SCSS and it will always be valid.
|
||||
- There's a gentler learning curve, as devs can write the same CSS they're used to, gradually incorporating more SCSS features as they're needed.
|
||||
- It's well-supported by both Stylelint and Prettier, eliminating nearly all arguments over code style.
|
||||
|
||||
**Why use CSS modules for scoping, instead of [Vue's `scoped` attribute](https://vue-loader.vuejs.org/en/features/scoped-css.html)?**
|
||||
|
||||
While a little more complex to begin with, CSS modules offer:
|
||||
|
||||
- Universality. The same scoping strategy can be used anywhere in our app, regardless of whether it's in a `.vue` file or `.scss` file.
|
||||
- True protection from collisions. Using the `scoped` attribute, vendor CSS could still affect your own classes, if you both use the same names.
|
||||
- Improved performance. Generated class selectors like `.base-input_inputLabel__3EAebB_0` are faster than attribute selectors, especially on an element selector like `input[data-v-3EAebB]`.
|
||||
- Increased versatility. There are cases the `scoped` attribute just can't handle, such as passing a scoped class to a child component that does _not_ render HTML directly. This is fairly common for component wrappers of views driven by WebGL or Canvas, that often inject HTML overlays such as tooltips at the root of the `<body>`.
|
||||
219
ui/docs/tests.md
Normal file
219
ui/docs/tests.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Tests and mocking the API
|
||||
|
||||
- [Tests and mocking the API](#tests-and-mocking-the-api)
|
||||
- [Running all tests](#running-all-tests)
|
||||
- [Unit tests with Jest](#unit-tests-with-jest)
|
||||
- [Running unit tests](#running-unit-tests)
|
||||
- [Introduction to Jest](#introduction-to-jest)
|
||||
- [Unit test files](#unit-test-files)
|
||||
- [Unit test helpers](#unit-test-helpers)
|
||||
- [Unit test mocks](#unit-test-mocks)
|
||||
- [End-to-end tests with Cypress](#end-to-end-tests-with-cypress)
|
||||
- [Running end-to-end tests](#running-end-to-end-tests)
|
||||
- [Introduction to Cypress](#introduction-to-cypress)
|
||||
- [Accessibility-driven end-to-end tests](#accessibility-driven-end-to-end-tests)
|
||||
- [The mock API](#the-mock-api)
|
||||
- [Mock authentication](#mock-authentication)
|
||||
- [Testing/developing against a real server](#testingdeveloping-against-a-real-server)
|
||||
|
||||
## Running all tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
yarn test
|
||||
```
|
||||
|
||||
## Unit tests with Jest
|
||||
|
||||
### Running unit tests
|
||||
|
||||
```bash
|
||||
# Run unit tests
|
||||
yarn test:unit
|
||||
|
||||
# Run unit tests in watch mode
|
||||
yarn test:unit:watch
|
||||
```
|
||||
|
||||
### Introduction to Jest
|
||||
|
||||
For unit tests, we use Jest with the `describe`/`expect` syntax. If you're not familiar with Jest, I recommend first browsing through the existing tests to get a sense for them.
|
||||
|
||||
Then at the very least, read about:
|
||||
|
||||
- [Jest's matchers](https://facebook.github.io/jest/docs/en/expect.html) for examples of other assertions you can make
|
||||
- [Testing async code](https://facebook.github.io/jest/docs/en/asynchronous.html)
|
||||
- [Setup and teardown](https://facebook.github.io/jest/docs/en/setup-teardown.html)
|
||||
|
||||
### Unit test files
|
||||
|
||||
Configuration for Jest is in `jest.config.js`, support files are in `tests/unit`, but as for the tests themselves - they're first-class citizens. That means they live alongside our source files, using the same name as the file they test, but with the extension `.unit.js`.
|
||||
|
||||
This may seem strange at first, but it makes poor test coverage obvious from a glance, even for those less familiar with the project. It also lowers the barrier to adding tests before creating a new file, adding a new feature, or fixing a bug.
|
||||
|
||||
### Unit test helpers
|
||||
|
||||
See [`tests/unit/setup.js`](../tests/unit/setup.js) for a list of helpers, including documentation in comments.
|
||||
|
||||
### Unit test mocks
|
||||
|
||||
Jest offers many tools for mocks, including:
|
||||
|
||||
- [For a function](https://facebook.github.io/jest/docs/en/mock-functions.html), use `jest.fn()`.
|
||||
- [For a source file](https://facebook.github.io/jest/docs/en/manual-mocks.html#mocking-user-modules), add the mock to a `__mocks__` directory adjacent to the file.
|
||||
- [For a dependency in `node_modules`](https://facebook.github.io/jest/docs/en/manual-mocks.html#mocking-node-modules), add the mock to `tests/unit/__mocks__`. You can see an example of this with the `axios` mock, which intercepts requests with relative URLs to either [our mock API](#the-mock-api) or a local/live API if the `API_BASE_URL` environment variable is set.
|
||||
|
||||
## End-to-end tests with Cypress
|
||||
|
||||
### Running end-to-end tests
|
||||
|
||||
```bash
|
||||
# Run end to end tests
|
||||
yarn test:e2e
|
||||
|
||||
# Run the dev server with the Cypress client
|
||||
yarn dev:e2e
|
||||
```
|
||||
|
||||
### Introduction to Cypress
|
||||
|
||||
Cypress offers many advantages over other test frameworks, including the abilities to:
|
||||
|
||||
- Travel through time to dissect the source of a problem when a test fails
|
||||
- Automatically record video and screenshots of your tests
|
||||
- Easily test in a wide range of screen sizes
|
||||
|
||||
And much more! I recommend checking out our Cypress tests in `tests/e2e/specs`, then reading through at least these sections of the excellent Cypress docs:
|
||||
|
||||
- [Core Concepts](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Cypress-Is-Simple)
|
||||
- [Best Practices](https://docs.cypress.io/guides/references/best-practices.html)
|
||||
|
||||
Beyond that, also know that you can access our app in Cypress on the `window`. For example, to dispatch a Vuex action that sets up some state:
|
||||
|
||||
```js
|
||||
cy.window().then((window) => {
|
||||
return window.__app__.$store.dispatch('someModule/someAction')
|
||||
})
|
||||
```
|
||||
|
||||
### Accessibility-driven end-to-end tests
|
||||
|
||||
Ideally, tests should only fail when either:
|
||||
|
||||
- something is actually broken, or
|
||||
- the requirements have changed
|
||||
|
||||
Unfortunately, there are _a lot_ of ways to get this wrong. For example, when creating a selector for a login link:
|
||||
|
||||
```js
|
||||
cy.get('a')
|
||||
// Too general, as there could be many links
|
||||
|
||||
cy.get('.login-link')
|
||||
// Tied to implementation detail of CSS
|
||||
|
||||
cy.get('#login-link')
|
||||
// Tied to implementation detail of JS and prevents component reusability
|
||||
|
||||
cy.contains('Log in')
|
||||
// Assumes the text only appears in one context
|
||||
```
|
||||
|
||||
To create the right selector, think from the perspective of the user. What _exactly_ are they looking for? They're not looking for:
|
||||
|
||||
```js
|
||||
cy.get('a')
|
||||
// Any link
|
||||
|
||||
cy.get('.login-link')
|
||||
// An element with a specific class
|
||||
|
||||
cy.get('#login-link')
|
||||
// An element with a specific id
|
||||
|
||||
cy.contains('Log in')
|
||||
// Specific text anywhere on the page
|
||||
```
|
||||
|
||||
But rather:
|
||||
|
||||
```js
|
||||
cy.contains('a', 'Log in')
|
||||
// A link containing the text "Log in"
|
||||
```
|
||||
|
||||
Note that we're targeting a **semantic element**, meaning that it tells the web browser (and users) something about the element's role within the page. Also note that we're trying to be **as general as possible**. We're not looking for the link in a specific place, like a navbar or sidebar (unless that's part of the requirements), and we're not overly specific with the content. The link may also contain other content, like an icon, but that won't break the test, because we only care that _some link_ contains the text "Log in" _somewhere_ inside it.
|
||||
|
||||
Now, some will be thinking:
|
||||
|
||||
> "But isn't this brittle? Wouldn't it be better to add another attribute to the link, like `data-testid="login-link`? Then we could target that attribute and even if the element or content changes, the test won't break."
|
||||
|
||||
I would argue that if the link's semantic element or content changes so drastically that it's no longer an anchor and doesn't even contain the text "Log in" anymore, the requirements _have_ changed, so the test _should_ break. And from an accessibility perspective, the app might indeed be broken.
|
||||
|
||||
For example, let's imagine you replaced "Log in" with an icon:
|
||||
|
||||
```html
|
||||
<a href="/login">
|
||||
<span class="icon icon-login"></span>
|
||||
</a>
|
||||
```
|
||||
|
||||
Now users browsing your page with a screen reader will have no way to find the login link. From their perspective, this is just a link with no content. You may be tempted to try to fix the test with something like:
|
||||
|
||||
```js
|
||||
cy.get('a[href="/login"]')
|
||||
// A link going to "/login"
|
||||
```
|
||||
|
||||
But when you're trying to find a login link as a user, you don't just inspect the destination of unlabeled links until you find one that looks like it's possibly a login page. That would be a very slow and painful experience!
|
||||
|
||||
Instead, thinking from a user's perspective forces you to stay accessible, perhaps updating your generated HTML to:
|
||||
|
||||
```html
|
||||
<a aria-label="Log in" href="/login">
|
||||
<span aria-hidden="true" class="icon icon-login"></span>
|
||||
</a>
|
||||
```
|
||||
|
||||
Then the selector in your test can update as well:
|
||||
|
||||
```js
|
||||
cy.get('a[aria-label*="Log in"]')
|
||||
// A link with a label containing the text "Log in"
|
||||
```
|
||||
|
||||
And the app now works for everyone:
|
||||
|
||||
- Sighted users will see an icon that they'll (hopefully) have the cultural context to interpret as "Log in".
|
||||
- Non-sighted users get a label with the text "Log in" read to them.
|
||||
|
||||
This strategy could be called **accessibility-driven end-to-end tests**, because you're parsing your own app with the same mindset as your users. It happens to be great for accessibility, but also helps to ensure that your app always breaks when requirements change, but never when you've just changed the implementation.
|
||||
|
||||
## The mock API
|
||||
|
||||
Working against the production API can be useful sometimes, but it also has some disadvantages:
|
||||
|
||||
- Networks requests are slow, which slows down both development and testing.
|
||||
- Development and testing become dependent on a stable network connection.
|
||||
- Hitting the production API often means modifying the production database, which you typically don't want to do during automated tests.
|
||||
- To work on a frontend feature, the backend for it must already be complete.
|
||||
|
||||
The mock API is an [Express](https://expressjs.com/) server in `tests/mock-api` you can extend to - you guessed it - mock what the real API would do, solving all the problems listed above. This solution is also backend-agnostic, making it ideal for a wide variety of projects.
|
||||
|
||||
### Mock authentication
|
||||
|
||||
See the [`users` resource](../tests/mock-api/resources/users.js) in the mock API for a list of usernames and passwords you can use in development.
|
||||
|
||||
### Testing/developing against a real server
|
||||
|
||||
In some situations, you might prefer to test against a local server while developing, or maybe just during continuous integration. To do so, you can run any development or test command with the `API_BASE_URL` environment variable. For example:
|
||||
|
||||
```bash
|
||||
API_BASE_URL=http://localhost:3000 yarn test
|
||||
```
|
||||
|
||||
Or similarly, with a live server:
|
||||
|
||||
```bash
|
||||
API_BASE_URL=https://staging.example.io yarn test
|
||||
```
|
||||
44
ui/docs/troubleshooting.md
Normal file
44
ui/docs/troubleshooting.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Troubleshooting
|
||||
|
||||
These are some troubleshooting tips for more common issues people might run into while developing, including more information on what might be happening and how to fix the problem.
|
||||
|
||||
- [Errors running scripts (e.g. `yarn dev`)](#errors-running-scripts-eg-yarn-dev)
|
||||
- [Visual Studio (VS) Code formatting issues](#visual-studio-vs-code-formatting-issues)
|
||||
|
||||
## Errors running scripts (e.g. `yarn dev`)
|
||||
|
||||
Make sure you've followed the instructions for [Setup and development](development.md). If you already have, try deleting the `node_modules` folder and installing fresh:
|
||||
|
||||
```bash
|
||||
# 1. Delete all previously-installed dependencies.
|
||||
rm -rf node_modules
|
||||
|
||||
# 2. Install dependencies fresh.
|
||||
yarn install
|
||||
```
|
||||
|
||||
If that doesn't work, it's possible that a newer version of a dependency is creating a problem. If this is the problem, you can work around it by installing dependencies from the `yarn.lock` file of a previously working branch or commit.
|
||||
|
||||
```bash
|
||||
# 1. Delete all previously-installed dependencies.
|
||||
rm -rf node_modules
|
||||
|
||||
# 2. Use the same yarn.lock as the `origin/master` branch. If the problem
|
||||
# exists on the `origin/master` as well, instead use the last-known
|
||||
# working branch or commit.
|
||||
git checkout origin/master -- yarn.lock
|
||||
|
||||
# 2. Install dependencies fresh, using only the exact versions specified
|
||||
# in the `yarn.lock` file.
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
If this solves your problem, you can use `yarn outdated` to see the packages that may have received updates, then upgrade them one at a time with `yarn upgrade the-package-name` to see which upgrade introduces the problem.
|
||||
|
||||
## Visual Studio (VS) Code formatting issues
|
||||
|
||||
If you're using VS Code and notice that some files are being formatted incorrectly on save, the source is probably a formatter extension you've installed. The reason you're seeing it now is that this project enables the `editor.formatOnSave` setting. Previously, that extension was probably just doing nothing. To fix the problem, you'll need to either properly configure the extension or, if it's simply broken, uninstall it.
|
||||
|
||||
Extensions with known issues include:
|
||||
|
||||
- [Visual Studio Code Format](https://marketplace.visualstudio.com/items?itemName=ryannaddy.vscode-format#review-details)
|
||||
31
ui/generators/new/component/component.ejs.t
Normal file
31
ui/generators/new/component/component.ejs.t
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.vue"
|
||||
---
|
||||
<%
|
||||
if (blocks.indexOf('script') !== -1) {
|
||||
%><script>
|
||||
export default {
|
||||
<% if (blocks.indexOf('template') === -1) {
|
||||
%>render(h) {
|
||||
return <div/>
|
||||
}<% } %>
|
||||
}
|
||||
</script>
|
||||
<%
|
||||
}
|
||||
|
||||
if (blocks.indexOf('template') !== -1) {
|
||||
%>
|
||||
<template>
|
||||
<div/>
|
||||
</template>
|
||||
<%
|
||||
}
|
||||
|
||||
if (blocks.indexOf('style') !== -1) {
|
||||
%>
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
</style><%
|
||||
}
|
||||
%>
|
||||
45
ui/generators/new/component/prompt.js
Normal file
45
ui/generators/new/component/prompt.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const _ = require('lodash')
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Components must have a name.'
|
||||
}
|
||||
const fileName = _.kebabCase(value)
|
||||
if (fileName.indexOf('-') === -1) {
|
||||
return 'Component names should contain at least two words to avoid conflicts with existing and future HTML elements.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'multiselect',
|
||||
name: 'blocks',
|
||||
message: 'Blocks:',
|
||||
initial: ['script', 'template', 'style'],
|
||||
choices: [
|
||||
{
|
||||
name: 'script',
|
||||
message: '<script>',
|
||||
},
|
||||
{
|
||||
name: 'template',
|
||||
message: '<template>',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
message: '<style>',
|
||||
},
|
||||
],
|
||||
validate(value) {
|
||||
if (value.indexOf('script') === -1 && value.indexOf('template') === -1) {
|
||||
return 'Components require at least a <script> or <template> tag.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
16
ui/generators/new/component/unit.ejs.t
Normal file
16
ui/generators/new/component/unit.ejs.t
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
let fileName = h.changeCase.kebab(name).toLowerCase()
|
||||
const importName = h.changeCase.pascal(fileName)
|
||||
if (fileName.slice(0, 5) === 'base-') {
|
||||
fileName = '_' + fileName
|
||||
}
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@components/<%= fileName %>', () => {
|
||||
it('exports a valid component', () => {
|
||||
expect(<%= importName %>).toBeAComponent()
|
||||
})
|
||||
})
|
||||
6
ui/generators/new/e2e/e2e.ejs.t
Normal file
6
ui/generators/new/e2e/e2e.ejs.t
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
to: tests/e2e/specs/<%= h.changeCase.kebab(name) %>.e2e.js
|
||||
---
|
||||
describe('<%= h.changeCase.pascal(name) %>', () => {
|
||||
|
||||
})
|
||||
13
ui/generators/new/e2e/prompt.js
Normal file
13
ui/generators/new/e2e/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Components must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
18
ui/generators/new/layout/layout.ejs.t
Normal file
18
ui/generators/new/layout/layout.ejs.t
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
to: "src/router/layouts/<%= h.changeCase.kebab(name) %>.vue"
|
||||
---
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
|
||||
.container {
|
||||
min-width: $size-content-width-min;
|
||||
max-width: $size-content-width-max;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
13
ui/generators/new/layout/prompt.js
Normal file
13
ui/generators/new/layout/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Layout components must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
19
ui/generators/new/layout/unit.ejs.t
Normal file
19
ui/generators/new/layout/unit.ejs.t
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
to: "src/router/layouts/<%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.pascal(fileName) + 'Layout'
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@layouts/<%= fileName %>', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<p>Hello!</p>'
|
||||
const { element } = shallowMount(<%= importName %>, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
10
ui/generators/new/module/module.ejs.t
Normal file
10
ui/generators/new/module/module.ejs.t
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
to: src/state/modules/<%= h.changeCase.kebab(name) %>.js
|
||||
---
|
||||
export const state = {}
|
||||
|
||||
export const getters = {}
|
||||
|
||||
export const mutations = {}
|
||||
|
||||
export const actions = {}
|
||||
13
ui/generators/new/module/prompt.js
Normal file
13
ui/generators/new/module/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Vuex modules must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
13
ui/generators/new/module/unit.ejs.t
Normal file
13
ui/generators/new/module/unit.ejs.t
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
to: src/state/modules/<%= h.changeCase.kebab(name) %>.unit.js
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.camel(fileName) + 'Module'
|
||||
%>import * as <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@state/modules/<%= fileName %>', () => {
|
||||
it('exports a valid Vuex module', () => {
|
||||
expect(<%= importName %>).toBeAVuexModule()
|
||||
})
|
||||
})
|
||||
13
ui/generators/new/util/prompt.js
Normal file
13
ui/generators/new/util/prompt.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'Utility functions must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
14
ui/generators/new/util/unit.ejs.t
Normal file
14
ui/generators/new/util/unit.ejs.t
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
to: "src/utils/<%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.camel(fileName)
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@utils/<%= fileName %>', () => {
|
||||
it('says hello', () => {
|
||||
const result = <%= importName %>()
|
||||
expect(result).toEqual('hello')
|
||||
})
|
||||
})
|
||||
9
ui/generators/new/util/util.ejs.t
Normal file
9
ui/generators/new/util/util.ejs.t
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
to: "src/utils/<%= h.changeCase.kebab(name) %>.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.camel(fileName)
|
||||
%>export default function <%= importName %>() {
|
||||
return 'hello'
|
||||
}
|
||||
18
ui/generators/new/view/prompt.js
Normal file
18
ui/generators/new/view/prompt.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Name:',
|
||||
validate(value) {
|
||||
if (!value.length) {
|
||||
return 'View components must have a name.'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useStyles',
|
||||
message: 'Add <style> block?',
|
||||
},
|
||||
]
|
||||
13
ui/generators/new/view/unit.ejs.t
Normal file
13
ui/generators/new/view/unit.ejs.t
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
to: "src/router/views/<%= h.changeCase.kebab(name) %>.unit.js"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.pascal(fileName)
|
||||
%>import <%= importName %> from './<%= fileName %>'
|
||||
|
||||
describe('@views/<%= fileName %>', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(<%= importName %>).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
31
ui/generators/new/view/view.ejs.t
Normal file
31
ui/generators/new/view/view.ejs.t
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
to: "src/router/views/<%= h.changeCase.kebab(name) %>.vue"
|
||||
---
|
||||
<%
|
||||
const fileName = h.changeCase.kebab(name)
|
||||
const importName = h.changeCase.pascal(fileName)
|
||||
const titleName = h.changeCase.title(name)
|
||||
%><script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: '<%= titleName %>',
|
||||
meta: [{ name: 'description', content: 'The <%= titleName %> page.' }],
|
||||
},
|
||||
components: { Layout }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<%= titleName %>
|
||||
</Layout>
|
||||
</template>
|
||||
<%
|
||||
|
||||
if (useStyles) { %>
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
</style>
|
||||
<% } %>
|
||||
54
ui/jest.config.js
Normal file
54
ui/jest.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const _ = require('lodash')
|
||||
// Use a random port number for the mock API by default,
|
||||
// to support multiple instances of Jest running
|
||||
// simultaneously, like during pre-commit lint.
|
||||
process.env.MOCK_API_PORT = process.env.MOCK_API_PORT || _.random(9000, 9999)
|
||||
|
||||
module.exports = {
|
||||
setupFiles: ['<rootDir>/tests/unit/setup'],
|
||||
globalSetup: '<rootDir>/tests/unit/global-setup',
|
||||
globalTeardown: '<rootDir>/tests/unit/global-teardown',
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/unit/matchers'],
|
||||
testMatch: ['**/(*.)unit.js'],
|
||||
moduleFileExtensions: ['js', 'json', 'vue'],
|
||||
transform: {
|
||||
'^.+\\.vue$': 'vue-jest',
|
||||
'^.+\\.js$': 'babel-jest',
|
||||
'.+\\.(css|scss|jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$':
|
||||
'jest-transform-stub',
|
||||
},
|
||||
moduleNameMapper: require('./aliases.config').jest,
|
||||
snapshotSerializers: ['jest-serializer-vue'],
|
||||
coverageDirectory: '<rootDir>/tests/unit/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,vue}',
|
||||
'!**/node_modules/**',
|
||||
'!src/main.js',
|
||||
'!src/app.vue',
|
||||
'!src/router/index.js',
|
||||
'!src/router/routes.js',
|
||||
'!src/state/store.js',
|
||||
'!src/state/helpers.js',
|
||||
'!src/state/modules/index.js',
|
||||
'!src/components/_globals.js',
|
||||
],
|
||||
// https://facebook.github.io/jest/docs/en/configuration.html#testurl-string
|
||||
// Set the `testURL` to a provided base URL if one exists, or the mock API base URL
|
||||
// Solves: https://stackoverflow.com/questions/42677387/jest-returns-network-error-when-doing-an-authenticated-request-with-axios
|
||||
testURL:
|
||||
process.env.API_BASE_URL || `http://localhost:${process.env.MOCK_API_PORT}`,
|
||||
// https://github.com/jest-community/jest-watch-typeahead
|
||||
watchPlugins: [
|
||||
'jest-watch-typeahead/filename',
|
||||
'jest-watch-typeahead/testname',
|
||||
],
|
||||
globals: {
|
||||
'vue-jest': {
|
||||
// Compilation errors in the <style> tags of Vue components will
|
||||
// already result in failing builds, so compiling CSS during unit
|
||||
// tests doesn't protect us from anything. It only complicates
|
||||
// and slows down our unit tests.
|
||||
experimentalCSSCompile: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
15
ui/jsconfig.template.js
Normal file
15
ui/jsconfig.template.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// This is a template for a jsconfig.json file which will be
|
||||
// generated when starting the dev server or a build.
|
||||
|
||||
module.exports = {
|
||||
baseUrl: '.',
|
||||
include: ['src/**/*', 'tests/**/*'],
|
||||
compilerOptions: {
|
||||
baseUrl: '.',
|
||||
target: 'esnext',
|
||||
module: 'es2015',
|
||||
// ...
|
||||
// `paths` will be automatically generated using aliases.config.js
|
||||
// ...
|
||||
},
|
||||
}
|
||||
16
ui/lint-staged.config.js
Normal file
16
ui/lint-staged.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
'*.js': ['yarn lint:eslint', 'yarn lint:prettier', 'yarn test:unit:file'],
|
||||
'{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [
|
||||
'yarn lint:prettier --parser json',
|
||||
],
|
||||
'package.json': ['yarn lint:prettier'],
|
||||
'*.vue': [
|
||||
'yarn lint:eslint',
|
||||
'yarn lint:stylelint',
|
||||
'yarn lint:prettier',
|
||||
'yarn test:unit:file',
|
||||
],
|
||||
'*.scss': ['yarn lint:stylelint', 'yarn lint:prettier'],
|
||||
'*.md': ['yarn lint:markdownlint', 'yarn lint:prettier'],
|
||||
'*.{png,jpeg,jpg,gif,svg}': ['imagemin-lint-staged'],
|
||||
}
|
||||
22010
ui/package-lock.json
generated
Normal file
22010
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
93
ui/package.json
Normal file
93
ui/package.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vue-cli-service serve",
|
||||
"dev:e2e": "cross-env VUE_APP_TEST=e2e vue-cli-service test:e2e --mode=development",
|
||||
"build": "vue-cli-service build --modern",
|
||||
"build:ci": "yarn build --report",
|
||||
"lint:eslint": "eslint --fix",
|
||||
"lint:stylelint": "stylelint --fix",
|
||||
"lint:markdownlint": "markdownlint",
|
||||
"lint:prettier": "prettier --write --loglevel warn",
|
||||
"lint:all:eslint": "yarn lint:eslint --ext .js,.vue .",
|
||||
"lint:all:stylelint": "yarn lint:stylelint \"src/**/*.{vue,scss}\"",
|
||||
"lint:all:markdownlint": "yarn lint:markdownlint \"docs/*.md\" \"*.md\"",
|
||||
"lint:all:prettier": "yarn lint:prettier \"**/*.{js,json,css,scss,vue,html,md}\"",
|
||||
"lint": "run-s lint:all:*",
|
||||
"test:unit": "cross-env VUE_APP_TEST=unit vue-cli-service test:unit",
|
||||
"test:unit:file": "yarn test:unit --bail --findRelatedTests",
|
||||
"test:unit:watch": "yarn test:unit --watch --notify --notifyMode change",
|
||||
"test:unit:ci": "yarn test:unit --coverage --ci",
|
||||
"test:e2e": "cross-env VUE_APP_TEST=e2e vue-cli-service test:e2e --headless",
|
||||
"test": "run-s test:unit test:e2e",
|
||||
"test:ci": "run-s test:unit:ci test:e2e",
|
||||
"new": "cross-env HYGEN_TMPLS=generators hygen new",
|
||||
"docs": "vuepress dev",
|
||||
"docker": "docker-compose exec dev yarn"
|
||||
},
|
||||
"gitHooks": {
|
||||
"pre-commit": "cross-env PRE_COMMIT=true lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.27",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.1",
|
||||
"@fortawesome/vue-fontawesome": "0.1.9",
|
||||
"axios": "0.19.2",
|
||||
"buefy": "^0.9.5",
|
||||
"core-js": "3.6.4",
|
||||
"currency-formatter": "^1.5.7",
|
||||
"date-fns": "2.10.0",
|
||||
"lodash": "4.17.15",
|
||||
"normalize.css": "8.0.1",
|
||||
"nprogress": "0.2.0",
|
||||
"vue": "2.6.11",
|
||||
"vue-meta": "2.3.3",
|
||||
"vue-router": "3.1.6",
|
||||
"vuex": "3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "4.2.x",
|
||||
"@vue/cli-plugin-e2e-cypress": "4.2.x",
|
||||
"@vue/cli-plugin-eslint": "4.2.x",
|
||||
"@vue/cli-plugin-unit-jest": "4.2.x",
|
||||
"@vue/cli-service": "4.2.x",
|
||||
"@vue/eslint-config-prettier": "6.0.x",
|
||||
"@vue/eslint-config-standard": "5.1.x",
|
||||
"@vue/test-utils": "1.0.0-beta.31",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "10.1.x",
|
||||
"cross-env": "7.0.x",
|
||||
"eslint": "6.8.x",
|
||||
"eslint-plugin-import": "2.20.x",
|
||||
"eslint-plugin-node": "11.0.x",
|
||||
"eslint-plugin-promise": "4.2.x",
|
||||
"eslint-plugin-standard": "4.0.x",
|
||||
"eslint-plugin-vue": "6.2.x",
|
||||
"express": "4.17.x",
|
||||
"hygen": "4.0.x",
|
||||
"imagemin-lint-staged": "0.4.x",
|
||||
"lint-staged": "10.0.x",
|
||||
"markdownlint-cli": "0.22.x",
|
||||
"npm-run-all": "4.1.x",
|
||||
"sass": "1.26.x",
|
||||
"sass-loader": "8.0.x",
|
||||
"stylelint": "13.2.x",
|
||||
"stylelint-config-css-modules": "2.2.x",
|
||||
"stylelint-config-prettier": "8.0.x",
|
||||
"stylelint-config-recess-order": "2.0.x",
|
||||
"stylelint-config-standard": "20.0.x",
|
||||
"stylelint-scss": "3.14.x",
|
||||
"vue-template-compiler": "2.6.11",
|
||||
"vuepress": "1.3.x"
|
||||
},
|
||||
"resolutions": {
|
||||
"@vue/cli-plugin-unit-jest/jest": "25.1.x",
|
||||
"@vue/cli-plugin-unit-jest/babel-jest": "25.1.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.3",
|
||||
"yarn": ">=1.0.0"
|
||||
}
|
||||
}
|
||||
75
ui/package.json.md
Normal file
75
ui/package.json.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# [`package.json`](https://docs.npmjs.com/files/package.json)
|
||||
|
||||
This document serves as a replace for comments in `package.json`, since it includes a lot of configuration often requiring explanation.
|
||||
|
||||
## [`name`](https://docs.npmjs.com/files/package.json#name)
|
||||
|
||||
This field is used by the [Vue CLI UI](https://cli.vuejs.org/guide/creating-a-project.html#using-the-gui) and sometimes other tooling to display the name of the project.
|
||||
|
||||
## [`version`](https://docs.npmjs.com/files/package.json#version)
|
||||
|
||||
This field often isn't useful for applications, but some tooling complains or even breaks if `version` is missing.
|
||||
|
||||
## [`private`](https://docs.npmjs.com/files/package.json#private)
|
||||
|
||||
This field indicates to tooling that this project contains private source code, preventing it from being accidentally published to public registries such as NPM.
|
||||
|
||||
## [`scripts`](https://docs.npmjs.com/files/package.json#scripts)
|
||||
|
||||
This field allows you to define commands that can be run from the terminal and sometimes [from editors](https://code.visualstudio.com/docs/editor/tasks). For example, in the following script:
|
||||
|
||||
```json
|
||||
"dev": "vue-cli-service serve"
|
||||
```
|
||||
|
||||
The name is `dev`, allowing you to run the script using `yarn dev`. When the script is run, it will execute the code: `vue-cli-service serve`. In this case, `vue-cli-service` is a binary installed to `node_modules/.bin`, provided by our `@vue/cli-service` dev dependency. All scripts in `package.json` have access to both globally installed binaries and those provided by local dependencies.
|
||||
|
||||
## [`gitHooks`](https://github.com/yyx990803/yorkie#yorkie)
|
||||
|
||||
This field works is provided by [yorkie](https://github.com/yyx990803/yorkie) and works similarly to `scripts`, except the key of each field is the name of a [client-side Git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks#_client_side_hooks) that runs automatically at a specific point in your Git workflow.
|
||||
|
||||
This project is currently configured to run checks defined in `lint-staged.config.js` on every file staged for commit, before allowing that commit to proceed. This prevents certain runtime errors, style violations, debugging code, and failing unit tests from accidentally being committed to the codebase.
|
||||
|
||||
## [`dependencies`](https://docs.npmjs.com/files/package.json#dependencies)
|
||||
|
||||
This field allows you to define dependencies that will be included in your bundled source code. Running `yarn add` will add dependencies to this list.
|
||||
|
||||
Since changes to these dependencies directly affect the code you ship, they're all locked to specific versions rather than using version ranges. Somewhere between a weekly and monthly basis, it's recommended to run `yarn outdated` to see what new versions have been released, then review the changelogs for each outdated dependency to determine:
|
||||
|
||||
- Whether you want to upgrade.
|
||||
|
||||
- Whether upgrading would require code changes (e.g. even a patch release fixing a bug may require you to update code that was previously working around or even relying on that bug).
|
||||
|
||||
- Whether upgrading might change your application's roadmap (e.g. a new feature may open possibilities that were previously inconceivable, unfeasible, or not worth the time).
|
||||
|
||||
Once you've determined how you'd like to proceed, you can update these versions manually and re-run `yarn` to install the new versions.
|
||||
|
||||
## [`devDependencies`](https://docs.npmjs.com/files/package.json#devdependencies)
|
||||
|
||||
This field allows you to define dev dependencies, which are _not_ included in your bundled source code, but instead used in development for code compilation/transformation, development servers, tests, and other development tasks. Running `yarn add --dev` will add dependencies to this list.
|
||||
|
||||
A few notable conventions:
|
||||
|
||||
- Instead of using `^`/`~` to specify version ranges, we're using `x` as a wildcard. For example, `1.2.x` will use the latest patch release of version `1.2`. The `x` notation tends to be much more intuitive to developers and `yarn.lock` already ensures a minimum patch version when one is needed.
|
||||
|
||||
- All dev dependencies using stable versions (e.g. _not_ `alpha`, `beta`, `rc`, `next`, etc) are locked to a minor version (e.g. `1.2.x`). This allows dev dependency bugs to be automatically fixed with `yarn upgrade`, while still requiring major/minor version upgrades to be done manually. It's strongly recommended to always check changelogs before upgrading to a new major or minor version. With a new major version, there are likely to be breaking changes requiring updates to your project's non-source code. With a new minor version, there are often new features that are important to be aware of, because they could improve the productivity of your team.
|
||||
|
||||
- Any dev dependencies using pre-release versions point to a specific version, rather than a version range, because any new version of pre-release software could contain breaking changes.
|
||||
|
||||
- The version of `vue-template-compiler` must always match the version of `vue` specified in `dependencies`.
|
||||
|
||||
### `babel-jest`
|
||||
|
||||
For users installing with NPM, there seems to be a bug in NPM module resolution (and possibly in `babel-jest`) that results in [Jest errors](https://github.com/chrisvfritz/vue-enterprise-boilerplate/issues/77) when using `babel-jest` >=24.
|
||||
|
||||
### `eslint-plugin-vue`
|
||||
|
||||
This package is locked to a specific commit until version >=5.3 is released, which should include all the uncategorized rules listed in `.eslintrc.js`.
|
||||
|
||||
## [`engines`](https://docs.npmjs.com/files/package.json#engines)
|
||||
|
||||
This field allows you to define specific versions for globally installed runtimes and tooling, such as [Node](https://nodejs.org) and [Yarn](https://yarnpkg.com). Ensuring that everyone on your team meets a minimum version threshold can vastly simplify debugging issues that only some developers experience.
|
||||
|
||||
## [`browserlist`](https://flaviocopes.com/package-json/#browserslist)
|
||||
|
||||
This field defines the browser ranges this project supports, used to determine which polyfills and helpers must be added by code compilers.
|
||||
BIN
ui/public/hammond.png
Normal file
BIN
ui/public/hammond.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 463 B |
16
ui/public/index.html
Normal file
16
ui/public/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="shortcut icon" href="<%= webpackConfig.output.publicPath %>hammond.png" />
|
||||
<title><%= webpackConfig.name %></title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- This is where our app will be mounted. -->
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- Built files will be auto injected here. -->
|
||||
</body>
|
||||
</html>
|
||||
4
ui/src/app.config.json
Normal file
4
ui/src/app.config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Hammond",
|
||||
"description": "Like Clarkson, but better"
|
||||
}
|
||||
35
ui/src/app.vue
Normal file
35
ui/src/app.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import appConfig from '@src/app.config'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
// All subcomponent titles will be injected into this template.
|
||||
titleTemplate(title) {
|
||||
title = typeof title === 'function' ? title(this.$store) : title
|
||||
return title ? `${title} | ${appConfig.title}` : appConfig.title
|
||||
},
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('resize', this.onResize)
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('resize', this.onResize)
|
||||
},
|
||||
methods: {
|
||||
onResize(e) {
|
||||
store.dispatch('utils/checkSize').then((isMobile) => {})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<!--
|
||||
Even when routes use the same component, treat them
|
||||
as distinct and create the component again.
|
||||
-->
|
||||
<RouterView :key="$route.fullPath" />
|
||||
</div>
|
||||
</template>
|
||||
BIN
ui/src/assets/images/logo.png
Normal file
BIN
ui/src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
13
ui/src/components/_base-button.unit.js
Normal file
13
ui/src/components/_base-button.unit.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import BaseButton from './_base-button.vue'
|
||||
|
||||
describe('@components/_base-button', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<span>foo</span>'
|
||||
const { element } = shallowMount(BaseButton, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
5
ui/src/components/_base-button.vue
Normal file
5
ui/src/components/_base-button.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<b-button :class="$style.button" v-on="$listeners">
|
||||
<slot />
|
||||
</b-button>
|
||||
</template>
|
||||
30
ui/src/components/_base-icon.unit.js
Normal file
30
ui/src/components/_base-icon.unit.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import BaseIcon from './_base-icon.vue'
|
||||
|
||||
describe('@components/_base-icon', () => {
|
||||
it('renders a font-awesome icon', () => {
|
||||
const { element } = mount(BaseIcon, {
|
||||
propsData: {
|
||||
name: 'sync',
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.tagName).toEqual('svg')
|
||||
expect(element.classList).toContain('svg-inline--fa', 'fa-sync', 'fa-w-16')
|
||||
})
|
||||
|
||||
it('renders a custom icon', () => {
|
||||
const { element } = shallowMount(BaseIcon, {
|
||||
...createComponentMocks({
|
||||
style: {
|
||||
iconCustomSomeIcon: 'generated-class-name',
|
||||
},
|
||||
}),
|
||||
propsData: {
|
||||
source: 'custom',
|
||||
name: 'some-icon',
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.className).toEqual('generated-class-name')
|
||||
})
|
||||
})
|
||||
47
ui/src/components/_base-icon.vue
Normal file
47
ui/src/components/_base-icon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { library as fontAwesomeIconLibrary } from '@fortawesome/fontawesome-svg-core'
|
||||
import camelCase from 'lodash/camelCase'
|
||||
|
||||
// https://fontawesome.com/icons
|
||||
fontAwesomeIconLibrary.add(
|
||||
require('@fortawesome/free-solid-svg-icons/faSync').definition,
|
||||
require('@fortawesome/free-solid-svg-icons/faUser').definition
|
||||
)
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FontAwesomeIcon,
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
default: 'font-awesome',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// Gets a CSS module class, e.g. iconCustomLogo
|
||||
customIconClass() {
|
||||
return this.$style[camelCase('icon-custom-' + this.name)]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FontAwesomeIcon
|
||||
v-if="source === 'font-awesome'"
|
||||
v-bind="$attrs"
|
||||
:icon="name"
|
||||
/>
|
||||
<span
|
||||
v-else-if="source === 'custom'"
|
||||
v-bind="$attrs"
|
||||
:class="customIconClass"
|
||||
/>
|
||||
</template>
|
||||
45
ui/src/components/_base-input-text.unit.js
Normal file
45
ui/src/components/_base-input-text.unit.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import BaseInputText from './_base-input-text.vue'
|
||||
|
||||
describe('@components/_base-input-text', () => {
|
||||
it('works with v-model', () => {
|
||||
const wrapper = mount(BaseInputText, { propsData: { value: 'aaa' } })
|
||||
const inputWrapper = wrapper.find('input')
|
||||
const inputEl = inputWrapper.element
|
||||
|
||||
// Has the correct starting value
|
||||
expect(inputEl.value).toEqual('aaa')
|
||||
|
||||
// Emits an update event with the correct value when edited
|
||||
inputEl.value = 'bbb'
|
||||
inputWrapper.trigger('input')
|
||||
expect(wrapper.emitted().update).toEqual([['bbb']])
|
||||
|
||||
// Sets the input to the correct value when props change
|
||||
wrapper.setValue('ccc')
|
||||
expect(inputEl.value).toEqual('ccc')
|
||||
})
|
||||
|
||||
it('allows a type of "password"', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mount(BaseInputText, {
|
||||
propsData: { value: 'aaa', type: 'password' },
|
||||
})
|
||||
expect(consoleError).not.toBeCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('does NOT allow a type of "checkbox"', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mount(BaseInputText, {
|
||||
propsData: { value: 'aaa', type: 'checkbox' },
|
||||
})
|
||||
expect(consoleError.mock.calls[0][0]).toContain(
|
||||
'custom validator check failed for prop "type"'
|
||||
)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
48
ui/src/components/_base-input-text.vue
Normal file
48
ui/src/components/_base-input-text.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
export default {
|
||||
// Disable automatic attribute inheritance, so that $attrs are
|
||||
// passed to the <input>, even if it's not the root element.
|
||||
// https://vuejs.org/v2/guide/components-props.html#Disabling-Attribute-Inheritance
|
||||
inheritAttrs: false,
|
||||
// Change the v-model event name to `update` to avoid changing
|
||||
// the behavior of the native `input` event.
|
||||
// https://vuejs.org/v2/guide/components-custom-events.html#Customizing-Component-v-model
|
||||
model: {
|
||||
event: 'update',
|
||||
},
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
// Only allow types that essentially just render text boxes.
|
||||
validator(value) {
|
||||
return [
|
||||
'email',
|
||||
'number',
|
||||
'password',
|
||||
'search',
|
||||
'tel',
|
||||
'text',
|
||||
'url',
|
||||
].includes(value)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-input
|
||||
:type="type"
|
||||
:class="$style.input"
|
||||
v-bind="
|
||||
$attrs
|
||||
// https://vuejs.org/v2/guide/components-props.html#Disabling-Attribute-Inheritance
|
||||
"
|
||||
@input="$emit('update', $event.target.value)"
|
||||
v-on="
|
||||
$listeners
|
||||
// https://vuejs.org/v2/guide/components-custom-events.html#Binding-Native-Events-to-Components
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
139
ui/src/components/_base-link.unit.js
Normal file
139
ui/src/components/_base-link.unit.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import BaseLink from './_base-link.vue'
|
||||
|
||||
const mountBaseLink = (options = {}) => {
|
||||
return mount(BaseLink, {
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
functional: true,
|
||||
render(h, { slots, data }) {
|
||||
return <a data-router-link='true'>{slots().default}</a>
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: 'hello',
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
describe('@components/_base-link', () => {
|
||||
const originalConsoleWarn = global.console.warn
|
||||
let warning
|
||||
beforeEach(() => {
|
||||
warning = undefined
|
||||
global.console.warn = jest.fn((text) => {
|
||||
warning = text
|
||||
})
|
||||
})
|
||||
afterAll(() => {
|
||||
global.console.warn = originalConsoleWarn
|
||||
})
|
||||
|
||||
it('exports a valid component', () => {
|
||||
expect(BaseLink).toBeAComponent()
|
||||
})
|
||||
|
||||
it('warns about missing required props', () => {
|
||||
mountBaseLink()
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Invalid <BaseLink> props/)
|
||||
})
|
||||
|
||||
it('warns about an invalid href', () => {
|
||||
mountBaseLink({
|
||||
propsData: {
|
||||
href: '/some/local/path',
|
||||
},
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Invalid <BaseLink> href/)
|
||||
})
|
||||
|
||||
it('warns about an insecure href', () => {
|
||||
mountBaseLink({
|
||||
propsData: {
|
||||
href: 'http://google.com',
|
||||
},
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Insecure <BaseLink> href/)
|
||||
})
|
||||
|
||||
it('renders an anchor element when passed an `href` prop', () => {
|
||||
const externalUrl = 'https://google.com/'
|
||||
const { element } = mountBaseLink({
|
||||
propsData: {
|
||||
href: externalUrl,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.tagName).toEqual('A')
|
||||
expect(element.href).toEqual(externalUrl)
|
||||
expect(element.target).toEqual('_blank')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `name` prop', () => {
|
||||
const routeName = 'home'
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
name: routeName,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed `name` and `params` props', () => {
|
||||
const routeName = 'home'
|
||||
const routeParams = { foo: 'bar' }
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `to` prop', () => {
|
||||
const routeName = 'home'
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
to: {
|
||||
name: routeName,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `to` prop with `params`', () => {
|
||||
const routeName = 'home'
|
||||
const routeParams = { foo: 'bar' }
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
to: {
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: routeParams })
|
||||
})
|
||||
})
|
||||
81
ui/src/components/_base-link.vue
Normal file
81
ui/src/components/_base-link.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowInsecure: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
params: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
routerLinkTo({ name, params }) {
|
||||
return {
|
||||
name,
|
||||
params,
|
||||
...(this.to || {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.validateProps()
|
||||
},
|
||||
methods: {
|
||||
// Perform more complex prop validations than is possible
|
||||
// inside individual validator functions for each prop.
|
||||
validateProps() {
|
||||
if (process.env.NODE_ENV === 'production') return
|
||||
|
||||
if (this.href) {
|
||||
// Check for non-external URL in href.
|
||||
if (!/^\w+:/.test(this.href)) {
|
||||
return console.warn(
|
||||
`Invalid <BaseLink> href: ${this.href}.\nIf you're trying to link to a local URL, provide at least a name or to`
|
||||
)
|
||||
}
|
||||
// Check for insecure URL in href.
|
||||
if (!this.allowInsecure && !/^(https|mailto|tel):/.test(this.href)) {
|
||||
return console.warn(
|
||||
`Insecure <BaseLink> href: ${this.href}.\nWhen linking to external sites, always prefer https URLs. If this site does not offer SSL, explicitly add the allow-insecure attribute on <BaseLink>.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Check for insufficient props.
|
||||
if (!this.name && !this.to) {
|
||||
return console.warn(
|
||||
`Invalid <BaseLink> props:\n\n${JSON.stringify(
|
||||
this.$props,
|
||||
null,
|
||||
2
|
||||
)}\n\nEither a \`name\` or \`to\` is required for internal links, or an \`href\` for external links.`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="href" :href="href" target="_blank" v-bind="$attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else :to="routerLinkTo" v-bind="$attrs">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
36
ui/src/components/_globals.js
Normal file
36
ui/src/components/_globals.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Globally register all base components for convenience, because they
|
||||
// will be used very frequently. Components are registered using the
|
||||
// PascalCased version of their file name.
|
||||
|
||||
import Vue from 'vue'
|
||||
|
||||
// https://webpack.js.org/guides/dependency-management/#require-context
|
||||
const requireComponent = require.context(
|
||||
// Look for files in the current directory
|
||||
'.',
|
||||
// Do not look in subdirectories
|
||||
false,
|
||||
// Only include "_base-" prefixed .vue files
|
||||
/_base-[\w-]+\.vue$/
|
||||
)
|
||||
|
||||
// For each matching file name...
|
||||
requireComponent.keys().forEach((fileName) => {
|
||||
// Get the component config
|
||||
const componentConfig = requireComponent(fileName)
|
||||
// Get the PascalCase version of the component name
|
||||
const componentName = fileName
|
||||
// Remove the "./_" from the beginning
|
||||
.replace(/^\.\/_/, '')
|
||||
// Remove the file extension from the end
|
||||
.replace(/\.\w+$/, '')
|
||||
// Split up kebabs
|
||||
.split('-')
|
||||
// Upper case
|
||||
.map((kebab) => kebab.charAt(0).toUpperCase() + kebab.slice(1))
|
||||
// Concatenated
|
||||
.join('')
|
||||
|
||||
// Globally register the component
|
||||
Vue.component(componentName, componentConfig.default || componentConfig)
|
||||
})
|
||||
122
ui/src/components/createQuickEntry.vue
Normal file
122
ui/src/components/createQuickEntry.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import store from '@state/store'
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
data: function() {
|
||||
return {
|
||||
file: null,
|
||||
tryingToCreate: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
uploadButtonLabel() {
|
||||
if (this.isMobile) {
|
||||
if (this.file == null) {
|
||||
return 'Upload/Click Photo'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
if (this.file == null) {
|
||||
return 'Upload Photo'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
createQuickEntry() {
|
||||
if (this.file == null) {
|
||||
return
|
||||
}
|
||||
this.tryingToCreate = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', this.file, this.file.name)
|
||||
axios
|
||||
.post(`/api/quickEntries`, formData)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Quick Entry Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.file = null
|
||||
store.dispatch('vehicles/fetchQuickEntries', { force: true }).then((data) => {
|
||||
this.quickEntries = data
|
||||
})
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section box">
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<p class="title">Quick Entry</p>
|
||||
<p class="subtitle"
|
||||
>Take a pic of the invoice or the fuel pump display to make an entry later.</p
|
||||
></div
|
||||
>
|
||||
<div class="column is-one-third is-flex is-align-content-center">
|
||||
<form @submit.prevent="createQuickEntry">
|
||||
<div class="columns"
|
||||
><div class="column">
|
||||
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
|
||||
<b-upload v-model="file" class="file-label" accept="image/*">
|
||||
<span class="file-cta">
|
||||
<b-icon class="file-icon" icon="upload"></b-icon>
|
||||
<span class="file-label">{{ uploadButtonLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="file"
|
||||
class="file-name"
|
||||
:class="isMobile ? 'file-name-mobile' : 'file-name-desktop'"
|
||||
>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
value="Upload File"
|
||||
class="control"
|
||||
>
|
||||
Upload File
|
||||
</b-button>
|
||||
</div></div
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.file-name-desktop {
|
||||
max-width: 9em;
|
||||
}
|
||||
.file-name-mobile {
|
||||
max-width: 12em;
|
||||
}
|
||||
</style>
|
||||
57
ui/src/components/nav-bar-routes.unit.js
Normal file
57
ui/src/components/nav-bar-routes.unit.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import NavBarRoutes from './nav-bar-routes.vue'
|
||||
|
||||
const mountRoutes = (options) => {
|
||||
return mount(
|
||||
{
|
||||
render(h) {
|
||||
return (
|
||||
<ul>
|
||||
<NavBarRoutes {...{ props: options.propsData }} />
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
stubs: {
|
||||
BaseLink: {
|
||||
functional: true,
|
||||
render(h, { slots }) {
|
||||
return <a>{slots().default}</a>
|
||||
},
|
||||
},
|
||||
...options.stubs,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('@components/nav-bar-routes', () => {
|
||||
it('correctly renders routes with text titles', () => {
|
||||
const { element } = mountRoutes({
|
||||
propsData: {
|
||||
routes: [
|
||||
{
|
||||
name: 'aaa',
|
||||
title: 'bbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(element.textContent).toEqual('bbb')
|
||||
})
|
||||
|
||||
it('correctly renders routes with function titles', () => {
|
||||
const { element } = mountRoutes({
|
||||
propsData: {
|
||||
routes: [
|
||||
{
|
||||
name: 'aaa',
|
||||
title: () => 'bbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(element.textContent).toEqual('bbb')
|
||||
})
|
||||
})
|
||||
62
ui/src/components/nav-bar-routes.vue
Normal file
62
ui/src/components/nav-bar-routes.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
// Allows stubbing BaseLink in unit tests
|
||||
const BaseLink = 'BaseLink'
|
||||
|
||||
export default {
|
||||
// Functional components are stateless, meaning they can't
|
||||
// have data, computed properties, etc and they have no
|
||||
// `this` context.
|
||||
functional: true,
|
||||
props: {
|
||||
routes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
// Render functions are an alternative to templates
|
||||
render(h, { props, $style = {} }) {
|
||||
function getRouteTitle(route) {
|
||||
return typeof route.title === 'function' ? route.title() : route.title
|
||||
}
|
||||
|
||||
function getRouteBadge(route) {
|
||||
if (!route.badge) {
|
||||
return false
|
||||
}
|
||||
return typeof route.badge === 'function' ? route.badge() : route.badge
|
||||
}
|
||||
|
||||
// Functional components are the only components allowed
|
||||
// to return an array of children, rather than a single
|
||||
// root node.
|
||||
return props.routes.map((route) => {
|
||||
if (getRouteBadge(route) > 0) {
|
||||
return (
|
||||
<BaseLink
|
||||
tag='b-navbar-item'
|
||||
key={route.name}
|
||||
to={route}
|
||||
exact-active-class={$style.active}
|
||||
>
|
||||
<a>{getRouteTitle(route)}</a>
|
||||
<b-tag rounded type='is-danger is-light'>
|
||||
{getRouteBadge(route)}
|
||||
</b-tag>
|
||||
</BaseLink>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<BaseLink
|
||||
tag='b-navbar-item'
|
||||
key={route.name}
|
||||
to={route}
|
||||
exact-active-class={$style.active}
|
||||
>
|
||||
<a>{getRouteTitle(route)}</a>
|
||||
</BaseLink>
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
28
ui/src/components/nav-bar.unit.js
Normal file
28
ui/src/components/nav-bar.unit.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import NavBar from './nav-bar.vue'
|
||||
|
||||
describe('@components/nav-bar', () => {
|
||||
it(`displays the user's name in the profile link`, () => {
|
||||
const { vm } = shallowMount(
|
||||
NavBar,
|
||||
createComponentMocks({
|
||||
store: {
|
||||
auth: {
|
||||
state: {
|
||||
currentUser: {
|
||||
name: 'My Name',
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
loggedIn: () => true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const profileRoute = vm.loggedInNavRoutes.find(
|
||||
(route) => route.name === 'profile'
|
||||
)
|
||||
expect(profileRoute.title()).toEqual('Logged in as My Name')
|
||||
})
|
||||
})
|
||||
81
ui/src/components/nav-bar.vue
Normal file
81
ui/src/components/nav-bar.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
import { authComputed } from '@state/helpers'
|
||||
import { mapGetters } from 'vuex'
|
||||
import NavBarRoutes from './nav-bar-routes.vue'
|
||||
|
||||
export default {
|
||||
components: { NavBarRoutes },
|
||||
data() {
|
||||
return {
|
||||
persistentNavRoutes: [
|
||||
{
|
||||
name: 'home',
|
||||
title: 'Home',
|
||||
},
|
||||
],
|
||||
loggedInNavRoutes: [
|
||||
{
|
||||
name: 'quickEntries',
|
||||
title: () => 'Quick Entries',
|
||||
badge: () => this.unprocessedQuickEntries.length,
|
||||
},
|
||||
// {
|
||||
// name: 'profile',
|
||||
// title: () => 'Logged in as ' + this.currentUser.name,
|
||||
// },
|
||||
{
|
||||
name: 'settings',
|
||||
title: 'Settings',
|
||||
},
|
||||
{
|
||||
name: 'logout',
|
||||
title: 'Log out',
|
||||
},
|
||||
],
|
||||
loggedOutNavRoutes: [
|
||||
{
|
||||
name: 'login',
|
||||
title: 'Log in',
|
||||
},
|
||||
],
|
||||
adminNavRoutes: [
|
||||
{
|
||||
name: 'site-settings',
|
||||
title: 'Site Settings',
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
title: 'Users',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...authComputed,
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries']),
|
||||
isAdmin() {
|
||||
return this.loggedIn && this.currentUser.role === 'ADMIN'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<b-navbar class="" spaced>
|
||||
<template v-slot:brand>
|
||||
<b-navbar-item tag="router-link" :to="{ path: '/' }">
|
||||
<h1 class="title" style="font-family:Arial Black">Hammond</h1>
|
||||
</b-navbar-item>
|
||||
</template>
|
||||
<template v-slot:end>
|
||||
<NavBarRoutes :routes="persistentNavRoutes" />
|
||||
<NavBarRoutes v-if="loggedIn" :routes="loggedInNavRoutes" />
|
||||
<NavBarRoutes v-else :routes="loggedOutNavRoutes" />
|
||||
<b-navbar-dropdown v-if="loggedIn && isAdmin" label="Admin">
|
||||
<NavBarRoutes :routes="adminNavRoutes" />
|
||||
</b-navbar-dropdown>
|
||||
</template>
|
||||
</b-navbar>
|
||||
</div>
|
||||
</template>
|
||||
68
ui/src/components/quickEntryDisplay.vue
Normal file
68
ui/src/components/quickEntryDisplay.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import { parseAndFormatDateTime } from '@utils/format-date'
|
||||
|
||||
export default {
|
||||
model: {
|
||||
prop: 'quickEntry',
|
||||
event: 'change',
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
quickEntry: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries', 'processedQuickEntries']),
|
||||
},
|
||||
methods: {
|
||||
parseAndFormatDateTime(date) {
|
||||
return parseAndFormatDateTime(date)
|
||||
},
|
||||
showQuickEntry(entry) {
|
||||
const h = this.$createElement
|
||||
const vnode = h('p', { class: 'image' }, [
|
||||
h('img', {
|
||||
attrs: {
|
||||
src: `/api/attachments/${entry.attachmentId}/file?access_token=${this.user.token}`,
|
||||
},
|
||||
}),
|
||||
])
|
||||
this.$buefy.modal.open({
|
||||
content: [vnode],
|
||||
})
|
||||
this.$emit('change', entry)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="level">
|
||||
<b-field class="level-right">
|
||||
<b-select
|
||||
v-if="unprocessedQuickEntries.length"
|
||||
v-model="quickEntry"
|
||||
placeholder="Refer quick entry"
|
||||
expanded
|
||||
@input="showQuickEntry($event)"
|
||||
>
|
||||
<option v-for="option in unprocessedQuickEntries" :key="option.id" :value="option">
|
||||
Taken: {{ parseAndFormatDateTime(option.createdAt) }}
|
||||
</option>
|
||||
</b-select>
|
||||
<p class="control">
|
||||
<b-button v-if="quickEntry" type="is-primary" @click="showQuickEntry(quickEntry)"
|
||||
>Show</b-button
|
||||
></p
|
||||
>
|
||||
</b-field>
|
||||
</div></template
|
||||
>
|
||||
68
ui/src/components/shareVehicle.vue
Normal file
68
ui/src/components/shareVehicle.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import store from '@state/store'
|
||||
import { sortBy } from 'lodash'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
models: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
store
|
||||
.dispatch('users/users')
|
||||
.then((allUsers) => {
|
||||
store.dispatch('vehicles/fetchUsersByVehicleId', { vehicleId: this.vehicle.id }).then((data) => {
|
||||
const arr = []
|
||||
for (const user of allUsers) {
|
||||
const toAdd = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isShared: false,
|
||||
isOwner: false,
|
||||
}
|
||||
for (const mappedUser of data) {
|
||||
if (mappedUser.userId === user.id) {
|
||||
toAdd.isShared = true
|
||||
toAdd.isOwner = mappedUser.isOwner
|
||||
}
|
||||
}
|
||||
arr.push(toAdd)
|
||||
}
|
||||
this.models = sortBy(arr, ['isOwner', 'name'])
|
||||
})
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
methods: {
|
||||
changeShareStatus(model) {
|
||||
var url = `/api/vehicles/${this.vehicle.id}/users/${model.id}`
|
||||
if (model.isShared) {
|
||||
axios.post(url, {}).then((data) => {})
|
||||
} else {
|
||||
axios.delete(url).then((data) => {})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box">
|
||||
<h1 class="subtitle">Share {{ vehicle.nickname }}</h1>
|
||||
<section>
|
||||
<b-field v-for="model in models" :key="model.id">
|
||||
<b-switch v-model="model.isShared" :disabled="model.isOwner" @input="changeShareStatus(model)">
|
||||
{{ model.name }}
|
||||
</b-switch>
|
||||
</b-field>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
149
ui/src/components/statsWidget.vue
Normal file
149
ui/src/components/statsWidget.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { addDays, addMonths } from 'date-fns'
|
||||
import currencyFormtter from 'currency-formatter'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
dateRangeOptions: [
|
||||
{ label: 'This week', value: 'this_week' },
|
||||
{ label: 'This month', value: 'this_month' },
|
||||
{ label: 'Past 30 days', value: 'past_30_days' },
|
||||
{ label: 'Past 3 months', value: 'past_3_months' },
|
||||
{ label: 'This year', value: 'this_year' },
|
||||
{ label: 'All Time', value: 'all_time' },
|
||||
],
|
||||
dateRangeOption: 'past_30_days',
|
||||
stats: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
summaryObject() {
|
||||
if (this.stats == null) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(0, this.user.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
return this.stats.map((x) => {
|
||||
return [
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(x.expenditureTotal, x.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(x.expenditureFillups, x.currency)} (${x.countFillups})`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(x.expenditureExpenses, x.currency)} (${x.countExpenses})`,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dateRangeOption(newOne, old) {
|
||||
if (newOne === old) {
|
||||
return
|
||||
}
|
||||
this.getStats()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getStats()
|
||||
},
|
||||
methods: {
|
||||
formatCurrency(number, currencyCode) {
|
||||
if (!currencyCode) {
|
||||
currencyCode = this.me.currency
|
||||
}
|
||||
return currencyFormtter.format(number, { code: currencyCode })
|
||||
},
|
||||
getStats() {
|
||||
axios
|
||||
.get('/api/me/stats', {
|
||||
params: {
|
||||
start: this.getStartDate(),
|
||||
end: new Date(),
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
this.stats = data.data
|
||||
})
|
||||
},
|
||||
getStartDate() {
|
||||
const toDate = new Date()
|
||||
switch (this.dateRangeOption) {
|
||||
case 'this_week':
|
||||
var currentDayOfWeek = toDate.getDay()
|
||||
var toSubtract = 0
|
||||
if (currentDayOfWeek === 0) {
|
||||
toSubtract = -6
|
||||
}
|
||||
if (currentDayOfWeek > 1) {
|
||||
toSubtract = -1 * (currentDayOfWeek - 1)
|
||||
}
|
||||
return addDays(toDate, toSubtract)
|
||||
case 'this_month':
|
||||
return new Date(toDate.getFullYear(), toDate.getMonth(), 1)
|
||||
case 'past_30_days':
|
||||
return addDays(toDate, -30)
|
||||
case 'past_3_months':
|
||||
return addMonths(toDate, -3)
|
||||
case 'this_year':
|
||||
return new Date(toDate.getFullYear(), 1, 1)
|
||||
case 'all_time':
|
||||
return new Date(1969, 4, 20)
|
||||
default:
|
||||
return new Date(1969, 4, 20)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns">
|
||||
<div class="column" :class="isMobile ? 'has-text-centered' : ''"> <h1 class="title">Stats</h1></div>
|
||||
<div class="column">
|
||||
<b-select v-model="dateRangeOption" class="is-pulled-right is-medium">
|
||||
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</b-select></div
|
||||
>
|
||||
</div>
|
||||
<div v-for="(currencyLevel, index) in summaryObject" :key="index" class="level box">
|
||||
<div v-for="item in currencyLevel" :key="item.label" class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">{{ item.label }}</p>
|
||||
<p class="title is-4">{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
16
ui/src/design/_colors.scss
Normal file
16
ui/src/design/_colors.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
// CONTENT
|
||||
$color-body-bg: #f9f7f5;
|
||||
$color-text: #444;
|
||||
$color-heading-text: #35495e;
|
||||
|
||||
// LINKS
|
||||
$color-link-text: #39a275;
|
||||
$color-link-text-active: $color-text;
|
||||
|
||||
// INPUTS
|
||||
$color-input-border: lighten($color-heading-text, 50%);
|
||||
|
||||
// BUTTONS
|
||||
$color-button-bg: $color-link-text;
|
||||
$color-button-disabled-bg: darken(desaturate($color-button-bg, 20%), 10%);
|
||||
$color-button-text: white;
|
||||
1
ui/src/design/_durations.scss
Normal file
1
ui/src/design/_durations.scss
Normal file
@@ -0,0 +1 @@
|
||||
$duration-animation-base: 300ms;
|
||||
21
ui/src/design/_fonts.scss
Normal file
21
ui/src/design/_fonts.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
$system-default-font-family: -apple-system, 'BlinkMacSystemFont', 'Segoe UI',
|
||||
'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol';
|
||||
|
||||
$heading-font-family: $system-default-font-family;
|
||||
$heading-font-weight: 600;
|
||||
|
||||
$content-font-family: $system-default-font-family;
|
||||
$content-font-weight: 400;
|
||||
|
||||
%font-heading {
|
||||
font-family: $heading-font-family;
|
||||
font-weight: $heading-font-weight;
|
||||
color: $color-heading-text;
|
||||
}
|
||||
|
||||
%font-content {
|
||||
font-family: $content-font-family;
|
||||
font-weight: $content-font-weight;
|
||||
color: $color-text;
|
||||
}
|
||||
6
ui/src/design/_layers.scss
Normal file
6
ui/src/design/_layers.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
$layer-negative-z-index: -1;
|
||||
$layer-page-z-index: 1;
|
||||
$layer-dropdown-z-index: 2;
|
||||
$layer-modal-z-index: 3;
|
||||
$layer-popover-z-index: 4;
|
||||
$layer-tooltip-z-index: 5;
|
||||
19
ui/src/design/_sizes.scss
Normal file
19
ui/src/design/_sizes.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
// GRID
|
||||
$size-grid-padding: 1.3rem;
|
||||
|
||||
// CONTENT
|
||||
$size-content-width-max: 50rem;
|
||||
$size-content-width-min: 25rem;
|
||||
|
||||
// INPUTS
|
||||
$size-input-padding-vertical: 0.75em;
|
||||
$size-input-padding-horizontal: 1em;
|
||||
$size-input-padding: $size-input-padding-vertical $size-input-padding-horizontal;
|
||||
$size-input-border: 1px;
|
||||
$size-input-border-radius: (1em + $size-input-padding-vertical * 2) / 10;
|
||||
|
||||
// BUTTONS
|
||||
$size-button-padding-vertical: $size-grid-padding / 2;
|
||||
$size-button-padding-horizontal: $size-grid-padding / 1.5;
|
||||
$size-button-padding: $size-button-padding-vertical
|
||||
$size-button-padding-horizontal;
|
||||
416
ui/src/design/_typography.scss
Normal file
416
ui/src/design/_typography.scss
Normal file
@@ -0,0 +1,416 @@
|
||||
// Interpolate v1.0
|
||||
|
||||
// This mixin generates CSS for interpolation of length properties.
|
||||
// It has 5 required values, including the target property, initial
|
||||
// screen size, initial value, final screen size and final value.
|
||||
|
||||
// It has two optional values which include an easing property,
|
||||
// which is a string, representing a CSS animation-timing-function
|
||||
// and finally a number of bending-points, that determines how many
|
||||
// interpolations steps are applied along the easing function.
|
||||
|
||||
// Author: Mike Riethmuller - @MikeRiethmuller
|
||||
// More information: http://codepen.io/MadeByMike/pen/a2249946658b139b7625b2a58cf03a65?editors=0100
|
||||
|
||||
///
|
||||
/// @param {String} $property - The CSS property to interpolate
|
||||
/// @param {Unit} $min-screen - A CSS length unit
|
||||
/// @param {Unit} $min-value - A CSS length unit
|
||||
/// @param {Unit} $max-screen - Value to be parsed
|
||||
/// @param {Unit} $max-value - Value to be parsed
|
||||
/// @param {String} $easing - Value to be parsed
|
||||
/// @param {Integer} $bending-points - Value to be parsed
|
||||
///
|
||||
|
||||
// Examples on line 258
|
||||
|
||||
// Issues:
|
||||
|
||||
// - kubic-bezier requires whitespace
|
||||
// - kubic-bezier cannot parse negative values
|
||||
|
||||
// stylelint-disable scss/dollar-variable-pattern
|
||||
@mixin typography-interpolate(
|
||||
$property,
|
||||
$min-screen,
|
||||
$min-value,
|
||||
$max-screen,
|
||||
$max-value,
|
||||
$easing: 'linear',
|
||||
$bending-points: 2
|
||||
) {
|
||||
// Default Easing 'Linear'
|
||||
$p0: 0;
|
||||
$p1: 0;
|
||||
$p2: 1;
|
||||
$p3: 1;
|
||||
|
||||
// Parse Cubic Bezier string
|
||||
@if (str-slice($easing, 1, 12) == 'kubic-bezier') {
|
||||
// Get the values between the brackets
|
||||
// TODO: Deal with different whitespace
|
||||
$i: str-index($easing, ')'); // Get index of closing bracket
|
||||
$values: str-slice($easing, 14, $i - 1); // Extract values between brackts
|
||||
$list: typography-explode($values, ', '); // Split the values into a list
|
||||
|
||||
@debug ($list);
|
||||
|
||||
// Cast values to numebrs
|
||||
$p0: typography-number(nth($list, 1));
|
||||
$p1: typography-number(nth($list, 2));
|
||||
$p2: typography-number(nth($list, 3));
|
||||
$p3: typography-number(nth($list, 4));
|
||||
}
|
||||
|
||||
@if ($easing == 'ease') {
|
||||
$p0: 0.25;
|
||||
$p1: 1;
|
||||
$p2: 0.25;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
@if ($easing == 'ease-in-out') {
|
||||
$p0: 0.42;
|
||||
$p1: 0;
|
||||
$p2: 0.58;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
@if ($easing == 'ease-in') {
|
||||
$p0: 0.42;
|
||||
$p1: 0;
|
||||
$p2: 1;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
@if ($easing == 'ease-out') {
|
||||
$p0: 0;
|
||||
$p1: 0;
|
||||
$p2: 0.58;
|
||||
$p3: 1;
|
||||
}
|
||||
|
||||
#{$property}: $min-value;
|
||||
|
||||
@if ($easing == 'linear' or $bending-points < 1) {
|
||||
@media screen and (min-width: $min-screen) {
|
||||
#{$property}: typography-calc-interpolation(
|
||||
$min-screen,
|
||||
$min-value,
|
||||
$max-screen,
|
||||
$max-value
|
||||
);
|
||||
}
|
||||
} @else {
|
||||
// Loop through bending points
|
||||
$t: 1 / ($bending-points + 1);
|
||||
$i: 1;
|
||||
$prev-screen: $min-screen;
|
||||
$prev-value: $min-value;
|
||||
|
||||
@while $t * $i <= 1 {
|
||||
$bending-point: $t * $i;
|
||||
$value: typography-cubic-bezier($p0, $p1, $p2, $p3, $bending-point);
|
||||
$screen-int: typography-lerp($min-screen, $max-screen, $bending-point);
|
||||
$value-int: typography-lerp($min-value, $max-value, $value);
|
||||
|
||||
@media screen and (min-width: $prev-screen) {
|
||||
#{$property}: typography-calc-interpolation(
|
||||
$prev-screen,
|
||||
$prev-value,
|
||||
$screen-int,
|
||||
$value-int
|
||||
);
|
||||
}
|
||||
|
||||
$prev-screen: $screen-int;
|
||||
$prev-value: $value-int;
|
||||
$i: $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $max-screen) {
|
||||
#{$property}: $max-value;
|
||||
}
|
||||
}
|
||||
|
||||
// Requires several helper functions including: pow, calc-interpolation, kubic-bezier, number and explode
|
||||
|
||||
// Math functions:
|
||||
|
||||
// Linear interpolations in CSS as a Sass function
|
||||
// Author: Mike Riethmuller | https://madebymike.com.au/writing/precise-control-responsive-typography/ I
|
||||
|
||||
@function typography-calc-interpolation(
|
||||
$min-screen,
|
||||
$min-value,
|
||||
$max-screen,
|
||||
$max-value
|
||||
) {
|
||||
$a: ($max-value - $min-value) / ($max-screen - $min-screen);
|
||||
$b: $min-value - $a * $min-screen;
|
||||
|
||||
$sign: '+';
|
||||
|
||||
@if ($b < 0) {
|
||||
$sign: '-';
|
||||
$b: abs($b);
|
||||
}
|
||||
|
||||
@return calc(#{$a * 100}vw #{$sign} #{$b});
|
||||
}
|
||||
|
||||
// This is a crude Sass port webkits cubic-bezier function. Looking to simplify this if you can help.
|
||||
@function typography-solve-bexier-x($p1x, $p1y, $p2x, $p2y, $x) {
|
||||
$cx: 3 * $p1x;
|
||||
$bx: 3 * ($p2x - $p1x) - $cx;
|
||||
$ax: 1 - $cx - $bx;
|
||||
|
||||
$t0: 0;
|
||||
$t1: 1;
|
||||
$t2: $x;
|
||||
$x2: 0;
|
||||
$res: 1000;
|
||||
|
||||
@while ($t0 < $t1 or $break) {
|
||||
$x2: (($ax * $t2 + $bx) * $t2 + $cx) * $t2;
|
||||
|
||||
@if (abs($x2 - $x) < $res) {
|
||||
@return $t2;
|
||||
}
|
||||
|
||||
@if ($x > $x2) {
|
||||
$t0: $t2;
|
||||
} @else {
|
||||
$t1: $t2;
|
||||
}
|
||||
$t2: ($t1 - $t0) * 0.5 + $t0;
|
||||
}
|
||||
|
||||
@return $t2;
|
||||
}
|
||||
|
||||
@function typography-cubic-bezier($p1x, $p1y, $p2x, $p2y, $x) {
|
||||
$cy: 3 * $p1y;
|
||||
$by: 3 * ($p2y - $p1y) - $cy;
|
||||
$ay: 1 - $cy - $by;
|
||||
$t: typography-solve-bexier-x($p1x, $p1y, $p2x, $p2y, $x);
|
||||
|
||||
@return (($ay * $t + $by) * $t + $cy) * $t;
|
||||
}
|
||||
|
||||
// A stright up lerp
|
||||
// Credit: Ancient Greeks possibly Hipparchus of Rhodes
|
||||
@function typography-lerp($a, $b, $t) {
|
||||
@return $a + ($b - $a) * $t;
|
||||
}
|
||||
|
||||
// String functions:
|
||||
|
||||
// Cast string to number
|
||||
// Credit: Hugo Giraudel | https://www.sassmeister.com/gist/9fa19d254864f33d4a80
|
||||
@function typography-number($value) {
|
||||
@if type-of($value) == 'number' {
|
||||
@return $value;
|
||||
} @else if type-of($value) != 'string' {
|
||||
$_: log('Value for `to-number` should be a number or a string.');
|
||||
}
|
||||
|
||||
$result: 0;
|
||||
$digits: 0;
|
||||
$minus: str-slice($value, 1, 1) == '-';
|
||||
$numbers: (
|
||||
'0': 0,
|
||||
'1': 1,
|
||||
'2': 2,
|
||||
'3': 3,
|
||||
'4': 4,
|
||||
'5': 5,
|
||||
'6': 6,
|
||||
'7': 7,
|
||||
'8': 8,
|
||||
'9': 9,
|
||||
);
|
||||
|
||||
@for $i from if($minus, 2, 1) through str-length($value) {
|
||||
$character: str-slice($value, $i, $i);
|
||||
|
||||
@if not(index(map-keys($numbers), $character) or $character == '.') {
|
||||
@return to-length(if($minus, -$result, $result), str-slice($value, $i));
|
||||
}
|
||||
|
||||
@if $character == '.' {
|
||||
$digits: 1;
|
||||
} @else if $digits == 0 {
|
||||
$result: $result * 10 + map-get($numbers, $character);
|
||||
} @else {
|
||||
$digits: $digits * 10;
|
||||
$result: $result + map-get($numbers, $character) / $digits;
|
||||
}
|
||||
}
|
||||
|
||||
@return if($minus, -$result, $result);
|
||||
}
|
||||
|
||||
// Explode a string by a delimiter
|
||||
// Credit: https://gist.github.com/danielpchen/3677421ea15dcf2579ff
|
||||
@function typography-explode($string, $delimiter) {
|
||||
$result: ();
|
||||
|
||||
@if $delimiter == '' {
|
||||
@for $i from 1 through str-length($string) {
|
||||
$result: append($result, str-slice($string, $i, $i));
|
||||
}
|
||||
|
||||
@return $result;
|
||||
}
|
||||
$exploding: true;
|
||||
|
||||
@while $exploding {
|
||||
$d-index: str-index($string, $delimiter);
|
||||
|
||||
@if $d-index {
|
||||
@if $d-index > 1 {
|
||||
$result: append($result, str-slice($string, 1, $d-index - 1));
|
||||
$string: str-slice($string, $d-index + str-length($delimiter));
|
||||
} @else if $d-index == 1 {
|
||||
$string: str-slice($string, 1, $d-index + str-length($delimiter));
|
||||
} @else {
|
||||
$result: append($result, $string);
|
||||
$exploding: false;
|
||||
}
|
||||
} @else {
|
||||
$result: append($result, $string);
|
||||
$exploding: false;
|
||||
}
|
||||
}
|
||||
|
||||
@return $result;
|
||||
}
|
||||
|
||||
// Using vertical rhythm methods from https://scotch.io/tutorials/aesthetic-sass-3-typography-and-vertical-rhythm
|
||||
// Using perfect 8/9 for low contrast and perfect fifth 2/3 for high
|
||||
$typography-type-scale: (
|
||||
-1: 0.889rem,
|
||||
0: 1rem,
|
||||
1: 1.125rem,
|
||||
2: 1.266rem,
|
||||
3: 1.424rem
|
||||
);
|
||||
|
||||
@function typography-type-scale($level) {
|
||||
@if map-has-key($typography-type-scale, $level) {
|
||||
@return map-get($typography-type-scale, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $typography-type-scale.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
$typography-type-scale-contrast: (
|
||||
-1: 1rem,
|
||||
0: 1.3333rem,
|
||||
1: 1.777rem,
|
||||
2: 2.369rem,
|
||||
3: 3.157rem
|
||||
);
|
||||
|
||||
@function typography-type-scale-contrast($level) {
|
||||
@if map-has-key($typography-type-scale-contrast, $level) {
|
||||
@return map-get($typography-type-scale-contrast, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $typography-type-scale-contrast.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
$typography-base-font-size: 1rem;
|
||||
$typography-base-line-height: $typography-base-font-size * 1.25;
|
||||
|
||||
$typography-line-heights: (
|
||||
-1: $typography-base-line-height,
|
||||
0: $typography-base-line-height,
|
||||
1: $typography-base-line-height * 1.5,
|
||||
2: $typography-base-line-height * 1.5,
|
||||
3: $typography-base-line-height * 1.5
|
||||
);
|
||||
|
||||
@function typography-line-height($level) {
|
||||
@if map-has-key($typography-line-heights, $level) {
|
||||
@return map-get($typography-line-heights, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $line-height.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
$typography-base-line-height-contrast: $typography-base-line-height;
|
||||
|
||||
$typography-line-heights-contrast: (
|
||||
-1: $typography-base-line-height-contrast,
|
||||
0: $typography-base-line-height-contrast * 2,
|
||||
1: $typography-base-line-height-contrast * 2,
|
||||
2: $typography-base-line-height-contrast * 2,
|
||||
3: $typography-base-line-height * 3
|
||||
);
|
||||
|
||||
@function typography-line-height-contrast($level) {
|
||||
@if map-has-key($typography-line-heights-contrast, $level) {
|
||||
@return map-get($typography-line-heights-contrast, $level);
|
||||
}
|
||||
|
||||
@warn 'Unknown `#{$level}` in $typography-line-heights-contrast.';
|
||||
|
||||
@return null;
|
||||
}
|
||||
|
||||
// Mixing these two sets of mixins ala Rachel:
|
||||
@mixin typography-got-rhythm($level: 0) {
|
||||
@include typography-interpolate(
|
||||
'font-size',
|
||||
$size-content-width-min,
|
||||
typography-type-scale($level),
|
||||
$size-content-width-max,
|
||||
typography-type-scale-contrast($level)
|
||||
);
|
||||
@include typography-interpolate(
|
||||
'line-height',
|
||||
$size-content-width-min,
|
||||
typography-line-height($level),
|
||||
$size-content-width-max,
|
||||
typography-line-height-contrast($level)
|
||||
);
|
||||
}
|
||||
|
||||
%typography-xxlarge {
|
||||
@include typography-got-rhythm(3);
|
||||
|
||||
@extend %font-heading;
|
||||
}
|
||||
|
||||
%typography-xlarge {
|
||||
@include typography-got-rhythm(2);
|
||||
|
||||
@extend %font-heading;
|
||||
}
|
||||
|
||||
%typography-large {
|
||||
@include typography-got-rhythm(1);
|
||||
|
||||
@extend %font-heading;
|
||||
}
|
||||
|
||||
%typography-medium {
|
||||
@include typography-got-rhythm(0);
|
||||
|
||||
@extend %font-content;
|
||||
}
|
||||
|
||||
%typography-small {
|
||||
@include typography-got-rhythm(-1);
|
||||
|
||||
@extend %font-content;
|
||||
}
|
||||
22
ui/src/design/index.scss
Normal file
22
ui/src/design/index.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
@import 'colors';
|
||||
@import 'durations';
|
||||
@import 'fonts';
|
||||
@import 'layers';
|
||||
@import 'sizes';
|
||||
@import 'typography';
|
||||
|
||||
:export {
|
||||
// Any values that need to be accessible from JavaScript
|
||||
// outside of a Vue component can be defined here, prefixed
|
||||
// with `global-` to avoid conflicts with classes. For
|
||||
// example:
|
||||
//
|
||||
// global-grid-padding: $size-grid-padding;
|
||||
//
|
||||
// Then in a JavaScript file, you can import this object
|
||||
// as you would normally with:
|
||||
//
|
||||
// import design from '@design'
|
||||
//
|
||||
// console.log(design['global-grid-padding'])
|
||||
}
|
||||
76
ui/src/main.js
Normal file
76
ui/src/main.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import Vue from 'vue'
|
||||
import Buefy from 'buefy'
|
||||
import router from '@router'
|
||||
import store from '@state/store'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faCheck,
|
||||
faTimes,
|
||||
faArrowUp,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faCalendar,
|
||||
faEdit,
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faUpload,
|
||||
faExclamationCircle,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import App from './app.vue'
|
||||
|
||||
// Globally register all `_base`-prefixed components
|
||||
import '@components/_globals'
|
||||
|
||||
import 'buefy/dist/buefy.css'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
Vue.component('vue-fontawesome', FontAwesomeIcon)
|
||||
library.add(
|
||||
faCheck,
|
||||
faTimes,
|
||||
faArrowUp,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faCalendar,
|
||||
faEdit,
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faUpload,
|
||||
faExclamationCircle,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash
|
||||
)
|
||||
Vue.use(Buefy, {
|
||||
defaultIconComponent: 'vue-fontawesome',
|
||||
defaultIconPack: 'fas',
|
||||
})
|
||||
|
||||
// Don't warn about using the dev version of Vue in development.
|
||||
Vue.config.productionTip = process.env.NODE_ENV === 'production'
|
||||
|
||||
// If running inside Cypress...
|
||||
if (process.env.VUE_APP_TEST === 'e2e') {
|
||||
// Ensure tests fail when Vue emits an error.
|
||||
Vue.config.errorHandler = window.Cypress.cy.onUncaughtException
|
||||
}
|
||||
|
||||
const app = new Vue({
|
||||
router,
|
||||
store,
|
||||
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app')
|
||||
|
||||
// If running e2e tests...
|
||||
if (process.env.VUE_APP_TEST === 'e2e') {
|
||||
// Attach the app to the window, which can be useful
|
||||
// for manually setting state in Cypress commands
|
||||
// such as `cy.logIn()`.
|
||||
window.__app__ = app
|
||||
}
|
||||
135
ui/src/router/index.js
Normal file
135
ui/src/router/index.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
// https://github.com/declandewet/vue-meta
|
||||
import VueMeta from 'vue-meta'
|
||||
// Adds a loading bar at the top during page loads.
|
||||
import NProgress from 'nprogress/nprogress'
|
||||
import store from '@state/store'
|
||||
import routes from './routes'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
Vue.use(VueMeta, {
|
||||
// The component option name that vue-meta looks for meta info on.
|
||||
keyName: 'page',
|
||||
})
|
||||
|
||||
const router = new VueRouter({
|
||||
routes,
|
||||
// Use the HTML5 history API (i.e. normal-looking routes)
|
||||
// instead of routes with hashes (e.g. example.com/#/about).
|
||||
// This may require some server configuration in production:
|
||||
// https://router.vuejs.org/en/essentials/history-mode.html#example-server-configurations
|
||||
mode: 'history',
|
||||
// Simulate native-like scroll behavior when navigating to a new
|
||||
// route and using back/forward buttons.
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Before each route evaluates...
|
||||
router.beforeEach((routeTo, routeFrom, next) => {
|
||||
// If this isn't an initial page load...
|
||||
if (routeFrom.name !== null) {
|
||||
// Start the route progress bar.
|
||||
NProgress.start()
|
||||
}
|
||||
|
||||
// Check if auth is required on this route
|
||||
// (including nested routes).
|
||||
const authRequired = routeTo.matched.some((route) => route.meta.authRequired)
|
||||
|
||||
// If auth isn't required for the route, just continue.
|
||||
if (!authRequired) return next()
|
||||
|
||||
// If auth is required and the user is logged in...
|
||||
if (store.getters['auth/loggedIn']) {
|
||||
// Validate the local user token...
|
||||
return store.dispatch('auth/validate').then((validUser) => {
|
||||
// Then continue if the token still represents a valid user,
|
||||
// otherwise redirect to login.
|
||||
|
||||
if (!validUser) {
|
||||
redirectToLogin()
|
||||
}
|
||||
const rolesRequired = routeTo.matched.some((route) => route.meta.roles)
|
||||
|
||||
if (!rolesRequired) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const roles = routeTo.matched.find((route) => route.meta.roles).meta.roles
|
||||
|
||||
roles.some((x) => x === validUser.role) ? next() : redirectToHome()
|
||||
})
|
||||
}
|
||||
|
||||
// If auth is required and the user is NOT currently logged in,
|
||||
// redirect to login.
|
||||
redirectToLogin()
|
||||
|
||||
function redirectToLogin() {
|
||||
// Pass the original route to the login component
|
||||
next({ name: 'login', query: { redirectFrom: routeTo.fullPath } })
|
||||
}
|
||||
function redirectToHome() {
|
||||
// Pass the original route to the login component
|
||||
next({ name: 'home', query: { redirectFrom: routeTo.fullPath } })
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeResolve(async (routeTo, routeFrom, next) => {
|
||||
// Create a `beforeResolve` hook, which fires whenever
|
||||
// `beforeRouteEnter` and `beforeRouteUpdate` would. This
|
||||
// allows us to ensure data is fetched even when params change,
|
||||
// but the resolved route does not. We put it in `meta` to
|
||||
// indicate that it's a hook we created, rather than part of
|
||||
// Vue Router (yet?).
|
||||
try {
|
||||
// For each matched route...
|
||||
for (const route of routeTo.matched) {
|
||||
await new Promise((resolve, reject) => {
|
||||
// If a `beforeResolve` hook is defined, call it with
|
||||
// the same arguments as the `beforeEnter` hook.
|
||||
if (route.meta && route.meta.beforeResolve) {
|
||||
route.meta.beforeResolve(routeTo, routeFrom, (...args) => {
|
||||
// If the user chose to redirect...
|
||||
if (args.length) {
|
||||
// If redirecting to the same route we're coming from...
|
||||
if (routeFrom.name === args[0].name) {
|
||||
// Complete the animation of the route progress bar.
|
||||
NProgress.done()
|
||||
}
|
||||
// Complete the redirect.
|
||||
next(...args)
|
||||
reject(new Error('Redirected'))
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Otherwise, continue resolving the route.
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
// If a `beforeResolve` hook chose to redirect, just return.
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we reach this point, continue resolving the route.
|
||||
next()
|
||||
})
|
||||
|
||||
// When each route is finished evaluating...
|
||||
router.afterEach((routeTo, routeFrom) => {
|
||||
// Complete the animation of the route progress bar.
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
13
ui/src/router/layouts/main.unit.js
Normal file
13
ui/src/router/layouts/main.unit.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import MainLayout from './main.vue'
|
||||
|
||||
describe('@layouts/main.vue', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<p>Hello!</p>'
|
||||
const { element } = shallowMount(MainLayout, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
14
ui/src/router/layouts/main.vue
Normal file
14
ui/src/router/layouts/main.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import NavBar from '@components/nav-bar.vue'
|
||||
|
||||
export default {
|
||||
components: { NavBar },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template
|
||||
><div>
|
||||
<NavBar />
|
||||
<div class="section container"> <slot /> </div
|
||||
></div>
|
||||
</template>
|
||||
466
ui/src/router/routes.js
Normal file
466
ui/src/router/routes.js
Normal file
@@ -0,0 +1,466 @@
|
||||
import store from '@state/store'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
meta: {
|
||||
authRequired: true,
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicles')
|
||||
.then((vehicles) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicles = vehicles
|
||||
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('users/me')
|
||||
.then((me) => {
|
||||
next()
|
||||
})
|
||||
// Continue to the route.
|
||||
})
|
||||
.catch((ex) => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
console.log(ex)
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
component: () => lazyLoadView(import('@views/home.vue')),
|
||||
props: (route) => ({ user: store.state.auth.currentUser || {} }),
|
||||
},
|
||||
{
|
||||
path: '/initialize',
|
||||
name: 'initialize',
|
||||
component: () => lazyLoadView(import('@views/initialize.vue')),
|
||||
meta: {
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
// If the user is already logged in
|
||||
|
||||
if (store.getters['auth/isInitialized']) {
|
||||
next({ name: 'login' })
|
||||
}
|
||||
|
||||
if (store.getters['auth/loggedIn']) {
|
||||
// Redirect to the home page instead
|
||||
next({ name: 'home' })
|
||||
} else {
|
||||
// Continue to the login page
|
||||
next()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => lazyLoadView(import('@views/login.vue')),
|
||||
meta: {
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
// If the user is already logged in
|
||||
|
||||
if (!store.getters['auth/isInitialized']) {
|
||||
// Redirect to the home page instead
|
||||
console.log('App is not initialized')
|
||||
next({ name: 'initialize' })
|
||||
}
|
||||
|
||||
if (store.getters['auth/loggedIn']) {
|
||||
// Redirect to the home page instead
|
||||
next({ name: 'home' })
|
||||
} else {
|
||||
// Continue to the login page
|
||||
next()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => lazyLoadView(import('@views/profile.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
props: (route) => ({ user: store.state.auth.currentUser || {} }),
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
name: 'site-settings',
|
||||
component: () => lazyLoadView(import('@/src/router/views/siteSettings.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
props: (route) => ({
|
||||
user: store.state.auth.currentUser || {},
|
||||
settings: store.state.utils.settings || {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'users',
|
||||
component: () => lazyLoadView(import('@/src/router/views/users.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
roles: ['ADMIN'],
|
||||
},
|
||||
props: (route) => ({
|
||||
user: store.state.auth.currentUser || {},
|
||||
settings: store.state.utils.settings || {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => lazyLoadView(import('@/src/router/views/settings.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
},
|
||||
props: (route) => ({
|
||||
user: store.state.auth.currentUser || {},
|
||||
me: store.state.users.me || {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/profile/:username',
|
||||
name: 'username-profile',
|
||||
component: () => lazyLoadView(import('@views/profile.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('users/fetchUser', { username: routeTo.params.username })
|
||||
.then((user) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.user = user
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ user: route.meta.tmp.user }),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/vehicles/create',
|
||||
name: 'vehicle-create',
|
||||
component: () => lazyLoadView(import('@views/createVehicle.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId',
|
||||
name: 'vehicle-detail',
|
||||
component: () => lazyLoadView(import('@views/vehicle.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/edit',
|
||||
name: 'vehicle-edit',
|
||||
component: () => lazyLoadView(import('@views/createVehicle.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/fillup',
|
||||
name: 'vehicle-create-fillup',
|
||||
component: () => lazyLoadView(import('@views/createFillup.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/fillup/:fillupId/edit',
|
||||
name: 'vehicle-edit-fillup',
|
||||
component: () => lazyLoadView(import('@views/createFillup.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchFillupById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
fillupId: routeTo.params.fillupId,
|
||||
})
|
||||
.then((fillup) => {
|
||||
routeTo.meta.tmp.fillup = fillup
|
||||
|
||||
next()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle, fillup: route.meta.tmp.fillup }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/expense',
|
||||
name: 'vehicle-create-expense',
|
||||
component: () => lazyLoadView(import('@views/createExpense.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
// HACK: In order to share data between the `beforeResolve` hook
|
||||
// and the `props` function, we must create an object for temporary
|
||||
// data only used during route resolution.
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
// Try to fetch the user's information by their username
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
// Add the user to `meta.tmp`, so that it can
|
||||
// be provided as a prop.
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
// Continue to the route.
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
// If a user with the provided username could not be
|
||||
// found, redirect to the 404 page.
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle }),
|
||||
},
|
||||
{
|
||||
path: '/vehicles/:vehicleId/expense/:expenseId/edit',
|
||||
name: 'vehicle-edit-expense',
|
||||
component: () => lazyLoadView(import('@views/createExpense.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
|
||||
tmp: {},
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchVehicleById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
})
|
||||
.then((vehicle) => {
|
||||
routeTo.meta.tmp.vehicle = vehicle
|
||||
store
|
||||
|
||||
.dispatch('vehicles/fetchExpenseById', {
|
||||
vehicleId: routeTo.params.vehicleId,
|
||||
expenseId: routeTo.params.expenseId,
|
||||
})
|
||||
.then((expense) => {
|
||||
routeTo.meta.tmp.expense = expense
|
||||
|
||||
next()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
next({ name: '404', params: { resource: 'User' } })
|
||||
})
|
||||
},
|
||||
},
|
||||
// Set the user from the route params, once it's set in the
|
||||
// beforeResolve route guard.
|
||||
props: (route) => ({ vehicle: route.meta.tmp.vehicle, expense: route.meta.tmp.expense }),
|
||||
},
|
||||
{
|
||||
path: '/quickEntries',
|
||||
name: 'quickEntries',
|
||||
component: () => lazyLoadView(import('@views/quickEntries.vue')),
|
||||
meta: {
|
||||
authRequired: true,
|
||||
},
|
||||
props: (route) => ({ user: store.state.auth.currentUser || {} }),
|
||||
},
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
meta: {
|
||||
authRequired: true,
|
||||
beforeResolve(routeTo, routeFrom, next) {
|
||||
store.dispatch('auth/logOut').then((data) => {
|
||||
const authRequiredOnPreviousRoute = routeFrom.matched.some(
|
||||
(route) => route.meta.authRequired
|
||||
)
|
||||
// Navigate back to previous page, or home as a fallback
|
||||
next(authRequiredOnPreviousRoute ? { name: 'login' } : { ...routeFrom })
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: '404',
|
||||
component: require('@views/_404.vue').default,
|
||||
// Allows props to be passed to the 404 page through route
|
||||
// params, such as `resource` to define what wasn't found.
|
||||
props: true,
|
||||
},
|
||||
// Redirect any unmatched routes to the 404 page. This may
|
||||
// require some server configuration to work in production:
|
||||
// https://router.vuejs.org/en/essentials/history-mode.html#example-server-configurations
|
||||
{
|
||||
path: '*',
|
||||
redirect: '404',
|
||||
},
|
||||
]
|
||||
|
||||
// Lazy-loads view components, but with better UX. A loading view
|
||||
// will be used if the component takes a while to load, falling
|
||||
// back to a timeout view in case the page fails to load. You can
|
||||
// use this component to lazy-load a route with:
|
||||
//
|
||||
// component: () => lazyLoadView(import('@views/my-view'))
|
||||
//
|
||||
// NOTE: Components loaded with this strategy DO NOT have access
|
||||
// to in-component guards, such as beforeRouteEnter,
|
||||
// beforeRouteUpdate, and beforeRouteLeave. You must either use
|
||||
// route-level guards instead or lazy-load the component directly:
|
||||
//
|
||||
// component: () => import('@views/my-view')
|
||||
//
|
||||
function lazyLoadView(AsyncView) {
|
||||
const AsyncHandler = () => ({
|
||||
component: AsyncView,
|
||||
// A component to use while the component is loading.
|
||||
loading: require('@views/_loading.vue').default,
|
||||
// Delay before showing the loading component.
|
||||
// Default: 200 (milliseconds).
|
||||
delay: 400,
|
||||
// A fallback component in case the timeout is exceeded
|
||||
// when loading the component.
|
||||
error: require('@views/_timeout.vue').default,
|
||||
// Time before giving up trying to load the component.
|
||||
// Default: Infinity (milliseconds).
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
return Promise.resolve({
|
||||
functional: true,
|
||||
render(h, { data, children }) {
|
||||
// Transparently pass any props or children
|
||||
// to the view component.
|
||||
return h(AsyncHandler, data, children)
|
||||
},
|
||||
})
|
||||
}
|
||||
7
ui/src/router/views/_404.unit.js
Normal file
7
ui/src/router/views/_404.unit.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import View404 from './_404.vue'
|
||||
|
||||
describe('@views/404', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(View404).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
35
ui/src/router/views/_404.vue
Normal file
35
ui/src/router/views/_404.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: '404',
|
||||
meta: [{ name: 'description', content: '404' }],
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<h1 :class="$style.title">
|
||||
404
|
||||
<template v-if="resource">
|
||||
{{ resource }}
|
||||
</template>
|
||||
Not Found
|
||||
</h1>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
7
ui/src/router/views/_loading.unit.js
Normal file
7
ui/src/router/views/_loading.unit.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Loading from './_loading.vue'
|
||||
|
||||
describe('@views/loading', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Loading).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
39
ui/src/router/views/_loading.vue
Normal file
39
ui/src/router/views/_loading.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Loading page...',
|
||||
meta: [{ name: 'description', content: 'Loading page...' }],
|
||||
},
|
||||
components: { Layout },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<Transition appear>
|
||||
<BaseIcon :class="$style.loadingIcon" name="sync" spin />
|
||||
</Transition>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@design';
|
||||
|
||||
.loadingIcon {
|
||||
@extend %typography-xxlarge;
|
||||
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
|
||||
// stylelint-disable-next-line selector-class-pattern
|
||||
&:global(.v-enter-active) {
|
||||
transition: opacity 1s;
|
||||
}
|
||||
// stylelint-disable-next-line selector-class-pattern
|
||||
&:global(.v-enter) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
ui/src/router/views/_timeout.unit.js
Normal file
7
ui/src/router/views/_timeout.unit.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import Timeout from './_timeout.vue'
|
||||
|
||||
describe('@views/timeout', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Timeout).toBeAViewComponent()
|
||||
})
|
||||
})
|
||||
46
ui/src/router/views/_timeout.vue
Normal file
46
ui/src/router/views/_timeout.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import Layout from '@layouts/main.vue'
|
||||
import LoadingView from './_loading.vue'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Page timeout',
|
||||
meta: [
|
||||
{ name: 'description', content: 'The page timed out while loading.' },
|
||||
],
|
||||
},
|
||||
components: { Layout, LoadingView },
|
||||
data() {
|
||||
return {
|
||||
offlineConfirmed: false,
|
||||
}
|
||||
},
|
||||
beforeCreate() {
|
||||
axios
|
||||
.head('/api/ping')
|
||||
.then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
.catch(() => {
|
||||
this.offlineConfirmed = true
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout v-if="offlineConfirmed">
|
||||
<h1 :class="$style.title">
|
||||
The page timed out while loading. Are you sure you're still connected to
|
||||
the Internet?
|
||||
</h1>
|
||||
</Layout>
|
||||
<LoadingView v-else />
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
243
ui/src/router/views/createExpense.vue
Normal file
243
ui/src/router/views/createExpense.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import QuickEntryDisplay from '@components/quickEntryDisplay.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import store from '@state/store'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Create Expense',
|
||||
},
|
||||
components: { Layout, QuickEntryDisplay },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
expense: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: function() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tryingToCreate: false,
|
||||
showMore: false,
|
||||
quickEntry: null,
|
||||
myVehicles: [],
|
||||
selectedVehicle: this.vehicle,
|
||||
expenseModel: this.expense,
|
||||
processQuickEntry: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
return store.state.auth.currentUser
|
||||
},
|
||||
...mapState('utils', ['isMobile']),
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['fuelUnitMasters', 'fuelTypeMasters', 'vehicles']),
|
||||
},
|
||||
watch: {
|
||||
quickEntry: function(newOne, old) {
|
||||
if (old == null && newOne !== null) {
|
||||
this.processQuickEntry = true
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.myVehicles = this.vehicles
|
||||
this.selectedVehicle = this.vehicle
|
||||
if (!this.expense.id) {
|
||||
this.expenseModel = this.getEmptyExpense()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getEmptyExpense() {
|
||||
return {
|
||||
vehicleId: this.selectedVehicle.id,
|
||||
amount: null,
|
||||
expenseType: '',
|
||||
odoReading: '',
|
||||
date: new Date(),
|
||||
comments: '',
|
||||
}
|
||||
},
|
||||
|
||||
createExpense() {
|
||||
this.tryingToCreate = true
|
||||
this.expenseModel.vehicleId = this.selectedVehicle.id
|
||||
this.expenseModel.userId = this.me.id
|
||||
|
||||
if (this.expense.id) {
|
||||
axios
|
||||
.put(
|
||||
`/api/vehicles/${this.selectedVehicle.id}/expenses/${this.expense.id}`,
|
||||
this.expenseModel
|
||||
)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Expense Updated Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.expenseModel = this.getEmptyExpense()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
} else {
|
||||
axios
|
||||
.post(`/api/vehicles/${this.selectedVehicle.id}/expenses`, this.expenseModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Expense Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.expenseModel = this.getEmptyExpense()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {
|
||||
this.quickEntry = null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title">Create Expense</h1>
|
||||
<h1 class="subtitle">
|
||||
{{
|
||||
[
|
||||
selectedVehicle.nickname,
|
||||
selectedVehicle.registration,
|
||||
selectedVehicle.make,
|
||||
selectedVehicle.model,
|
||||
].join(' | ')
|
||||
}}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column is-one-thirds">
|
||||
<QuickEntryDisplay v-model="quickEntry" :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="createExpense">
|
||||
<b-field label="Select a vehicle">
|
||||
<b-select
|
||||
v-model="selectedVehicle"
|
||||
placeholder="Vehicle"
|
||||
required
|
||||
expanded
|
||||
:disabled="expense.id"
|
||||
>
|
||||
<option v-for="option in myVehicles" :key="option.id" :value="option">
|
||||
{{ option.nickname }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Expense Date">
|
||||
<b-datepicker
|
||||
v-model="expenseModel.date"
|
||||
placeholder="Click to select..."
|
||||
icon="calendar"
|
||||
trap-focus
|
||||
:max-date="new Date()"
|
||||
>
|
||||
</b-datepicker>
|
||||
</b-field>
|
||||
<b-field label="Expense Type*">
|
||||
<b-input v-model="expenseModel.expenseType" expanded required></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Total Amount Paid">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.currency }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="expenseModel.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
expanded
|
||||
step=".01"
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Odometer Reading">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.distanceUnitDetail.short }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="expenseModel.odoReading"
|
||||
type="number"
|
||||
min="0"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
|
||||
<b-field>
|
||||
<b-switch v-model="showMore">Fill more details</b-switch>
|
||||
</b-field>
|
||||
<fieldset v-if="showMore">
|
||||
<b-field label="Comments">
|
||||
<b-input v-model="expenseModel.comments" type="textarea" expanded></b-input>
|
||||
</b-field>
|
||||
</fieldset>
|
||||
<b-field>
|
||||
<b-switch v-if="quickEntry" v-model="processQuickEntry"
|
||||
>Mark selected Quick Entry as processed</b-switch
|
||||
>
|
||||
</b-field>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
label="Create Expense"
|
||||
expanded
|
||||
>
|
||||
</b-button>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
299
ui/src/router/views/createFillup.vue
Normal file
299
ui/src/router/views/createFillup.vue
Normal file
@@ -0,0 +1,299 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import QuickEntryDisplay from '@components/quickEntryDisplay.vue'
|
||||
import store from '@state/store'
|
||||
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import { round } from 'lodash'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Create Fillup',
|
||||
},
|
||||
components: { Layout, QuickEntryDisplay },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fillup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: function() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
authError: null,
|
||||
tryingToCreate: false,
|
||||
showMore: false,
|
||||
quickEntry: null,
|
||||
myVehicles: [],
|
||||
selectedVehicle: this.vehicle,
|
||||
fillupModel: this.fillup,
|
||||
processQuickEntry: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
return store.state.auth.currentUser
|
||||
},
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['fuelUnitMasters', 'fuelTypeMasters', 'vehicles']),
|
||||
},
|
||||
watch: {
|
||||
'fillupModel.fuelQuantity': function(old, newOne) {
|
||||
this.fillupModel.totalAmount = round(
|
||||
this.fillupModel.fuelQuantity * this.fillupModel.perUnitPrice,
|
||||
2
|
||||
)
|
||||
},
|
||||
'fillupModel.perUnitPrice': function(old, newOne) {
|
||||
this.fillupModel.totalAmount = round(
|
||||
this.fillupModel.fuelQuantity * this.fillupModel.perUnitPrice,
|
||||
2
|
||||
)
|
||||
},
|
||||
quickEntry: function(newOne, old) {
|
||||
if (old == null && newOne !== null) {
|
||||
this.processQuickEntry = true
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.myVehicles = this.vehicles
|
||||
this.selectedVehicle = this.vehicle
|
||||
if (!this.fillup.id) {
|
||||
this.fillupModel = this.getEmptyFillup()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getEmptyFillup() {
|
||||
return {
|
||||
vehicleId: this.selectedVehicle.id,
|
||||
fuelUnit: this.selectedVehicle.fuelUnit,
|
||||
perUnitPrice: null,
|
||||
fuelQuantity: null,
|
||||
totalAmount: null,
|
||||
odoReading: '',
|
||||
isTankFull: true,
|
||||
hasMissedFillup: false,
|
||||
date: new Date(),
|
||||
fillingStation: '',
|
||||
comments: '',
|
||||
}
|
||||
},
|
||||
async createFillup() {
|
||||
this.tryingToCreate = true
|
||||
this.fillupModel.vehicleId = this.selectedVehicle.id
|
||||
this.fillupModel.userId = this.me.id
|
||||
if (this.fillup.id) {
|
||||
axios
|
||||
.put(
|
||||
`/api/vehicles/${this.selectedVehicle.id}/fillups/${this.fillup.id}`,
|
||||
this.fillupModel
|
||||
)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Fillup Updated Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.fillupModel = this.getEmptyFillup()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {
|
||||
this.quickEntry = null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
} else {
|
||||
axios
|
||||
.post(`/api/vehicles/${this.selectedVehicle.id}/fillups`, this.fillupModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Fillup Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.fillupModel = this.getEmptyFillup()
|
||||
if (this.processQuickEntry) {
|
||||
store
|
||||
.dispatch('vehicles/setQuickEntryAsProcessed', { id: this.quickEntry.id })
|
||||
.then((data) => {})
|
||||
}
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="has-text-centered">
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<h1 class="title">Create Fillup</h1>
|
||||
<h1 class="subtitle">
|
||||
{{
|
||||
[
|
||||
selectedVehicle.nickname,
|
||||
selectedVehicle.registration,
|
||||
selectedVehicle.make,
|
||||
selectedVehicle.model,
|
||||
].join(' | ')
|
||||
}}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="column is-one-thirds">
|
||||
<QuickEntryDisplay v-model="quickEntry" :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="" @submit.prevent="createFillup">
|
||||
<b-field label="Select a vehicle">
|
||||
<b-select
|
||||
v-model="selectedVehicle"
|
||||
placeholder="Vehicle"
|
||||
required
|
||||
expanded
|
||||
:disabled="fillup.id"
|
||||
>
|
||||
<option v-for="option in myVehicles" :key="option.id" :value="option">
|
||||
{{ option.nickname }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field label="Fillup Date">
|
||||
<b-datepicker
|
||||
v-model="fillupModel.date"
|
||||
placeholder="Click to select..."
|
||||
icon="calendar"
|
||||
trap-focus
|
||||
:max-date="new Date()"
|
||||
>
|
||||
</b-datepicker>
|
||||
</b-field>
|
||||
<b-field label="Quantity*" addons>
|
||||
<b-input
|
||||
v-model.number="fillupModel.fuelQuantity"
|
||||
type="number"
|
||||
step=".01"
|
||||
min="0"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
<b-select v-model="fillupModel.fuelUnit" placeholder="Fuel Unit" required>
|
||||
<option v-for="(option, key) in fuelUnitMasters" :key="key" :value="key">
|
||||
{{ option.long }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
<b-field :label="'Price per ' + vehicle.fuelUnitDetail.short + '*'"
|
||||
><p class="control">
|
||||
<span class="button is-static">{{ me.currency }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="fillupModel.perUnitPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step=".01"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Total Amount Paid">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.currency }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="fillupModel.totalAmount"
|
||||
type="number"
|
||||
min="0"
|
||||
step=".01"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field label="Odometer Reading">
|
||||
<p class="control">
|
||||
<span class="button is-static">{{ me.distanceUnitDetail.short }}</span>
|
||||
</p>
|
||||
<b-input
|
||||
v-model.number="fillupModel.odoReading"
|
||||
type="number"
|
||||
min="0"
|
||||
expanded
|
||||
required
|
||||
></b-input>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-checkbox v-model="fillupModel.isTankFull">Did you get a full tank?</b-checkbox>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-checkbox v-model="fillupModel.hasMissedFillup"
|
||||
>Did you miss the fillup entry before this one?</b-checkbox
|
||||
>
|
||||
</b-field>
|
||||
<b-field>
|
||||
<b-switch v-model="showMore">Fill more details</b-switch>
|
||||
</b-field>
|
||||
<fieldset v-if="showMore">
|
||||
<b-field label="Filling Station Name">
|
||||
<b-input v-model="fillupModel.fillingStation" type="text" expanded></b-input>
|
||||
</b-field>
|
||||
<b-field label="Comments">
|
||||
<b-input v-model="fillupModel.comments" type="textarea" expanded></b-input>
|
||||
</b-field>
|
||||
</fieldset>
|
||||
<b-field>
|
||||
<b-switch v-if="quickEntry" v-model="processQuickEntry"
|
||||
>Mark selected Quick Entry as processed</b-switch
|
||||
>
|
||||
</b-field>
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
label="Create Fillup"
|
||||
expanded
|
||||
>
|
||||
</b-button>
|
||||
<p v-if="authError">
|
||||
There was an error logging in to your account.
|
||||
</p>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
181
ui/src/router/views/createVehicle.vue
Normal file
181
ui/src/router/views/createVehicle.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script>
|
||||
import Layout from '@layouts/main.vue'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
page: {
|
||||
title: 'Create Vehicle',
|
||||
},
|
||||
components: { Layout },
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: function() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
authError: null,
|
||||
tryingToCreate: false,
|
||||
showMore: false,
|
||||
myVehicles: [],
|
||||
vehicleModel: {},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState('users', ['me']),
|
||||
...mapState('vehicles', ['fuelUnitMasters', 'fuelTypeMasters', 'vehicles']),
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
if (!this.vehicle) {
|
||||
this.vehicleModel = this.getEmptyVehicle()
|
||||
} else {
|
||||
this.vehicleModel = this.getEmptyVehicle(this.vehicle)
|
||||
}
|
||||
this.myVehicles = this.vehicles
|
||||
},
|
||||
methods: {
|
||||
getEmptyVehicle(veh) {
|
||||
if (!veh.id) {
|
||||
return {
|
||||
fuelUnit: null,
|
||||
fuelType: null,
|
||||
registration: '',
|
||||
nickname: '',
|
||||
engineSize: null,
|
||||
make: '',
|
||||
model: '',
|
||||
yearOfManufacture: null,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fuelUnit: veh.fuelUnit,
|
||||
fuelType: veh.fuelType,
|
||||
registration: veh.registration,
|
||||
nickname: veh.nickname,
|
||||
engineSize: veh.engineSize,
|
||||
make: veh.make,
|
||||
model: veh.model,
|
||||
yearOfManufacture: veh.yearOfManufacture,
|
||||
}
|
||||
}
|
||||
},
|
||||
createVehicle() {
|
||||
this.tryingToCreate = true
|
||||
this.vehicleModel.userId = this.me.id
|
||||
if (this.vehicle.id) {
|
||||
axios
|
||||
.put(`/api/vehicles/${this.vehicle.id}`, this.vehicleModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Vehicle Updated Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
// this.vehicleModel = this.getEmptyVehicle()
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
} else {
|
||||
axios
|
||||
.post(`/api/vehicles`, this.vehicleModel)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Vehicle Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.vehicleModel = this.getEmptyVehicle()
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<div class="columns">
|
||||
<div class="column is-three-quarters">
|
||||
<h1 class="title">Create Vehicle</h1>
|
||||
</div>
|
||||
<div class="column is-one-quarter">
|
||||
<router-link tag="b-button" type="is-primary" to="/">
|
||||
Back to Vehicle
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="createVehicle">
|
||||
<b-field label="Nickname*">
|
||||
<b-input v-model="vehicleModel.nickname" type="text" expanded required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Registration*">
|
||||
<b-input v-model="vehicleModel.registration" type="text" expanded required></b-input>
|
||||
</b-field>
|
||||
<b-field label="Fuel Type*">
|
||||
<b-select v-model.number="vehicleModel.fuelType" placeholder="Fuel Type" required expanded>
|
||||
<option v-for="(option, key) in fuelTypeMasters" :key="key" :value="key">
|
||||
{{ option.long }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Fuel Unit*">
|
||||
<b-select v-model.number="vehicleModel.fuelUnit" placeholder="Fuel Unit" required expanded>
|
||||
<option v-for="(option, key) in fuelUnitMasters" :key="key" :value="key">
|
||||
{{ option.long }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
<b-field label="Make / Company*">
|
||||
<b-input v-model="vehicleModel.make" type="text" required expanded></b-input>
|
||||
</b-field>
|
||||
<b-field label="Model*">
|
||||
<b-input v-model="vehicleModel.model" type="text" required expanded></b-input>
|
||||
</b-field>
|
||||
<b-field label="Year Of Manufacture">
|
||||
<b-input v-model.number="vehicleModel.yearOfManufacture" type="number" expanded number></b-input>
|
||||
</b-field>
|
||||
<b-field label="Engine Size (in cc)">
|
||||
<b-input v-model.number="vehicleModel.engineSize" type="number" expanded number></b-input>
|
||||
</b-field>
|
||||
|
||||
<br />
|
||||
<b-field>
|
||||
<b-button tag="input" native-type="submit" :disabled="tryingToCreate" type="is-primary" label="Create Vehicle" expanded>
|
||||
<BaseIcon v-if="tryingToCreate" name="sync" spin />
|
||||
</b-button>
|
||||
<p v-if="authError">
|
||||
There was an error logging in to your account.
|
||||
</p>
|
||||
</b-field>
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
12
ui/src/router/views/home.unit.js
Normal file
12
ui/src/router/views/home.unit.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Home from './home.vue'
|
||||
|
||||
describe('@views/home', () => {
|
||||
it('is a valid view', () => {
|
||||
expect(Home).toBeAViewComponent()
|
||||
})
|
||||
|
||||
it('renders an element', () => {
|
||||
const { element } = shallowMountView(Home)
|
||||
expect(element.textContent).toContain('Home Page')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user