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 "mixins";
|
||||||
@import "form";
|
@import "form";
|
||||||
|
@import "spinner";
|
||||||
|
|
||||||
#moco-bx-bubble {
|
#moco-bx-bubble {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
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"
|
} 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user