first commit

This commit is contained in:
Akhil Gupta
2021-05-29 15:20:50 +05:30
commit d25c30a7b2
194 changed files with 49873 additions and 0 deletions

2
ui/.browserslistrc Normal file
View File

@@ -0,0 +1,2 @@
> 1%
last 2 versions

2
ui/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

1
ui/.env Normal file
View File

@@ -0,0 +1 @@
API_BASE_URL=http://localhost:3000

2
ui/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
/dist/
/tests/unit/coverage/

81
ui/.eslintrc.js Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}

3
ui/.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
/node_modules/**
/dist/**
/tests/unit/coverage/**

17
ui/.prettierrc.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"pluginsFile": "tests/e2e/plugins/index.js"
}

16
ui/docker-compose.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
```

View 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)

View 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><%
}
%>

View 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
},
},
]

View 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()
})
})

View File

@@ -0,0 +1,6 @@
---
to: tests/e2e/specs/<%= h.changeCase.kebab(name) %>.e2e.js
---
describe('<%= h.changeCase.pascal(name) %>', () => {
})

View 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
},
},
]

View 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>

View 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
},
},
]

View 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)
})
})

View 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 = {}

View 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
},
},
]

View 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()
})
})

View 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
},
},
]

View 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')
})
})

View 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'
}

View 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?',
},
]

View 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()
})
})

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

93
ui/package.json Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 B

16
ui/public/index.html Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"title": "Hammond",
"description": "Like Clarkson, but better"
}

35
ui/src/app.vue Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View 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)
})
})

View File

@@ -0,0 +1,5 @@
<template>
<b-button :class="$style.button" v-on="$listeners">
<slot />
</b-button>
</template>

View 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')
})
})

View 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>

View 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()
})
})

View 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>

View 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 })
})
})

View 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>

View 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)
})

View 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>

View 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')
})
})

View 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>

View 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')
})
})

View 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>

View 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
>

View 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>

View 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>

View 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;

View File

@@ -0,0 +1 @@
$duration-animation-base: 300ms;

21
ui/src/design/_fonts.scss Normal file
View 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;
}

View 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
View 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;

View 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
View 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
View 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
View 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

View 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)
})
})

View 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
View 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)
},
})
}

View File

@@ -0,0 +1,7 @@
import View404 from './_404.vue'
describe('@views/404', () => {
it('is a valid view', () => {
expect(View404).toBeAViewComponent()
})
})

View 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>

View File

@@ -0,0 +1,7 @@
import Loading from './_loading.vue'
describe('@views/loading', () => {
it('is a valid view', () => {
expect(Loading).toBeAViewComponent()
})
})

View 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>

View File

@@ -0,0 +1,7 @@
import Timeout from './_timeout.vue'
describe('@views/timeout', () => {
it('is a valid view', () => {
expect(Timeout).toBeAViewComponent()
})
})

View 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>

View 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>

View 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>

View 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>

View 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