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 "mixins";
@import "form"; @import "form";
@import "spinner";
#moco-bx-bubble { #moco-bx-bubble {
position: fixed; position: fixed;

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ export default class Select extends Component {
selectOptions selectOptions
) )
return options.find(pathEq("value", value)) return options.find(pathEq("value", value)) || null
}; };
handleChange = option => { handleChange = option => {
@@ -73,6 +73,7 @@ export default class Select extends Component {
render() { render() {
const { value, hasError, ...passThroughProps } = this.props const { value, hasError, ...passThroughProps } = this.props
return ( return (
<ReactSelect <ReactSelect
{...passThroughProps} {...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" } from "lodash/fp"
import { format } from "date-fns" import { format } from "date-fns"
const SECONDS_PER_HOUR = 3600
const SECONDS_PER_MINUTE = 60
const nilToArray = input => input || [] const nilToArray = input => input || []
export const findLastProject = id => export const findLastProject = id =>
@@ -63,3 +66,18 @@ export const currentDate = (locale = "de") =>
export const extensionSettingsUrl = () => export const extensionSettingsUrl = () =>
`chrome://extensions/?id=${chrome.runtime.id}` `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 { import {
findLastProject, findLastProject,
findLastTask, findLastTask,
groupedProjectOptions groupedProjectOptions,
secondsFromHours
} from "../../src/js/utils" } from "../../src/js/utils"
import { map } from "lodash/fp" 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)
})
})
}) })