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:
Manuel Bouza
2019-03-22 15:56:24 +01:00
parent cbf79b960c
commit 28a9a86e27
58 changed files with 11017 additions and 47 deletions

63
src/js/api/Client.js Normal file
View File

@@ -0,0 +1,63 @@
import axios from "axios"
import { formatDate } from "utils"
const baseURL = subdomain => {
if (process.env.NODE_ENV === "production") {
return `https://${encodeURIComponent(
subdomain
)}.mocoapp.com/api/browser_extensions`
} else {
return `http://${encodeURIComponent(
subdomain
)}.mocoapp.localhost:3001/api/browser_extensions`
}
}
export default class Client {
#client;
#apiKey;
constructor({ subdomain, apiKey, version }) {
this.#apiKey = apiKey
this.#client = axios.create({
responseType: "json",
baseURL: baseURL(subdomain),
headers: {
common: {
"x-api-key": apiKey,
"x-extension-version": version
}
}
})
}
login = service =>
this.#client.post("session", {
api_key: this.#apiKey,
remote_service: service?.name,
remote_id: service?.id
});
projects = () => this.#client.get("projects");
schedules = (fromDate, toDate) =>
this.#client.get("schedules", {
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` }
});
activities = (fromDate, toDate) =>
this.#client.get("activities", {
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` }
});
bookedHours = service => {
if (!service) {
return Promise.resolve({ data: { hours: 0 } })
}
return this.#client.get("activities/tags", {
params: { selection: [service.id], remote_service: service.name }
})
};
createActivity = activity => this.#client.post("activities", { activity });
}

128
src/js/background.js Normal file
View File

@@ -0,0 +1,128 @@
import ApiClient from "api/Client"
import {
isChrome,
getCurrentTab,
getSettings,
isBrowserTab
} from "utils/browser"
import { BackgroundMessenger } from "utils/messaging"
import {
tabUpdated,
settingsChanged,
togglePopup
} from "utils/messageHandlers"
const messenger = new BackgroundMessenger()
messenger.on("togglePopup", () => {
getCurrentTab().then(tab => {
if (tab && !isBrowserTab(tab)) {
messenger.postMessage(tab, { type: "requestService" })
messenger.once("newService", ({ payload }) => {
togglePopup(tab, { messenger })(payload)
})
}
})
})
chrome.runtime.onMessage.addListener(action => {
if (action.type === "closePopup") {
getCurrentTab().then(tab => {
messenger.postMessage(tab, action)
})
}
if (action.type === "createActivity") {
const { activity, service } = action.payload
getCurrentTab().then(tab => {
getSettings().then(settings => {
const apiClient = new ApiClient(settings)
apiClient
.createActivity(activity)
.then(() => {
messenger.postMessage(tab, { type: "closePopup" })
apiClient.bookedHours(service).then(({ data }) => {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedHours: parseFloat(data[0]?.hours) || 0,
service
}
})
})
})
.catch(error => {
if (error.response?.status === 422) {
chrome.runtime.sendMessage({
type: "setFormErrors",
payload: error.response.data
})
}
})
})
})
}
if (action.type === "openOptions") {
let url
if (isChrome()) {
url = `chrome://extensions/?options=${chrome.runtime.id}`
} else {
url = browser.runtime.getURL("options.html")
}
return chrome.tabs.create({ url })
}
if (action.type === "openExtensions") {
if (isChrome()) {
chrome.tabs.create({ url: "chrome://extensions" })
}
}
})
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => {
if (areaName === "sync" && (apiKey || subdomain)) {
getSettings().then(settings => settingsChanged(settings, { messenger }))
}
})
})
chrome.runtime.onStartup.addListener(() => {
chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => {
if (areaName === "sync" && (apiKey || subdomain)) {
getSettings().then(settings => settingsChanged(settings, { messenger }))
}
})
})
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (!isBrowserTab(tab) && changeInfo.status === "complete") {
getSettings().then(settings => {
tabUpdated(tab, { settings, messenger })
})
}
})
chrome.tabs.onCreated.addListener(tab => {
if (!isBrowserTab(tab)) {
messenger.connectTab(tab)
}
})
chrome.tabs.onRemoved.addListener(messenger.disconnectTab)
chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => {
if (areaName === "sync" && (apiKey || subdomain)) {
getSettings().then(settings => settingsChanged(settings, { messenger }))
}
})
chrome.browserAction.onClicked.addListener(tab => {
if (!isBrowserTab(tab)) {
messenger.postMessage(tab, { type: "requestService" })
messenger.once("newService", ({ payload }) => {
togglePopup(tab, { messenger })(payload)
})
}
})

212
src/js/components/App.js Normal file
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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 &mdash; 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
View 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

View 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
&quot;Integrationen&quot;.
</p>
</div>
<button className="moco-bx-btn" onClick={this.handleSubmit}>
OK
</button>
</div>
)
}
}
export default Options

View 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
View 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)}
/>
)
}
}

View 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

View 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

115
src/js/content.js Normal file
View File

@@ -0,0 +1,115 @@
import React, { createRef } from "react"
import ReactDOM from "react-dom"
import { Transition, animated, config } from "react-spring/renderprops"
import Bubble from "./components/Bubble"
import Popup from "components/Popup"
import { createServiceFinder } from "utils/urlMatcher"
import remoteServices from "./remoteServices"
import { ErrorBoundary } from "utils/notifier"
import { ContentMessenger } from "utils/messaging"
import "../css/content.scss"
const popupRef = createRef()
const findService = createServiceFinder(remoteServices)(document)
chrome.runtime.onConnect.addListener(function(port) {
const messenger = new ContentMessenger(port)
port.onDisconnect.addListener(() => {
messenger.stop()
})
function updateBubble({ service, bookedHours } = {}) {
if (!document.getElementById("moco-bx-root")) {
const domRoot = document.createElement("div")
domRoot.setAttribute("id", "moco-bx-root")
document.body.appendChild(domRoot)
}
ReactDOM.render(
<ErrorBoundary>
<Transition
native
items={service}
from={{ opacity: "0" }}
enter={{ opacity: "1" }}
leave={{ opacity: "0" }}
config={config.stiff}
>
{service =>
service &&
// eslint-disable-next-line react/display-name
(props => (
<animated.div
className="moco-bx-bubble"
style={{ ...props, ...service.position }}
>
<Bubble
key={service.url}
bookedHours={bookedHours}
onClick={event => {
event.stopPropagation()
messenger.postMessage({ type: "togglePopup" })
}}
/>
</animated.div>
))
}
</Transition>
</ErrorBoundary>,
document.getElementById("moco-bx-root")
)
}
function openPopup(payload) {
if (!document.getElementById("moco-bx-popup-root")) {
const domRoot = document.createElement("div")
domRoot.setAttribute("id", "moco-bx-popup-root")
document.body.appendChild(domRoot)
}
ReactDOM.render(
<ErrorBoundary>
<Popup ref={popupRef} {...payload} onRequestClose={closePopup} />
</ErrorBoundary>,
document.getElementById("moco-bx-popup-root")
)
}
function closePopup() {
const domRoot = document.getElementById("moco-bx-popup-root")
if (domRoot) {
ReactDOM.unmountComponentAtNode(domRoot)
domRoot.remove()
}
}
messenger.on("requestService", () => {
const service = findService(window.location.href)
messenger.postMessage({
type: "newService",
payload: { isOpen: !!popupRef.current, service }
})
})
messenger.on("showBubble", ({ payload: { service, bookedHours } }) => {
updateBubble({ service, bookedHours })
})
messenger.on("hideBubble", () => {
updateBubble()
})
messenger.on("openPopup", ({ payload }) => {
openPopup(payload)
})
messenger.on("closePopup", () => {
closePopup()
})
messenger.on("activityCreated", () => {
closePopup()
})
})

14
src/js/options.js Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom'
import Options from './components/Options'
import { ErrorBoundary } from 'utils/notifier'
import '../css/options.scss'
const domContainer = document.querySelector('#moco-bx-root')
ReactDOM.render(
<ErrorBoundary>
<Options />
</ErrorBoundary>,
domContainer
)

31
src/js/popup.js Normal file
View File

@@ -0,0 +1,31 @@
import React from "react"
import ReactDOM from "react-dom"
import App from "./components/App"
import queryString from "query-string"
import { parseProps } from "utils"
import { ErrorBoundary } from "utils/notifier"
import "../css/popup.scss"
const parsedProps = parseProps([
"loading",
"service",
"projects",
"activities",
"schedules",
"lastProjectId",
"lastTaskId",
"roundTimeEntries",
"lastProjectId",
"lastTaskId",
"fromDate",
"toDate",
"errorType",
"errorMessage"
])(queryString.parse(location.search))
ReactDOM.render(
<ErrorBoundary>
<App {...parsedProps} />
</ErrorBoundary>,
document.querySelector("#moco-bx-root")
)

97
src/js/remoteServices.js Normal file
View File

@@ -0,0 +1,97 @@
export default {
asana: {
name: "asana",
urlPatterns: [
[/^https:\/\/app.asana.com\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]],
[
/^https:\/\/app.asana.com\/0\/search\/([^/]+)\/(\d+)/,
["domainUserId", "id"]
]
],
description: document =>
document
.querySelector(".ItemRow--highlighted textarea")
?.textContent?.trim() ||
document
.querySelector(".ItemRow--focused textarea")
?.textContent?.trim() ||
document.querySelector(".SingleTaskPane textarea")?.textContent?.trim()
},
"github-pr": {
name: "github",
urlPatterns: ["https\\://github.com/:org/:repo/pull/:id(/:tab)"],
id: (document, service, { org, repo, id }) =>
[service.key, org, repo, id].join("."),
description: (document, service, { org, repo, id }) =>
document.querySelector(".js-issue-title")?.textContent?.trim(),
projectId: document => {
const match = document
.querySelector(".js-issue-title")
?.textContent.trim()
?.match(/^\[(\d+)\]/)
return match && match[1]
},
position: { right: "2rem" }
},
"github-issue": {
name: "github",
urlPatterns: ["https\\://github.com/:org/:repo/issues/:id"],
id: (document, service, { org, repo, id }) =>
[service.key, org, repo, id].join("."),
description: (document, service, { org, repo, id }) =>
document.querySelector(".js-issue-title")?.textContent?.trim(),
position: { right: "2rem" }
},
jira: {
name: "jira",
urlPatterns: [
"https\\://:org.atlassian.net/secure/RapidBoard.jspa",
"https\\://:org.atlassian.net/browse/:id",
"https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board",
"https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board/backlog"
],
queryParams: {
id: "selectedIssue",
projectId: "projectKey"
},
description: (document, service, { id }) => {
const title =
document
.querySelector('[aria-label="Edit Summary"]')
?.parentNode?.querySelector("h1")
?.textContent?.trim() ||
document
.querySelector(".ghx-selected .ghx-summary")
?.textContent?.trim()
return `[${id}] ${title || ""}`
}
},
trello: {
name: "trello",
urlPatterns: ["https\\://trello.com/c/:id/:title"],
description: (document, service, { title }) =>
document.querySelector(".js-title-helper")?.textContent?.trim() || title,
position: { right: "calc(2rem + 4px)" }
},
youtrack: {
name: "youtrack",
urlPatterns: ["https\\://:org.myjetbrains.com/youtrack/issue/:id"],
description: document =>
document.querySelector("yt-issue-body h1")?.textContent?.trim()
},
wunderlist: {
name: "wunderlist",
urlPatterns: ["https\\://www.wunderlist.com/(webapp)#/tasks/:id(/*)"],
description: document =>
document
.querySelector(".taskItem.selected .taskItem-titleWrapper-title")
?.textContent?.trim(),
position: { right: "calc(2rem + 4px)" }
}
}

View File

@@ -0,0 +1,91 @@
export default class TimeInputParser {
#input;
constructor(input) {
this.#input = input.toLowerCase().replace(/[\s()]/g, "")
}
parseSeconds() {
if (this.#isDecimal()) {
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
} else if (this.#isTime()) {
return this.#parseTimeAsSeconds()
} else if (this.#isMinutes()) {
return this.#parseMinutesAsSeconds()
} else if (this.#isRange()) {
return this.#parseRange()
} else if (this.#isHoursAndMinutes()) {
return this.#parseHoursAndMinutes()
} else {
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
}
}
#calculateFromHoursAndMinutes = (hours, minutes, isNegative) => {
const calculated = hours * 3600 + minutes * 60
return isNegative ? -calculated : calculated
};
#parseDecimal = () => {
return this.#input.replace(/[.,]/g, ".")
};
#parseTimeAsSeconds = () => {
const match = this.#isTime()
const isNegative = "-" == match[1]
const hours = parseInt(match[2])
const minutes = parseInt(match[3])
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
};
#parseMinutesAsSeconds = () => {
const minutes = parseInt(this.#isMinutes()[1])
return minutes * 60
};
#parseRange = () => {
const match = this.#isRange()
const from_hours = parseInt(match[1])
const from_minutes = parseInt(match[2])
const to_hours = parseInt(match[3])
const to_minutes = parseInt(match[4])
return (to_hours - from_hours) * 3600 + (to_minutes - from_minutes) * 60
};
#parseHoursAndMinutes = () => {
const match = this.#isHoursAndMinutes()
const isNegative = "-" == match[1]
const hours = parseInt(match[2])
const minutes = parseInt(match[3])
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
};
#isDecimal = () => {
return this.#input.match(/^([-]?[0-9]{0,2})[.,]{1}([0-9]{1,2})$/)
};
#isTime = () => {
return this.#input.match(/^([-]?)([0-9]{1,2}):([0-9]{2})$/)
};
#isMinutes = () => {
return this.#input.match(/^([-]?[0-9]{1,3})(m|mins?)$/)
};
#isRange = () => {
return this.#input.match(
/^([0-9]{1,2})[:.]{0,1}([0-9]{2})-([0-9]{1,2})[:.]{0,1}([0-9]{2})$/
)
};
#isHoursAndMinutes = () => {
// 1h 14m(in)
return this.#input.match(/^([-]?)([0-9]{1,2})h([0-9]{1,2})(m|mins?)$/)
};
}

41
src/js/utils/browser.js Normal file
View File

@@ -0,0 +1,41 @@
export const isChrome = () => typeof browser === "undefined" && chrome
export const isFirefox = () => typeof browser !== "undefined" && chrome
import { head } from "lodash/fp"
export const getSettings = () => {
const keys = ["subdomain", "apiKey"]
const { version } = chrome.runtime.getManifest()
if (isChrome()) {
return new Promise(resolve => {
chrome.storage.sync.get(keys, data => {
resolve({ ...data, version })
})
})
} else {
return browser.storage.sync.get(keys).then(data => ({ ...data, version }))
}
}
export const setStorage = items => {
if (isChrome()) {
return new Promise(resolve => {
chrome.storage.sync.set(items, resolve)
})
} else {
return browser.storage.sync.set(items)
}
}
export const queryTabs = queryInfo => {
if (isChrome()) {
return new Promise(resolve => chrome.tabs.query(queryInfo, resolve))
} else {
return browser.tabs.query(queryInfo)
}
}
export const getCurrentTab = () => {
return queryTabs({ currentWindow: true, active: true }).then(head)
}
export const isBrowserTab = tab => /^(?:chrome|about):/.test(tab.url)

88
src/js/utils/index.js Normal file
View File

@@ -0,0 +1,88 @@
import {
groupBy,
compose,
map,
mapValues,
toPairs,
flatMap,
pathEq,
get,
find,
curry,
pick
} from "lodash/fp"
import { format } from "date-fns"
const nilToArray = input => input || []
export const ERROR_UNAUTHORIZED = "unauthorized"
export const ERROR_UPGRADE_REQUIRED = "upgrade-required"
export const ERROR_UNKNOWN = "unknown"
export const noop = () => null
export const findProjectBy = prop => val =>
compose(
find(pathEq(prop, val)),
flatMap(get("options"))
)
export const findProjectByIdentifier = findProjectBy("identifier")
export const findProjectByValue = findProjectBy("value")
export const findTask = id =>
compose(
find(pathEq("value", Number(id))),
get("tasks")
)
function taskOptions(tasks) {
return tasks.map(({ id, name, billable }) => ({
label: billable ? name : `(${name})`,
value: id,
billable
}))
}
export function projectOptions(projects) {
return projects.map(project => ({
value: project.id,
label: project.intern ? `(${project.name})` : project.name,
identifier: project.identifier,
customerName: project.customer_name,
tasks: taskOptions(project.tasks)
}))
}
export const groupedProjectOptions = compose(
map(([customerName, projects]) => ({
label: customerName,
options: projectOptions(projects)
})),
toPairs,
groupBy("customer_name"),
nilToArray
)
export const serializeProps = attrs =>
compose(
mapValues(JSON.stringify),
pick(attrs)
)
export const parseProps = attrs =>
compose(
mapValues(JSON.parse),
pick(attrs)
)
export const trace = curry((tag, value) => {
// eslint-disable-next-line no-console
console.log(tag, value)
return value
})
export const weekStartsOn = 1
export const formatDate = date => format(date, "YYYY-MM-DD")
export const extensionSettingsUrl = () =>
`chrome://extensions/?id=${chrome.runtime.id}`

View File

@@ -0,0 +1,127 @@
import ApiClient from "api/Client"
import {
ERROR_UNAUTHORIZED,
ERROR_UPGRADE_REQUIRED,
ERROR_UNKNOWN,
groupedProjectOptions,
weekStartsOn
} from "utils"
import { get, forEach, reject, isNil } from "lodash/fp"
import { startOfWeek, endOfWeek } from "date-fns"
import { createMatcher } from "utils/urlMatcher"
import remoteServices from "remoteServices"
import { queryTabs, isBrowserTab, getSettings } from "utils/browser"
const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn })
const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn })
const matcher = createMatcher(remoteServices)
export function tabUpdated(tab, { messenger, settings }) {
messenger.connectTab(tab)
const service = matcher(tab.url)
if (service?.match?.id) {
messenger.postMessage(tab, { type: "requestService" })
messenger.once("newService", ({ payload: { service } }) => {
const apiClient = new ApiClient(settings)
apiClient
.bookedHours(service)
.then(({ data }) => {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedHours: parseFloat(data[0]?.hours) || 0,
service
}
})
})
.catch(() => {
messenger.postMessage(tab, {
type: "showBubble",
payload: {
bookedHours: 0,
service
}
})
})
})
} else {
messenger.postMessage(tab, { type: "hideBubble" })
}
}
export function settingsChanged(settings, { messenger }) {
queryTabs({ currentWindow: true })
.then(reject(isBrowserTab))
.then(
forEach(tab => {
messenger.postMessage(tab, { type: "closePopup" })
tabUpdated(tab, { settings, messenger })
})
)
}
export function togglePopup(tab, { messenger }) {
return function({ isOpen, service } = {}) {
if (isNil(isOpen)) {
return
}
if (isOpen) {
messenger.postMessage(tab, { type: "closePopup" })
} else {
openPopup(tab, { service, messenger })
}
}
}
function openPopup(tab, { service, messenger }) {
messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } })
const fromDate = getStartOfWeek()
const toDate = getEndOfWeek()
getSettings()
.then(settings => new ApiClient(settings))
.then(apiClient =>
Promise.all([
apiClient.login(service),
apiClient.projects(),
apiClient.activities(fromDate, toDate),
apiClient.schedules(fromDate, toDate)
])
)
.then(responses => {
const action = {
type: "openPopup",
payload: {
service,
lastProjectId: get("[0].data.last_project_id", responses),
lastTaskId: get("[0].data.last_task_id", responses),
roundTimeEntries: get("[0].data.round_time_entries", responses),
projects: groupedProjectOptions(get("[1].data.projects", responses)),
activities: get("[2].data", responses),
schedules: get("[3].data", responses),
fromDate,
toDate,
loading: false
}
}
messenger.postMessage(tab, action)
})
.catch(error => {
let errorType, errorMessage
if (error.response?.status === 401) {
errorType = ERROR_UNAUTHORIZED
} else if (error.response?.status === 426) {
errorType = ERROR_UPGRADE_REQUIRED
} else {
errorType = ERROR_UNKNOWN
errorMessage = error.message
}
messenger.postMessage(tab, {
type: "openPopup",
payload: { errorType, errorMessage }
})
})
}

99
src/js/utils/messaging.js Normal file
View File

@@ -0,0 +1,99 @@
export class BackgroundMessenger {
#ports = new Map();
#handlers = new Map();
#onceHandlers = new Map();
#handler = action => {
const handler = this.#handlers.get(action.type)
if (handler) {
handler(action)
}
};
#onceHandler = action => {
const handler = this.#onceHandlers.get(action.type)
this.#onceHandlers.delete(action.type)
if (handler) {
handler(action)
}
};
#registerPort = (tabId, port) => {
this.#ports.set(tabId, port)
port.onMessage.addListener(this.#handler)
port.onMessage.addListener(this.#onceHandler)
port.onDisconnect.addListener(() => {
this.#unregisterPort(tabId, port)
})
};
#unregisterPort = (tabId, port) => {
port.onMessage.removeListener(this.#handler)
port.onMessage.removeListener(this.#onceHandler)
port.disconnect()
this.#ports.delete(tabId)
};
connectTab = tab => {
const currentPort = this.#ports.get(tab.id)
if (!currentPort) {
const port = chrome.tabs.connect(tab.id)
this.#registerPort(tab.id, port)
}
};
disconnectTab = tabId => {
const port = this.#ports.get(tabId)
if (port) {
this.#unregisterPort(tabId, port)
}
};
postMessage = (tab, action) => {
const port = this.#ports.get(tab.id)
if (port) {
port.postMessage(action)
}
};
once = (type, handler) => {
this.#onceHandlers.set(type, handler)
};
on = (type, handler) => {
this.#handlers.set(type, handler)
};
}
export class ContentMessenger {
#port;
#handlers = new Map();
#handler = action => {
const handler = this.#handlers.get(action.type)
if (handler) {
handler(action)
}
};
constructor(port) {
this.#port = port
this.#port.onMessage.addListener(this.#handler)
}
postMessage = action => {
if (this.#port) {
this.#port.postMessage(action)
}
};
on = (type, handler) => {
this.#handlers.set(type, handler)
};
stop = () => {
this.#port.onMessage.removeListener(this.#handler)
this.#port = null
this.#handlers.clear()
};
}

39
src/js/utils/notifier.js Normal file
View File

@@ -0,0 +1,39 @@
import React from "react"
import bugsnag from "@bugsnag/js"
import bugsnagReact from "@bugsnag/plugin-react"
import { includes } from "lodash/fp"
function getAppVersion() {
try {
return chrome.runtime.getManifest().version
} catch (error) {
return
}
}
const filterReport = report => {
const appVersion = getAppVersion()
if (!appVersion) {
return false
}
const scripts = ["background", "content", "options", "popup"].map(
file => `${chrome.extension.getURL(file)}.${appVersion}.js`
)
return scripts.some(script => report.stacktrace.some(includes(script)))
}
const bugsnagClient = bugsnag({
apiKey: "da6caac4af70af3e4683454b40fe5ef5",
appVersion: getAppVersion(),
collectUserIp: false,
beforeSend: filterReport,
releaseStage: process.env.NODE_ENV,
notifyReleaseStages: ["production"]
})
bugsnagClient.use(bugsnagReact, React)
export default bugsnagClient
export const ErrorBoundary = bugsnagClient.getPlugin("react")

104
src/js/utils/urlMatcher.js Normal file
View File

@@ -0,0 +1,104 @@
import UrlPattern from "url-pattern"
import {
isFunction,
isUndefined,
compose,
toPairs,
map,
pipe
} from "lodash/fp"
import queryString from "query-string"
const extractQueryParams = (queryParams, query) => {
return toPairs(queryParams).reduce((acc, [key, param]) => {
acc[key] = query[param]
return acc
}, {})
}
const createEvaluator = args => fnOrValue => {
if (isUndefined(fnOrValue)) {
return
}
if (isFunction(fnOrValue)) {
return fnOrValue(...args)
}
return fnOrValue
}
const parseServices = compose(
map(([key, config]) => ({
...config,
key,
patterns: config.urlPatterns.map(pattern => {
if (Array.isArray(pattern)) {
return new UrlPattern(...pattern)
}
return new UrlPattern(pattern)
})
})),
toPairs
)
export const createEnhancer = document => service => {
if (!service) {
return
}
const match = service.match
const args = [document, service, match]
const evaluate = createEvaluator(args)
return {
...service,
id: evaluate(service.id),
description: evaluate(service.description),
projectId: evaluate(service.projectId),
taskId: evaluate(service.taskId),
position: service.position || { left: "50%", transform: "translateX(-50%)" }
}
}
export const createMatcher = remoteServices => {
const services = parseServices(remoteServices)
return tabUrl => {
const { origin, pathname, hash, search } = new URL(tabUrl)
const url = `${origin}${pathname}${hash}`
const query = queryString.parse(search)
const service = services.find(service =>
service.patterns.some(pattern => pattern.match(url))
)
if (!service) {
return
}
const pattern = service.patterns.find(pattern => pattern.match(url))
let match = pattern.match(url)
if (service.queryParams) {
const extractedQueryParams = extractQueryParams(
service.queryParams,
query
)
match = { ...extractedQueryParams, ...match }
}
return {
...match,
...service,
url,
match
}
}
}
export const createServiceFinder = remoteServices => document => {
const matcher = createMatcher(remoteServices)
const enhancer = createEnhancer(document)
return pipe(
matcher,
enhancer
)
}