first commit
This commit is contained in:
13
ui/src/components/_base-button.unit.js
Normal file
13
ui/src/components/_base-button.unit.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import BaseButton from './_base-button.vue'
|
||||
|
||||
describe('@components/_base-button', () => {
|
||||
it('renders its content', () => {
|
||||
const slotContent = '<span>foo</span>'
|
||||
const { element } = shallowMount(BaseButton, {
|
||||
slots: {
|
||||
default: slotContent,
|
||||
},
|
||||
})
|
||||
expect(element.innerHTML).toContain(slotContent)
|
||||
})
|
||||
})
|
||||
5
ui/src/components/_base-button.vue
Normal file
5
ui/src/components/_base-button.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<b-button :class="$style.button" v-on="$listeners">
|
||||
<slot />
|
||||
</b-button>
|
||||
</template>
|
||||
30
ui/src/components/_base-icon.unit.js
Normal file
30
ui/src/components/_base-icon.unit.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import BaseIcon from './_base-icon.vue'
|
||||
|
||||
describe('@components/_base-icon', () => {
|
||||
it('renders a font-awesome icon', () => {
|
||||
const { element } = mount(BaseIcon, {
|
||||
propsData: {
|
||||
name: 'sync',
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.tagName).toEqual('svg')
|
||||
expect(element.classList).toContain('svg-inline--fa', 'fa-sync', 'fa-w-16')
|
||||
})
|
||||
|
||||
it('renders a custom icon', () => {
|
||||
const { element } = shallowMount(BaseIcon, {
|
||||
...createComponentMocks({
|
||||
style: {
|
||||
iconCustomSomeIcon: 'generated-class-name',
|
||||
},
|
||||
}),
|
||||
propsData: {
|
||||
source: 'custom',
|
||||
name: 'some-icon',
|
||||
},
|
||||
})
|
||||
|
||||
expect(element.className).toEqual('generated-class-name')
|
||||
})
|
||||
})
|
||||
47
ui/src/components/_base-icon.vue
Normal file
47
ui/src/components/_base-icon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { library as fontAwesomeIconLibrary } from '@fortawesome/fontawesome-svg-core'
|
||||
import camelCase from 'lodash/camelCase'
|
||||
|
||||
// https://fontawesome.com/icons
|
||||
fontAwesomeIconLibrary.add(
|
||||
require('@fortawesome/free-solid-svg-icons/faSync').definition,
|
||||
require('@fortawesome/free-solid-svg-icons/faUser').definition
|
||||
)
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FontAwesomeIcon,
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
default: 'font-awesome',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
// Gets a CSS module class, e.g. iconCustomLogo
|
||||
customIconClass() {
|
||||
return this.$style[camelCase('icon-custom-' + this.name)]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FontAwesomeIcon
|
||||
v-if="source === 'font-awesome'"
|
||||
v-bind="$attrs"
|
||||
:icon="name"
|
||||
/>
|
||||
<span
|
||||
v-else-if="source === 'custom'"
|
||||
v-bind="$attrs"
|
||||
:class="customIconClass"
|
||||
/>
|
||||
</template>
|
||||
45
ui/src/components/_base-input-text.unit.js
Normal file
45
ui/src/components/_base-input-text.unit.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import BaseInputText from './_base-input-text.vue'
|
||||
|
||||
describe('@components/_base-input-text', () => {
|
||||
it('works with v-model', () => {
|
||||
const wrapper = mount(BaseInputText, { propsData: { value: 'aaa' } })
|
||||
const inputWrapper = wrapper.find('input')
|
||||
const inputEl = inputWrapper.element
|
||||
|
||||
// Has the correct starting value
|
||||
expect(inputEl.value).toEqual('aaa')
|
||||
|
||||
// Emits an update event with the correct value when edited
|
||||
inputEl.value = 'bbb'
|
||||
inputWrapper.trigger('input')
|
||||
expect(wrapper.emitted().update).toEqual([['bbb']])
|
||||
|
||||
// Sets the input to the correct value when props change
|
||||
wrapper.setValue('ccc')
|
||||
expect(inputEl.value).toEqual('ccc')
|
||||
})
|
||||
|
||||
it('allows a type of "password"', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mount(BaseInputText, {
|
||||
propsData: { value: 'aaa', type: 'password' },
|
||||
})
|
||||
expect(consoleError).not.toBeCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('does NOT allow a type of "checkbox"', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mount(BaseInputText, {
|
||||
propsData: { value: 'aaa', type: 'checkbox' },
|
||||
})
|
||||
expect(consoleError.mock.calls[0][0]).toContain(
|
||||
'custom validator check failed for prop "type"'
|
||||
)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
48
ui/src/components/_base-input-text.vue
Normal file
48
ui/src/components/_base-input-text.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
export default {
|
||||
// Disable automatic attribute inheritance, so that $attrs are
|
||||
// passed to the <input>, even if it's not the root element.
|
||||
// https://vuejs.org/v2/guide/components-props.html#Disabling-Attribute-Inheritance
|
||||
inheritAttrs: false,
|
||||
// Change the v-model event name to `update` to avoid changing
|
||||
// the behavior of the native `input` event.
|
||||
// https://vuejs.org/v2/guide/components-custom-events.html#Customizing-Component-v-model
|
||||
model: {
|
||||
event: 'update',
|
||||
},
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
// Only allow types that essentially just render text boxes.
|
||||
validator(value) {
|
||||
return [
|
||||
'email',
|
||||
'number',
|
||||
'password',
|
||||
'search',
|
||||
'tel',
|
||||
'text',
|
||||
'url',
|
||||
].includes(value)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-input
|
||||
:type="type"
|
||||
:class="$style.input"
|
||||
v-bind="
|
||||
$attrs
|
||||
// https://vuejs.org/v2/guide/components-props.html#Disabling-Attribute-Inheritance
|
||||
"
|
||||
@input="$emit('update', $event.target.value)"
|
||||
v-on="
|
||||
$listeners
|
||||
// https://vuejs.org/v2/guide/components-custom-events.html#Binding-Native-Events-to-Components
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
139
ui/src/components/_base-link.unit.js
Normal file
139
ui/src/components/_base-link.unit.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import BaseLink from './_base-link.vue'
|
||||
|
||||
const mountBaseLink = (options = {}) => {
|
||||
return mount(BaseLink, {
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
functional: true,
|
||||
render(h, { slots, data }) {
|
||||
return <a data-router-link='true'>{slots().default}</a>
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: 'hello',
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
describe('@components/_base-link', () => {
|
||||
const originalConsoleWarn = global.console.warn
|
||||
let warning
|
||||
beforeEach(() => {
|
||||
warning = undefined
|
||||
global.console.warn = jest.fn((text) => {
|
||||
warning = text
|
||||
})
|
||||
})
|
||||
afterAll(() => {
|
||||
global.console.warn = originalConsoleWarn
|
||||
})
|
||||
|
||||
it('exports a valid component', () => {
|
||||
expect(BaseLink).toBeAComponent()
|
||||
})
|
||||
|
||||
it('warns about missing required props', () => {
|
||||
mountBaseLink()
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Invalid <BaseLink> props/)
|
||||
})
|
||||
|
||||
it('warns about an invalid href', () => {
|
||||
mountBaseLink({
|
||||
propsData: {
|
||||
href: '/some/local/path',
|
||||
},
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Invalid <BaseLink> href/)
|
||||
})
|
||||
|
||||
it('warns about an insecure href', () => {
|
||||
mountBaseLink({
|
||||
propsData: {
|
||||
href: 'http://google.com',
|
||||
},
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledTimes(1)
|
||||
expect(warning).toMatch(/Insecure <BaseLink> href/)
|
||||
})
|
||||
|
||||
it('renders an anchor element when passed an `href` prop', () => {
|
||||
const externalUrl = 'https://google.com/'
|
||||
const { element } = mountBaseLink({
|
||||
propsData: {
|
||||
href: externalUrl,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.tagName).toEqual('A')
|
||||
expect(element.href).toEqual(externalUrl)
|
||||
expect(element.target).toEqual('_blank')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `name` prop', () => {
|
||||
const routeName = 'home'
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
name: routeName,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed `name` and `params` props', () => {
|
||||
const routeName = 'home'
|
||||
const routeParams = { foo: 'bar' }
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `to` prop', () => {
|
||||
const routeName = 'home'
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
to: {
|
||||
name: routeName,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
|
||||
})
|
||||
|
||||
it('renders a RouterLink when passed a `to` prop with `params`', () => {
|
||||
const routeName = 'home'
|
||||
const routeParams = { foo: 'bar' }
|
||||
const { element, vm } = mountBaseLink({
|
||||
propsData: {
|
||||
to: {
|
||||
name: routeName,
|
||||
params: routeParams,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(console.warn).not.toHaveBeenCalled()
|
||||
expect(element.dataset.routerLink).toEqual('true')
|
||||
expect(element.textContent).toEqual('hello')
|
||||
expect(vm.routerLinkTo).toEqual({ name: routeName, params: routeParams })
|
||||
})
|
||||
})
|
||||
81
ui/src/components/_base-link.vue
Normal file
81
ui/src/components/_base-link.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowInsecure: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
params: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
routerLinkTo({ name, params }) {
|
||||
return {
|
||||
name,
|
||||
params,
|
||||
...(this.to || {}),
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.validateProps()
|
||||
},
|
||||
methods: {
|
||||
// Perform more complex prop validations than is possible
|
||||
// inside individual validator functions for each prop.
|
||||
validateProps() {
|
||||
if (process.env.NODE_ENV === 'production') return
|
||||
|
||||
if (this.href) {
|
||||
// Check for non-external URL in href.
|
||||
if (!/^\w+:/.test(this.href)) {
|
||||
return console.warn(
|
||||
`Invalid <BaseLink> href: ${this.href}.\nIf you're trying to link to a local URL, provide at least a name or to`
|
||||
)
|
||||
}
|
||||
// Check for insecure URL in href.
|
||||
if (!this.allowInsecure && !/^(https|mailto|tel):/.test(this.href)) {
|
||||
return console.warn(
|
||||
`Insecure <BaseLink> href: ${this.href}.\nWhen linking to external sites, always prefer https URLs. If this site does not offer SSL, explicitly add the allow-insecure attribute on <BaseLink>.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Check for insufficient props.
|
||||
if (!this.name && !this.to) {
|
||||
return console.warn(
|
||||
`Invalid <BaseLink> props:\n\n${JSON.stringify(
|
||||
this.$props,
|
||||
null,
|
||||
2
|
||||
)}\n\nEither a \`name\` or \`to\` is required for internal links, or an \`href\` for external links.`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="href" :href="href" target="_blank" v-bind="$attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else :to="routerLinkTo" v-bind="$attrs">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
36
ui/src/components/_globals.js
Normal file
36
ui/src/components/_globals.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Globally register all base components for convenience, because they
|
||||
// will be used very frequently. Components are registered using the
|
||||
// PascalCased version of their file name.
|
||||
|
||||
import Vue from 'vue'
|
||||
|
||||
// https://webpack.js.org/guides/dependency-management/#require-context
|
||||
const requireComponent = require.context(
|
||||
// Look for files in the current directory
|
||||
'.',
|
||||
// Do not look in subdirectories
|
||||
false,
|
||||
// Only include "_base-" prefixed .vue files
|
||||
/_base-[\w-]+\.vue$/
|
||||
)
|
||||
|
||||
// For each matching file name...
|
||||
requireComponent.keys().forEach((fileName) => {
|
||||
// Get the component config
|
||||
const componentConfig = requireComponent(fileName)
|
||||
// Get the PascalCase version of the component name
|
||||
const componentName = fileName
|
||||
// Remove the "./_" from the beginning
|
||||
.replace(/^\.\/_/, '')
|
||||
// Remove the file extension from the end
|
||||
.replace(/\.\w+$/, '')
|
||||
// Split up kebabs
|
||||
.split('-')
|
||||
// Upper case
|
||||
.map((kebab) => kebab.charAt(0).toUpperCase() + kebab.slice(1))
|
||||
// Concatenated
|
||||
.join('')
|
||||
|
||||
// Globally register the component
|
||||
Vue.component(componentName, componentConfig.default || componentConfig)
|
||||
})
|
||||
122
ui/src/components/createQuickEntry.vue
Normal file
122
ui/src/components/createQuickEntry.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
import store from '@state/store'
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
data: function() {
|
||||
return {
|
||||
file: null,
|
||||
tryingToCreate: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
uploadButtonLabel() {
|
||||
if (this.isMobile) {
|
||||
if (this.file == null) {
|
||||
return 'Upload/Click Photo'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
if (this.file == null) {
|
||||
return 'Upload Photo'
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
createQuickEntry() {
|
||||
if (this.file == null) {
|
||||
return
|
||||
}
|
||||
this.tryingToCreate = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', this.file, this.file.name)
|
||||
axios
|
||||
.post(`/api/quickEntries`, formData)
|
||||
.then((data) => {
|
||||
this.$buefy.toast.open({
|
||||
message: 'Quick Entry Created Successfully',
|
||||
type: 'is-success',
|
||||
duration: 3000,
|
||||
})
|
||||
this.file = null
|
||||
store.dispatch('vehicles/fetchQuickEntries', { force: true }).then((data) => {
|
||||
this.quickEntries = data
|
||||
})
|
||||
})
|
||||
.catch((ex) => {
|
||||
this.$buefy.toast.open({
|
||||
duration: 5000,
|
||||
message: ex.message,
|
||||
position: 'is-bottom',
|
||||
type: 'is-danger',
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.tryingToCreate = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section box">
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds">
|
||||
<p class="title">Quick Entry</p>
|
||||
<p class="subtitle"
|
||||
>Take a pic of the invoice or the fuel pump display to make an entry later.</p
|
||||
></div
|
||||
>
|
||||
<div class="column is-one-third is-flex is-align-content-center">
|
||||
<form @submit.prevent="createQuickEntry">
|
||||
<div class="columns"
|
||||
><div class="column">
|
||||
<b-field class="file is-primary" :class="{ 'has-name': !!file }">
|
||||
<b-upload v-model="file" class="file-label" accept="image/*">
|
||||
<span class="file-cta">
|
||||
<b-icon class="file-icon" icon="upload"></b-icon>
|
||||
<span class="file-label">{{ uploadButtonLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="file"
|
||||
class="file-name"
|
||||
:class="isMobile ? 'file-name-mobile' : 'file-name-desktop'"
|
||||
>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</b-upload>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<b-button
|
||||
tag="input"
|
||||
native-type="submit"
|
||||
:disabled="tryingToCreate"
|
||||
type="is-primary"
|
||||
value="Upload File"
|
||||
class="control"
|
||||
>
|
||||
Upload File
|
||||
</b-button>
|
||||
</div></div
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.file-name-desktop {
|
||||
max-width: 9em;
|
||||
}
|
||||
.file-name-mobile {
|
||||
max-width: 12em;
|
||||
}
|
||||
</style>
|
||||
57
ui/src/components/nav-bar-routes.unit.js
Normal file
57
ui/src/components/nav-bar-routes.unit.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import NavBarRoutes from './nav-bar-routes.vue'
|
||||
|
||||
const mountRoutes = (options) => {
|
||||
return mount(
|
||||
{
|
||||
render(h) {
|
||||
return (
|
||||
<ul>
|
||||
<NavBarRoutes {...{ props: options.propsData }} />
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
stubs: {
|
||||
BaseLink: {
|
||||
functional: true,
|
||||
render(h, { slots }) {
|
||||
return <a>{slots().default}</a>
|
||||
},
|
||||
},
|
||||
...options.stubs,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('@components/nav-bar-routes', () => {
|
||||
it('correctly renders routes with text titles', () => {
|
||||
const { element } = mountRoutes({
|
||||
propsData: {
|
||||
routes: [
|
||||
{
|
||||
name: 'aaa',
|
||||
title: 'bbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(element.textContent).toEqual('bbb')
|
||||
})
|
||||
|
||||
it('correctly renders routes with function titles', () => {
|
||||
const { element } = mountRoutes({
|
||||
propsData: {
|
||||
routes: [
|
||||
{
|
||||
name: 'aaa',
|
||||
title: () => 'bbb',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(element.textContent).toEqual('bbb')
|
||||
})
|
||||
})
|
||||
62
ui/src/components/nav-bar-routes.vue
Normal file
62
ui/src/components/nav-bar-routes.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
// Allows stubbing BaseLink in unit tests
|
||||
const BaseLink = 'BaseLink'
|
||||
|
||||
export default {
|
||||
// Functional components are stateless, meaning they can't
|
||||
// have data, computed properties, etc and they have no
|
||||
// `this` context.
|
||||
functional: true,
|
||||
props: {
|
||||
routes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
// Render functions are an alternative to templates
|
||||
render(h, { props, $style = {} }) {
|
||||
function getRouteTitle(route) {
|
||||
return typeof route.title === 'function' ? route.title() : route.title
|
||||
}
|
||||
|
||||
function getRouteBadge(route) {
|
||||
if (!route.badge) {
|
||||
return false
|
||||
}
|
||||
return typeof route.badge === 'function' ? route.badge() : route.badge
|
||||
}
|
||||
|
||||
// Functional components are the only components allowed
|
||||
// to return an array of children, rather than a single
|
||||
// root node.
|
||||
return props.routes.map((route) => {
|
||||
if (getRouteBadge(route) > 0) {
|
||||
return (
|
||||
<BaseLink
|
||||
tag='b-navbar-item'
|
||||
key={route.name}
|
||||
to={route}
|
||||
exact-active-class={$style.active}
|
||||
>
|
||||
<a>{getRouteTitle(route)}</a>
|
||||
<b-tag rounded type='is-danger is-light'>
|
||||
{getRouteBadge(route)}
|
||||
</b-tag>
|
||||
</BaseLink>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<BaseLink
|
||||
tag='b-navbar-item'
|
||||
key={route.name}
|
||||
to={route}
|
||||
exact-active-class={$style.active}
|
||||
>
|
||||
<a>{getRouteTitle(route)}</a>
|
||||
</BaseLink>
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
28
ui/src/components/nav-bar.unit.js
Normal file
28
ui/src/components/nav-bar.unit.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import NavBar from './nav-bar.vue'
|
||||
|
||||
describe('@components/nav-bar', () => {
|
||||
it(`displays the user's name in the profile link`, () => {
|
||||
const { vm } = shallowMount(
|
||||
NavBar,
|
||||
createComponentMocks({
|
||||
store: {
|
||||
auth: {
|
||||
state: {
|
||||
currentUser: {
|
||||
name: 'My Name',
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
loggedIn: () => true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const profileRoute = vm.loggedInNavRoutes.find(
|
||||
(route) => route.name === 'profile'
|
||||
)
|
||||
expect(profileRoute.title()).toEqual('Logged in as My Name')
|
||||
})
|
||||
})
|
||||
81
ui/src/components/nav-bar.vue
Normal file
81
ui/src/components/nav-bar.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script>
|
||||
import { authComputed } from '@state/helpers'
|
||||
import { mapGetters } from 'vuex'
|
||||
import NavBarRoutes from './nav-bar-routes.vue'
|
||||
|
||||
export default {
|
||||
components: { NavBarRoutes },
|
||||
data() {
|
||||
return {
|
||||
persistentNavRoutes: [
|
||||
{
|
||||
name: 'home',
|
||||
title: 'Home',
|
||||
},
|
||||
],
|
||||
loggedInNavRoutes: [
|
||||
{
|
||||
name: 'quickEntries',
|
||||
title: () => 'Quick Entries',
|
||||
badge: () => this.unprocessedQuickEntries.length,
|
||||
},
|
||||
// {
|
||||
// name: 'profile',
|
||||
// title: () => 'Logged in as ' + this.currentUser.name,
|
||||
// },
|
||||
{
|
||||
name: 'settings',
|
||||
title: 'Settings',
|
||||
},
|
||||
{
|
||||
name: 'logout',
|
||||
title: 'Log out',
|
||||
},
|
||||
],
|
||||
loggedOutNavRoutes: [
|
||||
{
|
||||
name: 'login',
|
||||
title: 'Log in',
|
||||
},
|
||||
],
|
||||
adminNavRoutes: [
|
||||
{
|
||||
name: 'site-settings',
|
||||
title: 'Site Settings',
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
title: 'Users',
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...authComputed,
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries']),
|
||||
isAdmin() {
|
||||
return this.loggedIn && this.currentUser.role === 'ADMIN'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<b-navbar class="" spaced>
|
||||
<template v-slot:brand>
|
||||
<b-navbar-item tag="router-link" :to="{ path: '/' }">
|
||||
<h1 class="title" style="font-family:Arial Black">Hammond</h1>
|
||||
</b-navbar-item>
|
||||
</template>
|
||||
<template v-slot:end>
|
||||
<NavBarRoutes :routes="persistentNavRoutes" />
|
||||
<NavBarRoutes v-if="loggedIn" :routes="loggedInNavRoutes" />
|
||||
<NavBarRoutes v-else :routes="loggedOutNavRoutes" />
|
||||
<b-navbar-dropdown v-if="loggedIn && isAdmin" label="Admin">
|
||||
<NavBarRoutes :routes="adminNavRoutes" />
|
||||
</b-navbar-dropdown>
|
||||
</template>
|
||||
</b-navbar>
|
||||
</div>
|
||||
</template>
|
||||
68
ui/src/components/quickEntryDisplay.vue
Normal file
68
ui/src/components/quickEntryDisplay.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
import { parseAndFormatDateTime } from '@utils/format-date'
|
||||
|
||||
export default {
|
||||
model: {
|
||||
prop: 'quickEntry',
|
||||
event: 'change',
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
quickEntry: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
...mapGetters('vehicles', ['unprocessedQuickEntries', 'processedQuickEntries']),
|
||||
},
|
||||
methods: {
|
||||
parseAndFormatDateTime(date) {
|
||||
return parseAndFormatDateTime(date)
|
||||
},
|
||||
showQuickEntry(entry) {
|
||||
const h = this.$createElement
|
||||
const vnode = h('p', { class: 'image' }, [
|
||||
h('img', {
|
||||
attrs: {
|
||||
src: `/api/attachments/${entry.attachmentId}/file?access_token=${this.user.token}`,
|
||||
},
|
||||
}),
|
||||
])
|
||||
this.$buefy.modal.open({
|
||||
content: [vnode],
|
||||
})
|
||||
this.$emit('change', entry)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="level">
|
||||
<b-field class="level-right">
|
||||
<b-select
|
||||
v-if="unprocessedQuickEntries.length"
|
||||
v-model="quickEntry"
|
||||
placeholder="Refer quick entry"
|
||||
expanded
|
||||
@input="showQuickEntry($event)"
|
||||
>
|
||||
<option v-for="option in unprocessedQuickEntries" :key="option.id" :value="option">
|
||||
Taken: {{ parseAndFormatDateTime(option.createdAt) }}
|
||||
</option>
|
||||
</b-select>
|
||||
<p class="control">
|
||||
<b-button v-if="quickEntry" type="is-primary" @click="showQuickEntry(quickEntry)"
|
||||
>Show</b-button
|
||||
></p
|
||||
>
|
||||
</b-field>
|
||||
</div></template
|
||||
>
|
||||
68
ui/src/components/shareVehicle.vue
Normal file
68
ui/src/components/shareVehicle.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import store from '@state/store'
|
||||
import { sortBy } from 'lodash'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
models: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
store
|
||||
.dispatch('users/users')
|
||||
.then((allUsers) => {
|
||||
store.dispatch('vehicles/fetchUsersByVehicleId', { vehicleId: this.vehicle.id }).then((data) => {
|
||||
const arr = []
|
||||
for (const user of allUsers) {
|
||||
const toAdd = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isShared: false,
|
||||
isOwner: false,
|
||||
}
|
||||
for (const mappedUser of data) {
|
||||
if (mappedUser.userId === user.id) {
|
||||
toAdd.isShared = true
|
||||
toAdd.isOwner = mappedUser.isOwner
|
||||
}
|
||||
}
|
||||
arr.push(toAdd)
|
||||
}
|
||||
this.models = sortBy(arr, ['isOwner', 'name'])
|
||||
})
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
},
|
||||
methods: {
|
||||
changeShareStatus(model) {
|
||||
var url = `/api/vehicles/${this.vehicle.id}/users/${model.id}`
|
||||
if (model.isShared) {
|
||||
axios.post(url, {}).then((data) => {})
|
||||
} else {
|
||||
axios.delete(url).then((data) => {})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box">
|
||||
<h1 class="subtitle">Share {{ vehicle.nickname }}</h1>
|
||||
<section>
|
||||
<b-field v-for="model in models" :key="model.id">
|
||||
<b-switch v-model="model.isShared" :disabled="model.isOwner" @input="changeShareStatus(model)">
|
||||
{{ model.name }}
|
||||
</b-switch>
|
||||
</b-field>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
149
ui/src/components/statsWidget.vue
Normal file
149
ui/src/components/statsWidget.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { addDays, addMonths } from 'date-fns'
|
||||
import currencyFormtter from 'currency-formatter'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
dateRangeOptions: [
|
||||
{ label: 'This week', value: 'this_week' },
|
||||
{ label: 'This month', value: 'this_month' },
|
||||
{ label: 'Past 30 days', value: 'past_30_days' },
|
||||
{ label: 'Past 3 months', value: 'past_3_months' },
|
||||
{ label: 'This year', value: 'this_year' },
|
||||
{ label: 'All Time', value: 'all_time' },
|
||||
],
|
||||
dateRangeOption: 'past_30_days',
|
||||
stats: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState('utils', ['isMobile']),
|
||||
summaryObject() {
|
||||
if (this.stats == null) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(0, this.user.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(0, this.user.currency)} (0)`,
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
return this.stats.map((x) => {
|
||||
return [
|
||||
{
|
||||
label: 'Total Expenditure',
|
||||
value: this.formatCurrency(x.expenditureTotal, x.currency),
|
||||
},
|
||||
{
|
||||
label: 'Fillup Costs',
|
||||
value: `${this.formatCurrency(x.expenditureFillups, x.currency)} (${x.countFillups})`,
|
||||
},
|
||||
{
|
||||
label: 'Other Expenses',
|
||||
value: `${this.formatCurrency(x.expenditureExpenses, x.currency)} (${x.countExpenses})`,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dateRangeOption(newOne, old) {
|
||||
if (newOne === old) {
|
||||
return
|
||||
}
|
||||
this.getStats()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getStats()
|
||||
},
|
||||
methods: {
|
||||
formatCurrency(number, currencyCode) {
|
||||
if (!currencyCode) {
|
||||
currencyCode = this.me.currency
|
||||
}
|
||||
return currencyFormtter.format(number, { code: currencyCode })
|
||||
},
|
||||
getStats() {
|
||||
axios
|
||||
.get('/api/me/stats', {
|
||||
params: {
|
||||
start: this.getStartDate(),
|
||||
end: new Date(),
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
this.stats = data.data
|
||||
})
|
||||
},
|
||||
getStartDate() {
|
||||
const toDate = new Date()
|
||||
switch (this.dateRangeOption) {
|
||||
case 'this_week':
|
||||
var currentDayOfWeek = toDate.getDay()
|
||||
var toSubtract = 0
|
||||
if (currentDayOfWeek === 0) {
|
||||
toSubtract = -6
|
||||
}
|
||||
if (currentDayOfWeek > 1) {
|
||||
toSubtract = -1 * (currentDayOfWeek - 1)
|
||||
}
|
||||
return addDays(toDate, toSubtract)
|
||||
case 'this_month':
|
||||
return new Date(toDate.getFullYear(), toDate.getMonth(), 1)
|
||||
case 'past_30_days':
|
||||
return addDays(toDate, -30)
|
||||
case 'past_3_months':
|
||||
return addMonths(toDate, -3)
|
||||
case 'this_year':
|
||||
return new Date(toDate.getFullYear(), 1, 1)
|
||||
case 'all_time':
|
||||
return new Date(1969, 4, 20)
|
||||
default:
|
||||
return new Date(1969, 4, 20)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns">
|
||||
<div class="column" :class="isMobile ? 'has-text-centered' : ''"> <h1 class="title">Stats</h1></div>
|
||||
<div class="column">
|
||||
<b-select v-model="dateRangeOption" class="is-pulled-right is-medium">
|
||||
<option v-for="option in dateRangeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</b-select></div
|
||||
>
|
||||
</div>
|
||||
<div v-for="(currencyLevel, index) in summaryObject" :key="index" class="level box">
|
||||
<div v-for="item in currencyLevel" :key="item.label" class="level-item has-text-centered">
|
||||
<div>
|
||||
<p class="heading">{{ item.label }}</p>
|
||||
<p class="title is-4">{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user