Allow to book hours with colon, error handling, spinner

This commit is contained in:
Manuel Bouza
2019-02-19 10:46:12 +01:00
parent 533be5a417
commit dd4fa996e8
9 changed files with 122 additions and 34 deletions

18
src/css/_spinner.scss Normal file
View File

@@ -0,0 +1,18 @@
#moco-bx-bubble, #moco-bx-container {
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
.spinner {
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 2px solid #999;
border-right-color: transparent;
border-radius: 50%;
animation: spinner .75s linear infinite;
}
}

View File

@@ -1,5 +1,6 @@
@import "mixins";
@import "form";
@import "spinner";
#moco-bx-bubble {
position: fixed;

View File

@@ -2,8 +2,9 @@ import React, { Component } from "react"
import PropTypes from "prop-types"
import ApiClient from "api/Client"
import Modal, { Content } from "components/Modal"
import MissingConfigurationError from "components/MissingConfigurationError"
import InvalidConfigurationError from "components/InvalidConfigurationError"
import Form from "components/Form"
import Spinner from "components/Spinner"
import { observable, computed, reaction } from "mobx"
import { observer, disposeOnUnmount } from "mobx-react"
import logoUrl from "images/logo.png"
@@ -11,7 +12,8 @@ import {
findLastProject,
findLastTask,
groupedProjectOptions,
currentDate
currentDate,
secondsFromHours
} from "utils"
import { head } from "lodash"
@@ -40,9 +42,10 @@ class Bubble extends Component {
@observable projects;
@observable lastProjectId;
@observable lastTaskId;
@observable bookedHours;
@observable bookedHours = 0;
@observable changeset = {};
@observable formErrors = {};
@observable unauthorizedError = false;
@computed get changesetWithDefaults() {
const { service } = this.props
@@ -62,6 +65,7 @@ class Bubble extends Component {
task_id: task?.value,
billable: task?.billable,
hours: "",
seconds: secondsFromHours(this.changeset.hours),
description: service.description
}
@@ -80,7 +84,7 @@ class Bubble extends Component {
disposeOnUnmount(
this,
reaction(
() => (this.hasMissingConfiguration() ? null : this.props.settings),
() => (this.hasInvalidConfiguration() ? null : this.props.settings),
this.fetchProjects,
{
fireImmediately: true
@@ -108,9 +112,9 @@ class Bubble extends Component {
this.isOpen = false
};
hasMissingConfiguration = () => {
hasInvalidConfiguration = () => {
const { settings } = this.props
return ["subdomain", "apiKey", "version"].some(key => !settings[key])
return ["subdomain", "apiKey"].some(key => !settings[key])
};
fetchProjects = settings => {
@@ -127,9 +131,16 @@ class Bubble extends Component {
this.projects = groupedProjectOptions(data.projects)
this.lastProjectId = data.last_project_id
this.lastTaskId = data.lastTaskId
this.unauthorizedError = false
})
.catch(error => {
if (error.response?.status === 401) {
this.unauthorizedError = true
}
})
.finally(() => {
this.isLoading = false
})
.catch(console.error)
.finally(() => (this.isLoading = false))
};
fetchBookedHours = service => {
@@ -137,8 +148,12 @@ class Bubble extends Component {
this.#apiClient
.bookedHours(service)
.then(({ data }) => (this.bookedHours = data[0]?.hours))
.catch(console.error)
.then(({ data }) => (this.bookedHours = parseFloat(data[0]?.hours) || 0))
.catch(error => {
if (error.response?.status === 401) {
this.unauthorizedError = true
}
})
.finally(() => (this.isLoading = false))
};
@@ -158,7 +173,7 @@ class Bubble extends Component {
this.changeset[name] = value
if (name === "assignment_id") {
this.changeset.task = null
this.changeset.task_id = null
}
};
@@ -166,8 +181,9 @@ class Bubble extends Component {
event.preventDefault()
this.#apiClient
.createActivity(this.changesetWithDefaults)
.then(() => {
.then(({ data }) => {
this.close()
this.bookedHours += data.hours
this.changeset = {}
this.formErrors = {}
})
@@ -182,16 +198,11 @@ class Bubble extends Component {
// RENDER -------------------------------------------------------------------
render() {
if (this.isLoading) {
return "Loading..."
}
let content
if (this.hasMissingConfiguration()) {
content = <MissingConfigurationError />
renderContent = () => {
if (this.unauthorizedError || this.hasInvalidConfiguration()) {
return <InvalidConfigurationError />
} else if (this.isOpen) {
content = (
return (
<Form
projects={this.projects}
changeset={this.changesetWithDefaults}
@@ -202,7 +213,13 @@ class Bubble extends Component {
/>
)
} else {
content = null
return null
}
}
render() {
if (this.isLoading) {
return <Spinner />
}
return (
@@ -212,10 +229,10 @@ class Bubble extends Component {
src={chrome.extension.getURL(logoUrl)}
width="50%"
/>
{this.bookedHours && <span className="booked-hours"><small>{this.bookedHours}</small></span>}
{this.bookedHours > 0 && <span className="booked-hours"><small>{this.bookedHours}h</small></span>}
{this.isOpen && (
<Modal>
<Content>{content}</Content>
<Content>{this.renderContent()}</Content>
</Modal>
)}
</>

View File

@@ -51,7 +51,7 @@ class Form extends Component {
onChange={onChange}
/>
{errors.assignment_id ? (
<div className="form-error">{errors.assignment_id.join('; ')}</div>
<div className="form-error">{errors.assignment_id.join("; ")}</div>
) : null}
</div>
<div className={cn("form-group", { "has-error": errors.task_id })}>
@@ -64,7 +64,7 @@ class Form extends Component {
noOptionsMessage={() => "Zuerst Projekt wählen"}
/>
{errors.task_id ? (
<div className="form-error">{errors.task_id.join('; ')}</div>
<div className="form-error">{errors.task_id.join("; ")}</div>
) : null}
</div>
<div className={cn("form-group", { "has-error": errors.hours })}>
@@ -73,12 +73,12 @@ class Form extends Component {
className="form-control"
onChange={onChange}
value={changeset.hours}
placeholder="0.00 h"
placeholder="0:00"
autoComplete="off"
autoFocus
/>
{errors.hours ? (
<div className="form-error">{errors.hours.join('; ')}</div>
<div className="form-error">{errors.hours.join("; ")}</div>
) : null}
</div>
<div className={cn("form-group", { "has-error": errors.description })}>
@@ -90,7 +90,7 @@ class Form extends Component {
rows={4}
/>
{errors.description ? (
<div className="form-error">{errors.description.join('; ')}</div>
<div className="form-error">{errors.description.join("; ")}</div>
) : null}
</div>

View File

@@ -1,9 +1,9 @@
import React from "react"
import configurationSettingsUrl from "images/configurationSettings.png"
const MissingConfigurationError = () => (
const InvalidConfigurationError = () => (
<div>
<h2>Fehlende Konfiguration</h2>
<h2>Konfiguration ungültig</h2>
<p>
Bitte trage deine Internetadresse und deinen API-Schlüssel in den
Einstellungen der MOCO Browser-Erweiterung ein. Deinen API-Key findest du
@@ -19,4 +19,4 @@ const MissingConfigurationError = () => (
</div>
)
export default MissingConfigurationError
export default InvalidConfigurationError

View File

@@ -62,7 +62,7 @@ export default class Select extends Component {
selectOptions
)
return options.find(pathEq("value", value))
return options.find(pathEq("value", value)) || null
};
handleChange = option => {
@@ -73,6 +73,7 @@ export default class Select extends Component {
render() {
const { value, hasError, ...passThroughProps } = this.props
return (
<ReactSelect
{...passThroughProps}

View File

@@ -0,0 +1,9 @@
import React from 'react'
const Spinner = () => (
<div className="spinner" role="status">
<span className="sr-only">Loading...</span>
</div>
)
export default Spinner

View File

@@ -11,6 +11,9 @@ import {
} from "lodash/fp"
import { format } from "date-fns"
const SECONDS_PER_HOUR = 3600
const SECONDS_PER_MINUTE = 60
const nilToArray = input => input || []
export const findLastProject = id =>
@@ -63,3 +66,18 @@ export const currentDate = (locale = "de") =>
export const extensionSettingsUrl = () =>
`chrome://extensions/?id=${chrome.runtime.id}`
export const secondsFromHours = hours => {
if (!hours) {
return 0
}
let number = Number(hours)
if (!isNaN(number)) {
return number * SECONDS_PER_HOUR
}
const parts = hours.split(':', 2).map(part => parseInt(part, 10) || 0)
return parts[0] * SECONDS_PER_HOUR + (parts[1] || 0) * SECONDS_PER_MINUTE
}

View File

@@ -2,7 +2,8 @@ import { projects } from "../data"
import {
findLastProject,
findLastTask,
groupedProjectOptions
groupedProjectOptions,
secondsFromHours
} from "../../src/js/utils"
import { map } from "lodash/fp"
@@ -62,4 +63,27 @@ describe("utils", () => {
])
})
})
describe("secondsFromHours", () => {
it("converts a single number to seconds", () => {
expect(secondsFromHours('1')).toEqual(3600)
expect(secondsFromHours('2')).toEqual(7200)
})
it("treats number after dot as fractional hours", () => {
expect(secondsFromHours('1.3')).toEqual(4680)
expect(secondsFromHours('2.8')).toEqual(10080)
})
it("treats number after colon as minutes", () => {
expect(secondsFromHours('1:20')).toEqual(4800)
expect(secondsFromHours('2:50')).toEqual(10200)
})
it("treats invalid numbers as zero", () => {
expect(secondsFromHours(undefined)).toEqual(0)
expect(secondsFromHours('ab')).toEqual(0)
expect(secondsFromHours('a:i')).toEqual(0)
})
})
})