MOCO Browser Extension (#2)
* spike * initial draft * updated styling * skeleton * added bubble script to webpack * added linter settings * installs * first implementation * Update webpack config - write bundle to `/build` - add support for SASS - improve options view as a proof o concept for styling * Update es-lint rules to mach mocoapp * Upgrade npm packages * Mount Bubble only for configured services * Update react and babel * Move module resolution config to webpack * Syncrhonize apiClient with chrome storage * Load projects and initialize form with last project and task * Enhance service * Improve handling of changeset with defaults * Create activity * Show error page on missing configuration * Refactor so that changeset can be used as activity params * Show form errors * Fetch and show booked hours for service * Allow to book hours with colon, error handling, spinner * WIP: Shadow DOM * Remove shadow dom * Render App in iframe * Refactor App component to load projects and create activity * Bugsnag integration * Add title to form and timer hint to hours input field * Configure positioning of bubble * Get rid of shared browser instance * Show Calendar and animate buble * Update webpack config * Prevent double animation of bubble * Fix eslint * Add margin to iframe body * Submit form when pressing enter on textarea * Open select on Enter * Use local environment for development * Show upgrade error if version invalid * Add asana service * Add jira and wunderlist services, add better support for query strings * Match urls with hash * Show popup in browser action * Pump version, add version to zip file * Add youtrack service * WIP: always show browserAction * Refactor * Update design * Finalize release 1.0.3 * Fix styles * Add support for Firefox browser * Extract common webpack config * Fix eslint * Close modal with ESC key * Use TimeInputParser to parse hours input * Improve webpack config * Show modal instead of popup when clicking on browser action * Pre-select last booked activities on service * Remove badge from booked hours * Show error and success feedback on options page * Remove updateBrowserActionForTab * Animate Bubble on unmount * Fix select date * Refactor * Fix key shortcut * Show schedule in calendar * Upload source maps to bugsnag * Upload sourcemaps to bugsnag * Define command shortcuts * Fix race condition where both Bubble and content wanted to mount Popup The content script is now the only place, where the Popup is mounted * Replace hash in filename by version * No new line in textarea and updated shortcuts for chrome * Change shortcut to Ctrl+Shift+K * Fix cors issue in new chrome 73 * Style improvements * Only report errors from own sources * Prevent sending messages to browser tabs * Fix scrollbars in iframe * Add error page for unknown error * Add stop propagation to Bubble click event * Update error pages * Remove timeout in tabHandler. The messaging error occurs only when the browser extension is reloaded/updated without refreshing the browser tab. * Refactor messaging * Show spinner in popup * Extract message handler to own module * Update styles and texts of error pages * Ensure focus is on document when opening popup * Find projects by identifier and value, do not highlight selected option in select component * Update docs * Spread match properties on service; improve remote service configuration for jira and wunderlist * Add webpack plugin to remove source mapping url * Bugsnag do not collect user ip * Upload source maps before removing source mapping url in bundles * Add support for regex url patterns, update asana config. * Fix animation Set default transform property via css * Improve config for asana * Change to fad-in/out animation
This commit is contained in:
212
src/js/components/App.js
Normal file
212
src/js/components/App.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { Component } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import Spinner from "components/Spinner"
|
||||
import Form from "components/Form"
|
||||
import Calendar from "components/Calendar"
|
||||
import { observable, computed } from "mobx"
|
||||
import { Observer, observer } from "mobx-react"
|
||||
import { Spring, animated, config } from "react-spring/renderprops"
|
||||
import {
|
||||
ERROR_UNKNOWN,
|
||||
ERROR_UNAUTHORIZED,
|
||||
ERROR_UPGRADE_REQUIRED,
|
||||
findProjectByValue,
|
||||
findProjectByIdentifier,
|
||||
findTask,
|
||||
formatDate
|
||||
} from "utils"
|
||||
import InvalidConfigurationError from "components/Errors/InvalidConfigurationError"
|
||||
import UpgradeRequiredError from "components/Errors/UpgradeRequiredError"
|
||||
import UnknownError from "components/Errors/UnknownError"
|
||||
import { parse } from "date-fns"
|
||||
import Header from "./shared/Header"
|
||||
import { head } from "lodash"
|
||||
import TimeInputParser from "utils/TimeInputParser"
|
||||
|
||||
@observer
|
||||
class App extends Component {
|
||||
static propTypes = {
|
||||
loading: PropTypes.bool,
|
||||
service: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
projectId: PropTypes.string,
|
||||
taskId: PropTypes.string
|
||||
}),
|
||||
activities: PropTypes.array,
|
||||
schedules: PropTypes.array,
|
||||
projects: PropTypes.array,
|
||||
lastProjectId: PropTypes.number,
|
||||
lastTaskId: PropTypes.number,
|
||||
roundTimeEntries: PropTypes.bool,
|
||||
fromDate: PropTypes.string,
|
||||
toDate: PropTypes.string,
|
||||
errorType: PropTypes.string,
|
||||
errorMessage: PropTypes.string
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
activities: [],
|
||||
schedules: [],
|
||||
projects: [],
|
||||
roundTimeEntries: false
|
||||
};
|
||||
|
||||
@observable changeset = {};
|
||||
@observable formErrors = {};
|
||||
|
||||
@computed get changesetWithDefaults() {
|
||||
const { service, projects, lastProjectId, lastTaskId } = this.props
|
||||
|
||||
const project =
|
||||
findProjectByIdentifier(service?.projectId)(projects) ||
|
||||
findProjectByValue(Number(lastProjectId))(projects) ||
|
||||
head(projects)
|
||||
|
||||
const task =
|
||||
findTask(service?.taskId || lastTaskId)(project) || head(project?.tasks)
|
||||
|
||||
const defaults = {
|
||||
remote_service: service?.name,
|
||||
remote_id: service?.id,
|
||||
remote_url: service?.url,
|
||||
date: formatDate(new Date()),
|
||||
assignment_id: project?.value,
|
||||
task_id: task?.value,
|
||||
billable: task?.billable,
|
||||
hours: "",
|
||||
seconds:
|
||||
this.changeset.hours &&
|
||||
new TimeInputParser(this.changeset.hours).parseSeconds(),
|
||||
description: service?.description
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...this.changeset
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("keydown", this.handleKeyDown)
|
||||
chrome.runtime.onMessage.addListener(this.handleSetFormErrors)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown)
|
||||
chrome.runtime.onMessage.removeListener(this.handleSetFormErrors)
|
||||
}
|
||||
|
||||
handleChange = event => {
|
||||
const { projects } = this.props
|
||||
const {
|
||||
target: { name, value }
|
||||
} = event
|
||||
|
||||
this.changeset[name] = value
|
||||
|
||||
if (name === "assignment_id") {
|
||||
const project = findProjectByValue(value)(projects)
|
||||
this.changeset.task_id = head(project?.tasks).value || null
|
||||
}
|
||||
};
|
||||
|
||||
handleSelectDate = date => {
|
||||
this.changeset.date = formatDate(date)
|
||||
};
|
||||
|
||||
handleSubmit = event => {
|
||||
event.preventDefault()
|
||||
const { service } = this.props
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: "createActivity",
|
||||
payload: {
|
||||
activity: this.changesetWithDefaults,
|
||||
service
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
handleKeyDown = event => {
|
||||
if (event.keyCode === 27) {
|
||||
event.stopPropagation()
|
||||
chrome.runtime.sendMessage({ type: "closePopup" })
|
||||
}
|
||||
};
|
||||
|
||||
handleSetFormErrors = ({ type, payload }) => {
|
||||
if (type === "setFormErrors") {
|
||||
this.formErrors = payload
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
projects,
|
||||
activities,
|
||||
schedules,
|
||||
fromDate,
|
||||
toDate,
|
||||
errorType,
|
||||
errorMessage
|
||||
} = this.props
|
||||
|
||||
if (loading) {
|
||||
return <Spinner />
|
||||
}
|
||||
|
||||
if (errorType === ERROR_UNAUTHORIZED) {
|
||||
return <InvalidConfigurationError />
|
||||
}
|
||||
|
||||
if (errorType === ERROR_UPGRADE_REQUIRED) {
|
||||
return <UpgradeRequiredError />
|
||||
}
|
||||
|
||||
if (errorType === ERROR_UNKNOWN) {
|
||||
return <UnknownError message={errorMessage} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Spring
|
||||
native
|
||||
from={{ opacity: 0 }}
|
||||
to={{ opacity: 1 }}
|
||||
config={config.stiff}
|
||||
>
|
||||
{props => (
|
||||
<animated.div className="moco-bx-app-container" style={props}>
|
||||
<Header />
|
||||
<Observer>
|
||||
{() => (
|
||||
<>
|
||||
<Calendar
|
||||
fromDate={parse(fromDate)}
|
||||
toDate={parse(toDate)}
|
||||
activities={activities}
|
||||
schedules={schedules}
|
||||
selectedDate={new Date(this.changesetWithDefaults.date)}
|
||||
onChange={this.handleSelectDate}
|
||||
/>
|
||||
<Form
|
||||
changeset={this.changesetWithDefaults}
|
||||
projects={projects}
|
||||
errors={this.formErrors}
|
||||
onChange={this.handleChange}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Observer>
|
||||
</animated.div>
|
||||
)}
|
||||
</Spring>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default App
|
||||
23
src/js/components/Bubble.js
Normal file
23
src/js/components/Bubble.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import logoUrl from "images/logo.png"
|
||||
|
||||
const Bubble = ({ bookedHours, onClick }) => (
|
||||
<div className="moco-bx-bubble-inner" onClick={onClick}>
|
||||
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
|
||||
{bookedHours > 0 ? (
|
||||
<span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
Bubble.propTypes = {
|
||||
bookedHours: PropTypes.number,
|
||||
onClick: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
Bubble.defaultProps = {
|
||||
bookedHours: 0
|
||||
}
|
||||
|
||||
export default Bubble
|
||||
40
src/js/components/Calendar/Day.js
Normal file
40
src/js/components/Calendar/Day.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useCallback } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import Hours from "./Hours"
|
||||
import { format, getDay } from "date-fns"
|
||||
import deLocale from "date-fns/locale/de"
|
||||
import cn from "classnames"
|
||||
|
||||
const Day = ({ date, hours, absence, active, onClick }) => {
|
||||
const handleClick = useCallback(() => onClick(date), [date])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"moco-bx-calendar__day",
|
||||
`moco-bx-calendar__day--week-day-${getDay(date)}`,
|
||||
{
|
||||
"moco-bx-calendar__day--active": active,
|
||||
"moco-bx-calendar__day--filled": hours > 0,
|
||||
"moco-bx-calendar__day--absence": absence
|
||||
}
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="moco-bx-calendar__day-of-week">
|
||||
{format(date, "dd", { locale: deLocale })}
|
||||
</span>
|
||||
<Hours hours={hours} absence={absence} active={active} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Day.propTypes = {
|
||||
date: PropTypes.instanceOf(Date).isRequired,
|
||||
hours: PropTypes.number.isRequired,
|
||||
absence: PropTypes.object,
|
||||
active: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default Day
|
||||
43
src/js/components/Calendar/Hours.js
Normal file
43
src/js/components/Calendar/Hours.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
|
||||
const Hours = ({ hours, absence, active }) => {
|
||||
let style
|
||||
let content = null
|
||||
|
||||
if (hours > 0) {
|
||||
content = hours.toFixed(1)
|
||||
} else if (absence) {
|
||||
if (!active) {
|
||||
style = { backgroundColor: absence.assignment_color }
|
||||
}
|
||||
|
||||
content =
|
||||
absence.assignment_code === "1"
|
||||
? "/"
|
||||
: absence.assignment_code === "2"
|
||||
? "★"
|
||||
: absence.assignment_code === "3"
|
||||
? "K"
|
||||
: absence.assignment_code === "4"
|
||||
? "✈"
|
||||
: null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="moco-bx-calendar__hours" style={style}>
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Hours.propTypes = {
|
||||
hours: PropTypes.number.isRequired,
|
||||
absence: PropTypes.shape({
|
||||
assignment_code: PropTypes.string,
|
||||
assignment_color: PropTypes.string
|
||||
}),
|
||||
active: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
export default Hours
|
||||
60
src/js/components/Calendar/index.js
Normal file
60
src/js/components/Calendar/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import Day from "./Day"
|
||||
import { formatDate } from "utils"
|
||||
import { eachDay } from "date-fns"
|
||||
import { pathEq } from "lodash/fp"
|
||||
|
||||
const findAbsence = (date, schedules) =>
|
||||
schedules.find(pathEq("date", formatDate(date)))
|
||||
|
||||
const hoursAtDate = (date, activities) =>
|
||||
activities
|
||||
.filter(pathEq("date", formatDate(date)))
|
||||
.reduce((acc, activity) => acc + activity.hours, 0)
|
||||
|
||||
const Calendar = ({
|
||||
fromDate,
|
||||
toDate,
|
||||
selectedDate,
|
||||
activities,
|
||||
schedules,
|
||||
onChange
|
||||
}) => (
|
||||
<div className="moco-bx-calendar">
|
||||
{eachDay(fromDate, toDate).map(date => (
|
||||
<Day
|
||||
key={date}
|
||||
date={date}
|
||||
hours={hoursAtDate(date, activities)}
|
||||
absence={findAbsence(date, schedules)}
|
||||
active={formatDate(date) === formatDate(selectedDate)}
|
||||
onClick={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
Calendar.propTypes = {
|
||||
fromDate: PropTypes.instanceOf(Date).isRequired,
|
||||
toDate: PropTypes.instanceOf(Date).isRequired,
|
||||
selectedDate: PropTypes.instanceOf(Date).isRequired,
|
||||
activities: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
date: PropTypes.string.isRequired,
|
||||
hours: PropTypes.number.isRequired,
|
||||
timer_started_at: PropTypes.string
|
||||
}).isRequired
|
||||
),
|
||||
schedules: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
date: PropTypes.string,
|
||||
assignment_code: PropTypes.string,
|
||||
assignment_color: PropTypes.string
|
||||
})
|
||||
).isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default Calendar
|
||||
28
src/js/components/Errors/InvalidConfigurationError.js
Normal file
28
src/js/components/Errors/InvalidConfigurationError.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react"
|
||||
import settingsUrl from "images/settings.png"
|
||||
|
||||
const InvalidConfigurationError = () => (
|
||||
<div className="moco-bx-error-container">
|
||||
<h1>Bitte Einstellungen aktualisieren</h1>
|
||||
<ol>
|
||||
<li>Internetadresse eintragen</li>
|
||||
<li>Persönlichen API-Schlüssel eintragen</li>
|
||||
</ol>
|
||||
<button
|
||||
className="moco-bx-btn"
|
||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||
>
|
||||
Einstellungen öffnen
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
<img
|
||||
src={chrome.extension.getURL(settingsUrl)}
|
||||
alt="Browser extension configuration settings"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default InvalidConfigurationError
|
||||
21
src/js/components/Errors/UnknownError.js
Normal file
21
src/js/components/Errors/UnknownError.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import logo from "images/logo.png"
|
||||
|
||||
const UnknownError = ({ message = "Unbekannter Fehler" }) => (
|
||||
<div className="moco-bx-error-container">
|
||||
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
||||
<h1>Ups, es ist ein Fehler passiert!</h1>
|
||||
<p>Bitte überprüfe deine Internetverbindung.</p>
|
||||
<p>Wir wurden per Email benachrichtigt und untersuchen den Vorfall.</p>
|
||||
<br />
|
||||
<p>Fehlermeldung:</p>
|
||||
<pre>{message}</pre>
|
||||
</div>
|
||||
)
|
||||
|
||||
UnknownError.propTypes = {
|
||||
message: PropTypes.string
|
||||
}
|
||||
|
||||
export default UnknownError
|
||||
35
src/js/components/Errors/UpgradeRequiredError.js
Normal file
35
src/js/components/Errors/UpgradeRequiredError.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react"
|
||||
import { isChrome } from "utils/browser"
|
||||
import logo from "images/logo.png"
|
||||
import firefoxAddons from "images/firefox_addons.png"
|
||||
|
||||
const UpgradeRequiredError = () => (
|
||||
<div className="moco-bx-error-container">
|
||||
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
||||
<h1>Upgrade erforderlich</h1>
|
||||
<p>
|
||||
Die installierte MOCO Browser-Erweiterung ist veraltet — bitte
|
||||
aktualisieren.
|
||||
</p>
|
||||
{isChrome() ? (
|
||||
<button
|
||||
className="moco-bx-btn"
|
||||
onClick={() => chrome.runtime.sendMessage({ type: "openExtensions" })}
|
||||
>
|
||||
Browser-Erweiterungen öffnen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<p>Unter folgender URL:</p>
|
||||
<img
|
||||
className="firefox-addons"
|
||||
src={firefoxAddons}
|
||||
alt="about:addons"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default UpgradeRequiredError
|
||||
113
src/js/components/Form.js
Normal file
113
src/js/components/Form.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { Component } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import Select from "components/Select"
|
||||
import cn from "classnames"
|
||||
|
||||
class Form extends Component {
|
||||
static propTypes = {
|
||||
changeset: PropTypes.shape({
|
||||
project: PropTypes.object,
|
||||
task: PropTypes.object,
|
||||
hours: PropTypes.string
|
||||
}).isRequired,
|
||||
errors: PropTypes.object,
|
||||
projects: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
inline: true
|
||||
};
|
||||
|
||||
isValid = () => {
|
||||
const { changeset } = this.props
|
||||
return ["assignment_id", "task_id", "hours", "description"]
|
||||
.map(prop => changeset[prop])
|
||||
.every(Boolean)
|
||||
};
|
||||
|
||||
handleTextareaKeyDown = event => {
|
||||
const { onSubmit } = this.props
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
this.isValid() && onSubmit(event)
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { projects, changeset, errors, onChange, onSubmit } = this.props
|
||||
const project = Select.findOptionByValue(projects, changeset.assignment_id)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div
|
||||
className={cn("form-group", {
|
||||
"has-error": errors.assignment_id || errors.task_id
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
className="moco-bx-select"
|
||||
name="assignment_id"
|
||||
placeholder="Auswählen..."
|
||||
options={projects}
|
||||
value={changeset.assignment_id}
|
||||
hasError={!!errors.assignment_id}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Select
|
||||
className="moco-bx-select"
|
||||
name="task_id"
|
||||
placeholder="Auswählen..."
|
||||
options={project?.tasks || []}
|
||||
value={changeset.task_id}
|
||||
onChange={onChange}
|
||||
hasError={!!errors.task_id}
|
||||
noOptionsMessage={() => "Zuerst Projekt wählen"}
|
||||
/>
|
||||
{errors.assignment_id ? (
|
||||
<div className="form-error">{errors.assignment_id.join("; ")}</div>
|
||||
) : null}
|
||||
{errors.task_id ? (
|
||||
<div className="form-error">{errors.task_id.join("; ")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("form-group", { "has-error": errors.hours })}>
|
||||
<input
|
||||
name="hours"
|
||||
className="form-control"
|
||||
onChange={onChange}
|
||||
value={changeset.hours}
|
||||
placeholder="0:00"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.hours ? (
|
||||
<div className="form-error">{errors.hours.join("; ")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("form-group", { "has-error": errors.description })}>
|
||||
<textarea
|
||||
name="description"
|
||||
onChange={onChange}
|
||||
value={changeset.description}
|
||||
placeholder="Beschreibung der Tätigkeit - mind. 3 Zeichen"
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
onKeyDown={this.handleTextareaKeyDown}
|
||||
/>
|
||||
{errors.description ? (
|
||||
<div className="form-error">{errors.description.join("; ")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button className="moco-bx-btn" disabled={!this.isValid()}>
|
||||
OK
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Form
|
||||
103
src/js/components/Options.js
Normal file
103
src/js/components/Options.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { Component } from "react"
|
||||
import { observable } from "mobx"
|
||||
import { observer } from "mobx-react"
|
||||
import { isChrome, getSettings, setStorage } from "utils/browser"
|
||||
import ApiClient from "api/Client"
|
||||
|
||||
@observer
|
||||
class Options extends Component {
|
||||
@observable subdomain = "";
|
||||
@observable apiKey = "";
|
||||
@observable errorMessage = null;
|
||||
@observable isSuccess = false;
|
||||
|
||||
componentDidMount() {
|
||||
getSettings().then(({ subdomain, apiKey }) => {
|
||||
this.subdomain = subdomain || ""
|
||||
this.apiKey = apiKey || ""
|
||||
})
|
||||
}
|
||||
|
||||
onChange = event => {
|
||||
this[event.target.name] = event.target.value.trim()
|
||||
};
|
||||
|
||||
handleSubmit = _event => {
|
||||
this.isSuccess = false
|
||||
this.errorMessage = null
|
||||
setStorage({ subdomain: this.subdomain, apiKey: this.apiKey }).then(() => {
|
||||
const { version } = chrome.runtime.getManifest()
|
||||
const apiClient = new ApiClient({
|
||||
subdomain: this.subdomain,
|
||||
apiKey: this.apiKey,
|
||||
version
|
||||
})
|
||||
apiClient
|
||||
.login()
|
||||
.then(() => {
|
||||
this.isSuccess = true
|
||||
this.closeWindow()
|
||||
})
|
||||
.catch(error => {
|
||||
this.errorMessage =
|
||||
error.response?.data?.message || "Anmeldung fehlgeschlagen"
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
handleInputKeyDown = event => {
|
||||
if (event.key === "Enter") {
|
||||
this.handleSubmit()
|
||||
}
|
||||
};
|
||||
|
||||
closeWindow = () => {
|
||||
isChrome() && window.close()
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="moco-bx-options">
|
||||
<h2 style={{ textAlign: "center" }}>Einstellungen</h2>
|
||||
{this.errorMessage && (
|
||||
<div className="text-danger">{this.errorMessage}</div>
|
||||
)}
|
||||
{this.isSuccess && (
|
||||
<div className="text-success">Anmeldung erfolgreich</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Internetadresse</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
name="subdomain"
|
||||
value={this.subdomain}
|
||||
onKeyDown={this.handleInputKeyDown}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
<span className="input-group-addon">.mocoapp.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>API-Schlüssel</label>
|
||||
<input
|
||||
type="text"
|
||||
name="apiKey"
|
||||
value={this.apiKey}
|
||||
onKeyDown={this.handleInputKeyDown}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
<p className="text-muted">
|
||||
Den API-Schlüssel findest du in deinem Profil unter
|
||||
"Integrationen".
|
||||
</p>
|
||||
</div>
|
||||
<button className="moco-bx-btn" onClick={this.handleSubmit}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Options
|
||||
87
src/js/components/Popup.js
Normal file
87
src/js/components/Popup.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { Component } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import queryString from "query-string"
|
||||
import {
|
||||
ERROR_UNKNOWN,
|
||||
ERROR_UNAUTHORIZED,
|
||||
ERROR_UPGRADE_REQUIRED,
|
||||
serializeProps
|
||||
} from "utils"
|
||||
import { isChrome } from "utils/browser"
|
||||
|
||||
function getStyles(errorType) {
|
||||
return {
|
||||
width: "516px",
|
||||
height:
|
||||
errorType === ERROR_UNAUTHORIZED
|
||||
? "834px"
|
||||
: errorType === ERROR_UPGRADE_REQUIRED
|
||||
? isChrome()
|
||||
? "369px"
|
||||
: "461px"
|
||||
: errorType === ERROR_UNKNOWN
|
||||
? "550px"
|
||||
: "558px"
|
||||
}
|
||||
}
|
||||
|
||||
class Popup extends Component {
|
||||
static propTypes = {
|
||||
service: PropTypes.object,
|
||||
errorType: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
handleRequestClose = event => {
|
||||
if (event.target.classList.contains("moco-bx-popup")) {
|
||||
this.props.onRequestClose()
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Document might lose focus when clicking the browser action.
|
||||
// Document might be out of focus when hitting the shortcut key.
|
||||
// This puts the focus back to the document and ensures that:
|
||||
// - the autofocus on the hours input field is triggered
|
||||
// - the ESC key closes the popup without closing anything else
|
||||
window.focus()
|
||||
document.activeElement?.blur()
|
||||
}
|
||||
|
||||
render() {
|
||||
const serializedProps = serializeProps([
|
||||
"loading",
|
||||
"service",
|
||||
"lastProjectId",
|
||||
"lastTaskId",
|
||||
"roundTimeEntries",
|
||||
"projects",
|
||||
"activities",
|
||||
"schedules",
|
||||
"lastProjectId",
|
||||
"lastTaskId",
|
||||
"fromDate",
|
||||
"toDate",
|
||||
"errorType",
|
||||
"errorMessage"
|
||||
])(this.props)
|
||||
|
||||
const styles = getStyles(this.props.errorType)
|
||||
|
||||
return (
|
||||
<div className="moco-bx-popup" onClick={this.handleRequestClose}>
|
||||
<div className="moco-bx-popup-content" style={styles}>
|
||||
<iframe
|
||||
src={chrome.extension.getURL(
|
||||
`popup.html?${queryString.stringify(serializedProps)}`
|
||||
)}
|
||||
width={styles.width}
|
||||
height={styles.height}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Popup
|
||||
132
src/js/components/Select.js
Normal file
132
src/js/components/Select.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { Component } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import ReactSelect, { createFilter } from "react-select"
|
||||
import {
|
||||
values,
|
||||
isString,
|
||||
isNumber,
|
||||
join,
|
||||
filter,
|
||||
compose,
|
||||
property,
|
||||
flatMap,
|
||||
pathEq
|
||||
} from "lodash/fp"
|
||||
|
||||
const hasOptionGroups = options =>
|
||||
options.some(option => Boolean(option.options))
|
||||
|
||||
const customTheme = theme => ({
|
||||
...theme,
|
||||
borderRadius: 0,
|
||||
spacing: {
|
||||
...theme.spacing,
|
||||
baseUnit: 3,
|
||||
controlHeight: 32
|
||||
},
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "#38b5eb",
|
||||
primary75: "rgba(56, 181, 235, 0.25)",
|
||||
primary50: "#38b5eb",
|
||||
primary25: "#38b5eb"
|
||||
}
|
||||
})
|
||||
|
||||
const customStyles = props => ({
|
||||
control: (base, _state) => ({
|
||||
...base,
|
||||
borderColor: props.hasError ? "#FB3A2F" : base.borderColor
|
||||
}),
|
||||
valueContainer: base => ({
|
||||
...base,
|
||||
padding: "4px 12px"
|
||||
}),
|
||||
input: base => ({
|
||||
...base,
|
||||
border: "0 !important",
|
||||
boxShadow: "0 !important"
|
||||
}),
|
||||
groupHeading: (base, _state) => ({
|
||||
...base,
|
||||
color: "black",
|
||||
textTransform: "none",
|
||||
fontWeight: "bold",
|
||||
fontSize: "100%",
|
||||
padding: "2px 7px 4px"
|
||||
}),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
padding: hasOptionGroups(state.options)
|
||||
? "4px 7px 4px 20px"
|
||||
: "4px 7px 4px",
|
||||
backgroundColor: state.isFocused ? "#38b5eb" : "none",
|
||||
color: state.isFocused ? "white" : "hsl(0, 0%, 20%)"
|
||||
})
|
||||
})
|
||||
|
||||
const filterOption = createFilter({
|
||||
stringify: compose(
|
||||
join(" "),
|
||||
filter(value => isString(value) || isNumber(value)),
|
||||
values,
|
||||
property("data")
|
||||
)
|
||||
})
|
||||
|
||||
export default class Select extends Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
options: PropTypes.array,
|
||||
hasError: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
static findOptionByValue = (selectOptions, value) => {
|
||||
const options = flatMap(
|
||||
option => (option.options ? option.options : option),
|
||||
selectOptions
|
||||
)
|
||||
|
||||
return options.find(pathEq("value", value)) || null
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.select = React.createRef()
|
||||
}
|
||||
|
||||
handleChange = option => {
|
||||
const { name, onChange } = this.props
|
||||
const { value } = option
|
||||
onChange({ target: { name, value } })
|
||||
};
|
||||
|
||||
handleKeyDown = event => {
|
||||
if (!this.select.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.select.current.state.menuIsOpen && event.key === "Enter") {
|
||||
this.select.current.setState({ menuIsOpen: true })
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value, ...passThroughProps } = this.props
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
{...passThroughProps}
|
||||
ref={this.select}
|
||||
value={Select.findOptionByValue(this.props.options, value)}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
filterOption={filterOption}
|
||||
theme={customTheme}
|
||||
styles={customStyles(this.props)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
14
src/js/components/Spinner.js
Normal file
14
src/js/components/Spinner.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const Spinner = ({ style }) => (
|
||||
<div className='moco-bx-spinner__container' style={style}>
|
||||
<div className='moco-bx-spinner' role='status' />
|
||||
</div>
|
||||
)
|
||||
|
||||
Spinner.propTypes = {
|
||||
style: PropTypes.object
|
||||
}
|
||||
|
||||
export default Spinner
|
||||
13
src/js/components/shared/Header.js
Normal file
13
src/js/components/shared/Header.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import logoUrl from "images/logo.png"
|
||||
|
||||
const Header = () => (
|
||||
<div className="moco-bx-logo__container">
|
||||
<img
|
||||
className="moco-bx-logo"
|
||||
src={chrome.extension.getURL(logoUrl)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Header
|
||||
Reference in New Issue
Block a user