diff --git a/src/css/_spinner.scss b/src/css/_spinner.scss new file mode 100644 index 0000000..9bbf7d8 --- /dev/null +++ b/src/css/_spinner.scss @@ -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; + } +} diff --git a/src/css/main.scss b/src/css/main.scss index 327054d..8b4323e 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -1,5 +1,6 @@ @import "mixins"; @import "form"; +@import "spinner"; #moco-bx-bubble { position: fixed; diff --git a/src/js/components/Bubble.js b/src/js/components/Bubble.js index a1832bb..3aefa72 100644 --- a/src/js/components/Bubble.js +++ b/src/js/components/Bubble.js @@ -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 = + renderContent = () => { + if (this.unauthorizedError || this.hasInvalidConfiguration()) { + return } else if (this.isOpen) { - content = ( + return (
) } else { - content = null + return null + } + } + + render() { + if (this.isLoading) { + return } return ( @@ -212,10 +229,10 @@ class Bubble extends Component { src={chrome.extension.getURL(logoUrl)} width="50%" /> - {this.bookedHours && {this.bookedHours}} + {this.bookedHours > 0 && {this.bookedHours}h} {this.isOpen && ( - {content} + {this.renderContent()} )} diff --git a/src/js/components/Form.js b/src/js/components/Form.js index bfb3b97..455d727 100644 --- a/src/js/components/Form.js +++ b/src/js/components/Form.js @@ -51,7 +51,7 @@ class Form extends Component { onChange={onChange} /> {errors.assignment_id ? ( -
{errors.assignment_id.join('; ')}
+
{errors.assignment_id.join("; ")}
) : null}
@@ -64,7 +64,7 @@ class Form extends Component { noOptionsMessage={() => "Zuerst Projekt wählen"} /> {errors.task_id ? ( -
{errors.task_id.join('; ')}
+
{errors.task_id.join("; ")}
) : null}
@@ -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 ? ( -
{errors.hours.join('; ')}
+
{errors.hours.join("; ")}
) : null}
@@ -90,7 +90,7 @@ class Form extends Component { rows={4} /> {errors.description ? ( -
{errors.description.join('; ')}
+
{errors.description.join("; ")}
) : null}
diff --git a/src/js/components/MissingConfigurationError.js b/src/js/components/InvalidConfigurationError.js similarity index 83% rename from src/js/components/MissingConfigurationError.js rename to src/js/components/InvalidConfigurationError.js index d57bacd..d01c086 100644 --- a/src/js/components/MissingConfigurationError.js +++ b/src/js/components/InvalidConfigurationError.js @@ -1,9 +1,9 @@ import React from "react" import configurationSettingsUrl from "images/configurationSettings.png" -const MissingConfigurationError = () => ( +const InvalidConfigurationError = () => (
-

Fehlende Konfiguration

+

Konfiguration ungültig

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 = () => (

) -export default MissingConfigurationError +export default InvalidConfigurationError diff --git a/src/js/components/Select.js b/src/js/components/Select.js index 77678af..4d054d6 100644 --- a/src/js/components/Select.js +++ b/src/js/components/Select.js @@ -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 ( ( +
+ Loading... +
+) + +export default Spinner diff --git a/src/js/utils/index.js b/src/js/utils/index.js index 8cc5424..1cd71ac 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -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 +} diff --git a/test/utils/index.test.js b/test/utils/index.test.js index ef4a5bb..a5b71be 100644 --- a/test/utils/index.test.js +++ b/test/utils/index.test.js @@ -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) + }) + }) })