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

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