Allow to book hours with colon, error handling, spinner
This commit is contained in:
18
src/css/_spinner.scss
Normal file
18
src/css/_spinner.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "mixins";
|
||||
@import "form";
|
||||
@import "spinner";
|
||||
|
||||
#moco-bx-bubble {
|
||||
position: fixed;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
9
src/js/components/Spinner.js
Normal file
9
src/js/components/Spinner.js
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user