Compare commits

...

18 Commits

Author SHA1 Message Date
Manuel Bouza
1533c2261f Update projecte regex to match on alphanumeric values with hyphens 2019-03-30 07:51:59 +01:00
Manuel Bouza
d8398fca5f Pump version to 1.1.0 2019-03-30 07:26:25 +01:00
Manuel Bouza
02a0bec738 Browser extension fixes (#8)
* Set full url on service

* Link logo to `/activities` in modal

* Update changelog

* Honor the selected task and set the correct billability
2019-03-30 06:59:18 +01:00
Manuel Bouza
0f5172a820 Read project identifier in asana service (#7) 2019-03-30 06:54:28 +01:00
Tobias Miesel
a3f94738b6 Merge pull request #6 from hundertzehn/feature/meistertask
Add support for Meistertask
2019-03-30 06:52:54 +01:00
Manuel Bouza
c153eb6c91 Update regex for project identifier to match anywhere 2019-03-29 22:26:35 +01:00
Manuel Bouza
87aaa99276 Parse description and projectId 2019-03-29 22:24:32 +01:00
Manuel Bouza
e6b6f67814 Add support for Meistertask service 2019-03-29 22:24:32 +01:00
Manuel Bouza
1f8bc33830 Change default subdomain to unset 2019-03-28 09:12:34 +01:00
Manuel Bouza
8e55c13d72 Add license information 2019-03-26 16:57:23 +01:00
Manuel Bouza
b02be37bdd Update CHANGELOG 2019-03-26 16:44:12 +01:00
Manuel Bouza
76422d7343 Update configuration for local builds 2019-03-26 16:13:27 +01:00
Manuel Bouza
22ac8f4984 Update README, add CHANGELOG 2019-03-26 16:13:27 +01:00
Manuel Bouza
1b1fae6f7a Allow to set tag in description 2019-03-26 16:00:02 +01:00
Manuel Bouza
f49c0bdc3d Update jira service configuratioin 2019-03-26 12:41:00 +01:00
Manuel Bouza
b9f417140d Create source zip after build 2019-03-26 12:40:19 +01:00
Manuel Bouza
29db681e1c Set default Bubble position to bottom right. 2019-03-26 10:01:58 +01:00
Manuel Bouza
dda92746fa Set subdomain to "__unset__" if it empty
This is to prevent invalid network requests with an empty subdomain.
2019-03-26 10:01:37 +01:00
25 changed files with 412 additions and 130 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
BUGSNAG_API_KEY=
APPLICATION_ID=

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/ node_modules/
build/ build/
.env

42
CHANGELOG.md Normal file
View File

@@ -0,0 +1,42 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- Add support for starting/stopping a timer
- Show hours as HH:MM or decimal in the Bubble, depending on setting in MOCO
## [1.0.18] - 2019-03-23
### Added
- First release of version 1
## [1.0.19] - 2019-03-26
### Changed
- Position Bubble in the bottom right by default
### Fixed
- Set default value of subdomain to `__unset__` to prevent network error if it is empty
## [1.0.20] - 2019-03-26
### Added
- Add support for tags in description
## [1.0.21] - 2019-03-26
### Changed
- Update README with example configuration and instructions for local installation
## [1.0.22] - 2019-03-28
### Changed
- Change the default value of subdomain to `unset` to have a well-formed URL.
## [1.1.0] - 2019-03-30
### Added
- Read project identifier from Asana project title
- Add support for meistertask.com
### Fixed
- Link logo in modal to MOCO activities page
- Set full url on service, including query params

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019, hundertzehn GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

116
README.md
View File

@@ -1,15 +1,6 @@
mocoapp-browser-extension # MOCO Browser Extension
=========================
Documentation ## Development
-------------
* https://checklyhq.com/blog/2018/08/creating-a-chrome-extension-in-2018-the-good-the-bad-and-the-meh/
* https://developer.chrome.com/extensions
* https://developer.chrome.com/extensions/api_index
Development
-----------
* run `yarn` * run `yarn`
* run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`) * run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`)
@@ -18,9 +9,106 @@ Development
* Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox` * Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox`
* reload browser extension after change * reload browser extension after change
Release ## Production Build
-------
* bump version in `package.json` * bump version in `package.json`
* run `yarn build` * run `yarn build`
* upload Chrome and Firefox extensions in `build/chrome` and `build/firefox` respectively * The Chrome and Firefox extensions are available as ZIP-files in `build/chrome` and `build/firefox` respectively
## Install Local Builds
### Chrome
1. `yarn build:chrome`
1. Visit `chrome://extensions`
2. Enable `Developer mode`
3. `Load unpacked` and select the `build/chrome` folder.
### Firefox
1. `yarn build:firefox`
1. Visit `about:debugging`
2. Click on `Load temporary Add-on` and select the ZIP-file in `build/firefox`
Only signed extensions can be permantly installed in Firefox (unless you are using <em>Firefox Developer Edition</em>). To sign the build, proceed as described in [Getting started with web-ext](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Getting_started_with_web-ext).
You can keep the extension settings between builds by providing a stable `APPLICATION_ID` between builds. You can set an `APPLICATION_ID` in a file named `.env` or at build time as follows:
`APPLICATION_ID=my-custom-moco-extension@mycompany.com yarn build:firefox`
## Remote Service Configuration
Remote services are configured in `src/remoteServices.js`.
A remote service is configured as follows:
```javascript
{
service_key: {
name: "service_name",
urlPatterns: [
"https:\\://:subdomain.example.com/card/:id",
[/^https:\/\/(\w+).example.com\/card\/(\d+), ["subdomain", "id"]],
],
queryParams: {
projectId: "currentList"
},
description: (document, service, { subdomain, id, projectId }) => {
const title = document
.querySelector('.title')
?.textContent
?.trim()
return `#${id} ${service.key} ${title || ""}`
},
projectId: (document, service, { subdomain, id, projectId }) => {
return projectId
},
position: { left: "50%", transform: "translate(-50%)" }
}
}
```
| Parameter | Description |
|--------------|:-------------|
| service_key | `string` &mdash; Unique identifier for the service |
| service_name | `string` &mdash; Must be one of the registered services `trello`, `jira`, `asana`, `wunderlist`, `github` or `youtrack` |
| urlPatterns | `string` \| `RegEx` &mdash; A valid URL pattern or regular expression, as described in the [url-pattern](https://www.npmjs.com/package/url-pattern) package. |
| queryParams | `Object` &mdash; The object value is the name of the query parameter and the key the property it will available on, e.g. the value of the query parameter `currentList` will be available under `projectId`. Matches in `urlPatterns` have precedence over matches in `queryParams`. |
| description | `undefined` \| `string` \| `function` &mdash; The default description of the service. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and the return value set as the default description. |
| projectId | `undefined` \| `string` \| `function` &mdash; The pre-selected project of the service matching the MOCO project identifier. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and must return the MOCO project identifier or `undefined`. |
| position | `Object` &mdash; CSS properties as object styles for position the bubble. Defaults to `{ right: calc(4rem + 5px)` |
## Adding a Custom Service
1. Fork and clone this repository
2. Add your service to `src/removeServices.js`, e.g. for self-hosted Jira copy the entry with the key `jira` and update the `urlPatterns`:
```javascript
"self-hosted-jira": {
name: "jira",
urlPatterns: [
"https\\://jira.my-company.com/secure/RapidBoard.jspa",
"https\\://jira.my-company.net/browse/:id",
"https\\://jira.my-company.net/jira/software/projects/:projectId/boards/:board",
"https\\://jira.my-company.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 || ""}`
}
},
```
3. Build the extension (see [Production Build](#production-build)).
4. Install the extension locally (see [Install Local Builds](#install-local-builds)).

View File

@@ -1,24 +1,28 @@
{ {
"name": "moco-browser-extensions", "name": "moco-browser-extensions",
"description": "Browser plugin for MOCO", "description": "Browser plugin for MOCO",
"version": "1.0.18", "version": "1.1.0",
"license": "MIT",
"scripts": { "scripts": {
"start": "yarn start:chrome", "start": "yarn start:chrome",
"start:chrome": "node_modules/.bin/webpack --config webpack.chrome.config.js --watch --env.browser chrome --env.NODE_ENV development", "start:chrome": "node_modules/.bin/webpack --config webpack.chrome.config.js --watch --env.browser chrome --env.NODE_ENV development",
"start:firefox": "node_modules/.bin/webpack --config webpack.firefox.config.js --watch --env.browser firefox --env.NODE_ENV development", "start:firefox": "node_modules/.bin/webpack --config webpack.firefox.config.js --watch --env.browser firefox --env.NODE_ENV development",
"zip:chrome": "zip -qr build/chrome/moco-bx-source.zip . -x .git/\\* build/\\* node_modules/\\* test/\\* .DS_Store",
"zip:firefox": "zip -qr build/firefox/moco-bx-source.zip . -x .git/\\* build/\\* node_modules/\\* test/\\* .DS_Store",
"build:chrome": "node_modules/.bin/webpack -p --config webpack.chrome.config.js --env.browser chrome --env.NODE_ENV production", "build:chrome": "node_modules/.bin/webpack -p --config webpack.chrome.config.js --env.browser chrome --env.NODE_ENV production",
"build:firefox": "node_modules/.bin/webpack -p --config webpack.firefox.config.js --env.browser firefox --env.NODE_ENV production", "build:firefox": "node_modules/.bin/webpack -p --config webpack.firefox.config.js --env.browser firefox --env.NODE_ENV production",
"build": "yarn run build:firefox && yarn run build:chrome", "build": "yarn build:firefox && yarn zip:firefox && yarn build:chrome && yarn zip:chrome",
"test": "node_modules/.bin/jest", "test": "node_modules/.bin/jest",
"test:watch": "node_modules/.bin/jest --watch", "test:watch": "node_modules/.bin/jest --watch"
"release": "copyfiles main.css main.min.js background.min.js manifest.json popup.html options.html node_modules/jquery/dist/jquery.min.js node_modules/select2/select2.js src/images/* release"
}, },
"dependencies": { "dependencies": {
"@babel/polyfill": "^7.4.0",
"@bugsnag/js": "^5.2.0", "@bugsnag/js": "^5.2.0",
"@bugsnag/plugin-react": "^5.2.0", "@bugsnag/plugin-react": "^5.2.0",
"axios": "^0.18.0", "axios": "^0.18.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"date-fns": "^1.30.1", "date-fns": "^1.30.1",
"dotenv": "^7.0.0",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"mobx": "^5.5.0", "mobx": "^5.5.0",
"mobx-react": "^5.2.8", "mobx-react": "^5.2.8",
@@ -55,6 +59,7 @@
"prettier": "^1.16.4", "prettier": "^1.16.4",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"uuid": "^3.3.2",
"webpack": "^4.15.0", "webpack": "^4.15.0",
"webpack-bugsnag-plugins": "^1.3.0", "webpack-bugsnag-plugins": "^1.3.0",
"webpack-cli": "^3.0.8", "webpack-cli": "^3.0.8",

View File

@@ -4,6 +4,7 @@
#moco-bx-root { #moco-bx-root {
font-family: $font-family; font-family: $font-family;
color: $font-color; color: $font-color;
pointer-events: all;
.moco-bx-bubble { .moco-bx-bubble {
box-sizing: content-box; box-sizing: content-box;
@@ -48,6 +49,7 @@
#moco-bx-popup-root { #moco-bx-popup-root {
font-family: $font-family; font-family: $font-family;
color: $font-color; color: $font-color;
pointer-events: all;
iframe { iframe {
border: 0; border: 0;
@@ -58,16 +60,15 @@
} }
.moco-bx-popup { .moco-bx-popup {
position: fixed; /* Stay in place */ position: fixed;
z-index: 2000; /* Sit on top */ z-index: 2000;
padding-top: 100px; /* Location of the box */ padding-top: 100px;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; /* Full width */ width: 100%;
height: 100%; /* Full height */ height: 100%;
overflow: auto; /* Enable scroll if needed */ overflow: auto;
background-color: rgb(0, 0, 0); /* Fallback color */ background-color: rgba(0, 0, 0, 0.4);
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
.moco-bx-popup-content { .moco-bx-popup-content {
background-color: white; background-color: white;

View File

@@ -1,3 +1,4 @@
import "@babel/polyfill"
import ApiClient from "api/Client" import ApiClient from "api/Client"
import { import {
isChrome, isChrome,

View File

@@ -10,6 +10,7 @@ import {
ERROR_UNKNOWN, ERROR_UNKNOWN,
ERROR_UNAUTHORIZED, ERROR_UNAUTHORIZED,
ERROR_UPGRADE_REQUIRED, ERROR_UPGRADE_REQUIRED,
extractAndSetTag,
findProjectByValue, findProjectByValue,
findProjectByIdentifier, findProjectByIdentifier,
findTask, findTask,
@@ -35,6 +36,7 @@ class App extends Component {
projectId: PropTypes.string, projectId: PropTypes.string,
taskId: PropTypes.string taskId: PropTypes.string
}), }),
subdomain: PropTypes.string,
activities: PropTypes.array, activities: PropTypes.array,
schedules: PropTypes.array, schedules: PropTypes.array,
projects: PropTypes.array, projects: PropTypes.array,
@@ -61,12 +63,14 @@ class App extends Component {
const { service, projects, lastProjectId, lastTaskId } = this.props const { service, projects, lastProjectId, lastTaskId } = this.props
const project = const project =
findProjectByValue(this.changeset.assignment_id)(projects) ||
findProjectByIdentifier(service?.projectId)(projects) || findProjectByIdentifier(service?.projectId)(projects) ||
findProjectByValue(Number(lastProjectId))(projects) || findProjectByValue(Number(lastProjectId))(projects) ||
head(projects) head(projects)
const task = const task =
findTask(service?.taskId || lastTaskId)(project) || head(project?.tasks) findTask(this.changeset.task_id || service?.taskId || lastTaskId)(project) ||
head(project?.tasks)
const defaults = { const defaults = {
remote_service: service?.name, remote_service: service?.name,
@@ -80,13 +84,11 @@ class App extends Component {
seconds: seconds:
this.changeset.hours && this.changeset.hours &&
new TimeInputParser(this.changeset.hours).parseSeconds(), new TimeInputParser(this.changeset.hours).parseSeconds(),
description: service?.description description: service?.description,
tag: ""
} }
return { return { ...defaults, ...this.changeset }
...defaults,
...this.changeset
}
} }
componentDidMount() { componentDidMount() {
@@ -109,7 +111,7 @@ class App extends Component {
if (name === "assignment_id") { if (name === "assignment_id") {
const project = findProjectByValue(value)(projects) const project = findProjectByValue(value)(projects)
this.changeset.task_id = head(project?.tasks).value || null this.changeset.task_id = head(project?.tasks)?.value
} }
}; };
@@ -124,7 +126,7 @@ class App extends Component {
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
type: "createActivity", type: "createActivity",
payload: { payload: {
activity: this.changesetWithDefaults, activity: extractAndSetTag(this.changesetWithDefaults),
service service
} }
}) })
@@ -146,6 +148,7 @@ class App extends Component {
render() { render() {
const { const {
loading, loading,
subdomain,
projects, projects,
activities, activities,
schedules, schedules,
@@ -180,7 +183,7 @@ class App extends Component {
> >
{props => ( {props => (
<animated.div className="moco-bx-app-container" style={props}> <animated.div className="moco-bx-app-container" style={props}>
<Header /> <Header subdomain={subdomain} />
<Observer> <Observer>
{() => ( {() => (
<> <>

View File

@@ -2,8 +2,8 @@ import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import logoUrl from "images/logo.png" import logoUrl from "images/logo.png"
const Bubble = ({ bookedHours, onClick }) => ( const Bubble = ({ bookedHours }) => (
<div className="moco-bx-bubble-inner" onClick={onClick}> <div className="moco-bx-bubble-inner">
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} /> <img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
{bookedHours > 0 ? ( {bookedHours > 0 ? (
<span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span> <span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span>
@@ -12,8 +12,7 @@ const Bubble = ({ bookedHours, onClick }) => (
) )
Bubble.propTypes = { Bubble.propTypes = {
bookedHours: PropTypes.number, bookedHours: PropTypes.number
onClick: PropTypes.func.isRequired
} }
Bubble.defaultProps = { Bubble.defaultProps = {

View File

@@ -12,7 +12,7 @@ class Options extends Component {
@observable isSuccess = false; @observable isSuccess = false;
componentDidMount() { componentDidMount() {
getSettings().then(({ subdomain, apiKey }) => { getSettings(false).then(({ subdomain, apiKey }) => {
this.subdomain = subdomain || "" this.subdomain = subdomain || ""
this.apiKey = apiKey || "" this.apiKey = apiKey || ""
}) })

View File

@@ -52,6 +52,7 @@ class Popup extends Component {
const serializedProps = serializeProps([ const serializedProps = serializeProps([
"loading", "loading",
"service", "service",
"subdomain",
"lastProjectId", "lastProjectId",
"lastTaskId", "lastTaskId",
"roundTimeEntries", "roundTimeEntries",

View File

@@ -1,13 +1,20 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types'
import logoUrl from "images/logo.png" import logoUrl from "images/logo.png"
const Header = () => ( const Header = ({ subdomain }) => (
<div className="moco-bx-logo__container"> <div className="moco-bx-logo__container">
<img <a href={`https://${subdomain}.mocoapp.com/activities`} target="_blank" rel="noopener noreferrer">
className="moco-bx-logo" <img
src={chrome.extension.getURL(logoUrl)} className="moco-bx-logo"
/> src={chrome.extension.getURL(logoUrl)}
/>
</a>
</div> </div>
) )
Header.propTypes = {
subdomain: PropTypes.string
}
export default Header export default Header

View File

@@ -43,15 +43,12 @@ chrome.runtime.onConnect.addListener(function(port) {
<animated.div <animated.div
className="moco-bx-bubble" className="moco-bx-bubble"
style={{ ...props, ...service.position }} style={{ ...props, ...service.position }}
onClick={event => {
event.stopPropagation()
messenger.postMessage({ type: "togglePopup" })
}}
> >
<Bubble <Bubble key={service.url} bookedHours={bookedHours} />
key={service.url}
bookedHours={bookedHours}
onClick={event => {
event.stopPropagation()
messenger.postMessage({ type: "togglePopup" })
}}
/>
</animated.div> </animated.div>
)) ))
} }

View File

@@ -9,6 +9,7 @@ import "../css/popup.scss"
const parsedProps = parseProps([ const parsedProps = parseProps([
"loading", "loading",
"service", "service",
"subdomain",
"projects", "projects",
"activities", "activities",
"schedules", "schedules",

View File

@@ -1,3 +1,5 @@
const projectRegex = /\[([\w-]+)\]/
export default { export default {
asana: { asana: {
name: "asana", name: "asana",
@@ -15,7 +17,14 @@ export default {
document document
.querySelector(".ItemRow--focused textarea") .querySelector(".ItemRow--focused textarea")
?.textContent?.trim() || ?.textContent?.trim() ||
document.querySelector(".SingleTaskPane textarea")?.textContent?.trim() document.querySelector(".SingleTaskPane textarea")?.textContent?.trim(),
projectId: document => {
const match = document
.querySelector(".ProjectPageHeader-projectName")
?.textContent?.trim()
?.match(projectRegex)
return match && match[1]
}
}, },
"github-pr": { "github-pr": {
@@ -29,10 +38,9 @@ export default {
const match = document const match = document
.querySelector(".js-issue-title") .querySelector(".js-issue-title")
?.textContent.trim() ?.textContent.trim()
?.match(/^\[(\d+)\]/) ?.match(projectRegex)
return match && match[1] return match && match[1]
}, }
position: { right: "2rem" }
}, },
"github-issue": { "github-issue": {
@@ -41,8 +49,7 @@ export default {
id: (document, service, { org, repo, id }) => id: (document, service, { org, repo, id }) =>
[service.key, org, repo, id].join("."), [service.key, org, repo, id].join("."),
description: (document, service, { org, repo, id }) => description: (document, service, { org, repo, id }) =>
document.querySelector(".js-issue-title")?.textContent?.trim(), document.querySelector(".js-issue-title")?.textContent?.trim()
position: { right: "2rem" }
}, },
jira: { jira: {
@@ -66,7 +73,25 @@ export default {
document document
.querySelector(".ghx-selected .ghx-summary") .querySelector(".ghx-selected .ghx-summary")
?.textContent?.trim() ?.textContent?.trim()
return `[${id}] ${title || ""}` return `#${id} ${title || ""}`
}
},
meistertask: {
name: "meistertask",
urlPatterns: ["https\\://www.meistertask.com/app/task/:id/:slug"],
description: document => {
const json =
document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}"
const data = JSON.parse(json)
return data.taskName
},
projectId: document => {
const json =
document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}"
const data = JSON.parse(json)
const match = data.projectName?.match(projectRegex)
return match && match[1]
} }
}, },
@@ -74,8 +99,7 @@ export default {
name: "trello", name: "trello",
urlPatterns: ["https\\://trello.com/c/:id/:title"], urlPatterns: ["https\\://trello.com/c/:id/:title"],
description: (document, service, { title }) => description: (document, service, { title }) =>
document.querySelector(".js-title-helper")?.textContent?.trim() || title, document.querySelector(".js-title-helper")?.textContent?.trim() || title
position: { right: "calc(2rem + 4px)" }
}, },
youtrack: { youtrack: {
@@ -91,7 +115,6 @@ export default {
description: document => description: document =>
document document
.querySelector(".taskItem.selected .taskItem-titleWrapper-title") .querySelector(".taskItem.selected .taskItem-titleWrapper-title")
?.textContent?.trim(), ?.textContent?.trim()
position: { right: "calc(2rem + 4px)" }
} }
} }

View File

@@ -1,18 +1,28 @@
import { head } from "lodash/fp"
export const isChrome = () => typeof browser === "undefined" && chrome export const isChrome = () => typeof browser === "undefined" && chrome
export const isFirefox = () => typeof browser !== "undefined" && chrome export const isFirefox = () => typeof browser !== "undefined" && chrome
import { head } from "lodash/fp"
export const getSettings = () => { const DEFAULT_SUBDOMAIN = "unset"
export const getSettings = (withDefaultSubdomain = true) => {
const keys = ["subdomain", "apiKey"] const keys = ["subdomain", "apiKey"]
const { version } = chrome.runtime.getManifest() const { version } = chrome.runtime.getManifest()
if (isChrome()) { if (isChrome()) {
return new Promise(resolve => { return new Promise(resolve => {
chrome.storage.sync.get(keys, data => { chrome.storage.sync.get(keys, data => {
if (withDefaultSubdomain) {
data.subdomain = data.subdomain || DEFAULT_SUBDOMAIN
}
resolve({ ...data, version }) resolve({ ...data, version })
}) })
}) })
} else { } else {
return browser.storage.sync.get(keys).then(data => ({ ...data, version })) return browser.storage.sync.get(keys).then(data => {
if (withDefaultSubdomain) {
data.subdomain = data.subdomain || DEFAULT_SUBDOMAIN
}
return { ...data, version }
})
} }
} }

View File

@@ -86,3 +86,16 @@ export const formatDate = date => format(date, "YYYY-MM-DD")
export const extensionSettingsUrl = () => export const extensionSettingsUrl = () =>
`chrome://extensions/?id=${chrome.runtime.id}` `chrome://extensions/?id=${chrome.runtime.id}`
export const extractAndSetTag = changeset => {
let { description } = changeset
const match = description.match(/^#(\S+)/)
if (!match) {
return changeset
}
return {
...changeset,
description: description.replace(/^#\S+\s/, ""),
tag: match[1]
}
}

View File

@@ -76,52 +76,50 @@ export function togglePopup(tab, { messenger }) {
} }
} }
function openPopup(tab, { service, messenger }) { async function openPopup(tab, { service, messenger }) {
messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } }) messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } })
const fromDate = getStartOfWeek() const fromDate = getStartOfWeek()
const toDate = getEndOfWeek() const toDate = getEndOfWeek()
getSettings() const settings = await getSettings()
.then(settings => new ApiClient(settings)) const apiClient = new ApiClient(settings)
.then(apiClient => try {
Promise.all([ const responses = await Promise.all([
apiClient.login(service), apiClient.login(service),
apiClient.projects(), apiClient.projects(),
apiClient.activities(fromDate, toDate), apiClient.activities(fromDate, toDate),
apiClient.schedules(fromDate, toDate) apiClient.schedules(fromDate, toDate)
]) ])
) const action = {
.then(responses => { type: "openPopup",
const action = { payload: {
type: "openPopup", service,
payload: { subdomain: settings.subdomain,
service, lastProjectId: get("[0].data.last_project_id", responses),
lastProjectId: get("[0].data.last_project_id", responses), lastTaskId: get("[0].data.last_task_id", responses),
lastTaskId: get("[0].data.last_task_id", responses), roundTimeEntries: get("[0].data.round_time_entries", responses),
roundTimeEntries: get("[0].data.round_time_entries", responses), projects: groupedProjectOptions(get("[1].data.projects", responses)),
projects: groupedProjectOptions(get("[1].data.projects", responses)), activities: get("[2].data", responses),
activities: get("[2].data", responses), schedules: get("[3].data", responses),
schedules: get("[3].data", responses), fromDate,
fromDate, toDate,
toDate, loading: false
loading: false
}
} }
messenger.postMessage(tab, action) }
}) messenger.postMessage(tab, action)
.catch(error => { } catch (error) {
let errorType, errorMessage let errorType, errorMessage
if (error.response?.status === 401) { if (error.response?.status === 401) {
errorType = ERROR_UNAUTHORIZED errorType = ERROR_UNAUTHORIZED
} else if (error.response?.status === 426) { } else if (error.response?.status === 426) {
errorType = ERROR_UPGRADE_REQUIRED errorType = ERROR_UPGRADE_REQUIRED
} else { } else {
errorType = ERROR_UNKNOWN errorType = ERROR_UNKNOWN
errorMessage = error.message errorMessage = error.message
} }
messenger.postMessage(tab, { messenger.postMessage(tab, {
type: "openPopup", type: "openPopup",
payload: { errorType, errorMessage } payload: { errorType, errorMessage }
})
}) })
}
} }

View File

@@ -25,7 +25,7 @@ const filterReport = report => {
} }
const bugsnagClient = bugsnag({ const bugsnagClient = bugsnag({
apiKey: "da6caac4af70af3e4683454b40fe5ef5", apiKey: process.env.BUGSNAG_API_KEY,
appVersion: getAppVersion(), appVersion: getAppVersion(),
collectUserIp: false, collectUserIp: false,
beforeSend: filterReport, beforeSend: filterReport,

View File

@@ -57,7 +57,7 @@ export const createEnhancer = document => service => {
description: evaluate(service.description), description: evaluate(service.description),
projectId: evaluate(service.projectId), projectId: evaluate(service.projectId),
taskId: evaluate(service.taskId), taskId: evaluate(service.taskId),
position: service.position || { left: "50%", transform: "translateX(-50%)" } position: service.position || { right: "calc(2rem + 5px)" }
} }
} }
@@ -88,7 +88,7 @@ export const createMatcher = remoteServices => {
return { return {
...match, ...match,
...service, ...service,
url, url: tabUrl,
match match
} }
} }

View File

@@ -3,7 +3,8 @@ import {
findProjectByValue, findProjectByValue,
findProjectByIdentifier, findProjectByIdentifier,
findTask, findTask,
groupedProjectOptions groupedProjectOptions,
extractAndSetTag
} from "../../src/js/utils" } from "../../src/js/utils"
import { map } from "lodash/fp" import { map } from "lodash/fp"
@@ -86,4 +87,36 @@ describe("utils", () => {
]) ])
}) })
}) })
describe("extractAndSetTag", () => {
it("sets the correct tag and updates description", () => {
const changeset = {
description: "#meeting Lorem ipsum",
tag: ""
}
expect(extractAndSetTag(changeset)).toEqual({
description: "Lorem ipsum",
tag: "meeting"
})
})
it("only matches tag at the beginning", () => {
const changeset = {
description: "Lorem #meeting ipsum",
tag: ""
}
expect(extractAndSetTag(changeset)).toEqual(changeset)
})
it("returns the changeset if not tag is set", () => {
const changeset = {
description: "Without tag",
tag: ""
}
expect(extractAndSetTag(changeset)).toEqual(changeset)
})
})
}) })

View File

@@ -1,3 +1,5 @@
require("dotenv").config()
const path = require("path") const path = require("path")
const webpack = require("webpack") const webpack = require("webpack")
const CleanWebpackPlugin = require("clean-webpack-plugin") const CleanWebpackPlugin = require("clean-webpack-plugin")
@@ -60,7 +62,10 @@ module.exports = env => {
plugins: [ plugins: [
new CleanWebpackPlugin([`build/${env.browser}`]), new CleanWebpackPlugin([`build/${env.browser}`]),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify(env.NODE_ENV) "process.env.NODE_ENV": JSON.stringify(env.NODE_ENV),
"process.env.BUGSNAG_API_KEY": JSON.stringify(
process.env.BUGSNAG_API_KEY
)
}), }),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: "[name].css", filename: "[name].css",
@@ -95,22 +100,27 @@ module.exports = env => {
if (env.NODE_ENV === "production") { if (env.NODE_ENV === "production") {
config.devtool = "source-maps" config.devtool = "source-maps"
if (process.env.BUGSNAG_API_KEY) {
config.plugins.push(
new BugsnagBuildReporterPlugin({
apiKey: process.env.BUGSNAG_API_KEY,
appVersion: process.env.npm_package_version,
releaseStage: "production"
}),
// important: upload sourcemaps before removing source mapping url
new BugsnagSourceMapUploaderPlugin({
apiKey: process.env.BUGSNAG_API_KEY,
appVersion: process.env.npm_package_version,
publicPath:
env.browser === "firefox"
? "moz-extension*://*/"
: "chrome-extension*://*/", // extra asterisk after protocol needed
overwrite: true
})
)
}
config.plugins.push( config.plugins.push(
new BugsnagBuildReporterPlugin({
apiKey: "da6caac4af70af3e4683454b40fe5ef5",
appVersion: process.env.npm_package_version,
releaseStage: "production"
}),
// important: upload sourcemaps before removing source mapping url
new BugsnagSourceMapUploaderPlugin({
apiKey: "da6caac4af70af3e4683454b40fe5ef5",
appVersion: process.env.npm_package_version,
publicPath:
env.browser === "firefox"
? "moz-extension*://*/"
: "chrome-extension*://*/", // extra asterisk after protocol needed
overwrite: true
}),
new RemoveSourceMapPlugin(), new RemoveSourceMapPlugin(),
new ZipPlugin({ new ZipPlugin({
filename: `moco-bx-${env.browser}-v${ filename: `moco-bx-${env.browser}-v${

View File

@@ -1,3 +1,4 @@
const uuidv4 = require("uuid/v4")
const CopyWebpackPlugin = require("copy-webpack-plugin") const CopyWebpackPlugin = require("copy-webpack-plugin")
const { compact } = require("lodash/fp") const { compact } = require("lodash/fp")
@@ -30,7 +31,9 @@ module.exports = env => {
browser_style: true browser_style: true
}, },
browser_specific_settings: { browser_specific_settings: {
gecko: { id: "browser-extension@mocoapp.com" } gecko: {
id: process.env.APPLICATION_ID || `{${uuidv4()}}`
}
}, },
description: process.env.npm_package_description, description: process.env.npm_package_description,
version: process.env.npm_package_version version: process.env.npm_package_version

View File

@@ -607,6 +607,14 @@
"@babel/helper-regex" "^7.0.0" "@babel/helper-regex" "^7.0.0"
regexpu-core "^4.1.3" regexpu-core "^4.1.3"
"@babel/polyfill@^7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.4.0.tgz#90f9d68ae34ac42ab4b4aa03151848f536960218"
integrity sha512-bVsjsrtsDflIHp5I6caaAa2V25Kzn50HKPL6g3X0P0ni1ks+58cPB8Mz6AOKVuRPgaVdq/OwEUc/1vKqX+Mo4A==
dependencies:
core-js "^2.6.5"
regenerator-runtime "^0.13.2"
"@babel/preset-env@^7.2.2": "@babel/preset-env@^7.2.2":
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.3.1.tgz#389e8ca6b17ae67aaf9a2111665030be923515db" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.3.1.tgz#389e8ca6b17ae67aaf9a2111665030be923515db"
@@ -2021,6 +2029,11 @@ copyfiles@^2.1.0:
through2 "^2.0.1" through2 "^2.0.1"
yargs "^11.0.0" yargs "^11.0.0"
core-js@^2.6.5:
version "2.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895"
integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==
core-util-is@1.0.2, core-util-is@~1.0.0: core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -2437,6 +2450,11 @@ domutils@1.5.1:
dom-serializer "0" dom-serializer "0"
domelementtype "1" domelementtype "1"
dotenv@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-7.0.0.tgz#a2be3cd52736673206e8a85fb5210eea29628e7c"
integrity sha512-M3NhsLbV1i6HuGzBUH8vXrtxOk+tWmzWKDMbAVSUp3Zsjm7ywFeuwrUXhmhQyRK1q5B5GGy7hcXPbj3bnfZg2g==
duplexify@^3.4.2, duplexify@^3.6.0: duplexify@^3.4.2, duplexify@^3.6.0:
version "3.7.1" version "3.7.1"
resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
@@ -6155,6 +6173,11 @@ regenerator-runtime@^0.12.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.2:
version "0.13.2"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz#32e59c9a6fb9b1a4aff09b4930ca2d4477343447"
integrity sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==
regenerator-transform@^0.13.3: regenerator-transform@^0.13.3:
version "0.13.3" version "0.13.3"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb"