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:
parent
cbf79b960c
commit
28a9a86e27
8
.babelrc
Normal file
8
.babelrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env", "@babel/preset-react"],
|
||||||
|
"plugins": [
|
||||||
|
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||||
|
["@babel/plugin-proposal-class-properties", { "loose": true }],
|
||||||
|
["@babel/plugin-proposal-optional-chaining"]
|
||||||
|
]
|
||||||
|
}
|
73
.eslintrc.json
Normal file
73
.eslintrc.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"extends": ["eslint:recommended", "plugin:react/recommended"],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"commonjs": true,
|
||||||
|
"es6": true,
|
||||||
|
"node": true,
|
||||||
|
"jest/globals": true
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"chrome": false,
|
||||||
|
"browser": false
|
||||||
|
},
|
||||||
|
"plugins": ["jest"],
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 7,
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"experimentalObjectRestSpread": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"strict": 0,
|
||||||
|
"semi": ["error", "never"],
|
||||||
|
"array-callback-return": "warn",
|
||||||
|
"getter-return": "warn",
|
||||||
|
"no-const-assign": "warn",
|
||||||
|
"no-this-before-super": "warn",
|
||||||
|
"no-undef": "warn",
|
||||||
|
"no-unreachable": "warn",
|
||||||
|
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"constructor-super": "warn",
|
||||||
|
"valid-typeof": "warn",
|
||||||
|
"jest/no-disabled-tests": "warn",
|
||||||
|
"jest/no-focused-tests": "error",
|
||||||
|
"jest/no-identical-title": "error",
|
||||||
|
"jest/prefer-to-have-length": "warn",
|
||||||
|
"jest/valid-expect": "error",
|
||||||
|
"react/prop-types": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"ignore": [
|
||||||
|
"activityStore",
|
||||||
|
"commentStore",
|
||||||
|
"contactStore",
|
||||||
|
"dealStore",
|
||||||
|
"holidayStore",
|
||||||
|
"scheduleStore",
|
||||||
|
"presenceStore",
|
||||||
|
"employmentStore",
|
||||||
|
"match",
|
||||||
|
"permission",
|
||||||
|
"presenceStore",
|
||||||
|
"projectStore",
|
||||||
|
"purchaseBudgetStore",
|
||||||
|
"purchaseCategoryStore",
|
||||||
|
"purchasePaymentStore",
|
||||||
|
"userStore",
|
||||||
|
"vacationDailyHoursOverrideStore",
|
||||||
|
"purchaseStore",
|
||||||
|
"routing",
|
||||||
|
"scheduleStore",
|
||||||
|
"tagStore",
|
||||||
|
"taskTemplateStore",
|
||||||
|
"userStore"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
26
README.md
Normal file
26
README.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
mocoapp-browser-extension
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
* 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 start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`)
|
||||||
|
* load extension into browser:
|
||||||
|
* Chrome: visit `chrome://extensions` and load unpacked extension from `build/chrome`
|
||||||
|
* Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox`
|
||||||
|
* reload browser extension after change
|
||||||
|
|
||||||
|
Release
|
||||||
|
-------
|
||||||
|
|
||||||
|
* bump version in `package.json`
|
||||||
|
* run `yarn build`
|
||||||
|
* upload Chrome and Firefox extensions in `build/chrome` and `build/firefox` respectively
|
1
jest.config.js
Normal file
1
jest.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = {}
|
@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "MOCO Zeiterfassung",
|
|
||||||
"short_name": "MOCO",
|
|
||||||
"description": "MOCO Zeiterfassung Plugin",
|
|
||||||
"version": "0.9.20",
|
|
||||||
"manifest_version": 2,
|
|
||||||
"description" : "MOCO Time Tracking Plugin",
|
|
||||||
"icons": { "16": "src/images/logo.png",
|
|
||||||
"48": "src/images/logo.png",
|
|
||||||
"128": "src/images/logo.png" },
|
|
||||||
"options_ui": {
|
|
||||||
"page": "options.html",
|
|
||||||
"chrome_style": true
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
"https://*.mocoapp.com/*",
|
|
||||||
"background",
|
|
||||||
"storage",
|
|
||||||
"tabs"
|
|
||||||
],
|
|
||||||
"optional_permissions": [
|
|
||||||
"*://*/"
|
|
||||||
],
|
|
||||||
"background": {
|
|
||||||
"scripts": [
|
|
||||||
"node_modules/jquery/dist/jquery.min.js",
|
|
||||||
"node_modules/select2/select2.js",
|
|
||||||
"background.min.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"browser_action": {
|
|
||||||
"default_icon": "src/images/logo.png",
|
|
||||||
"default_title": "MOCO Time Tracking",
|
|
||||||
"default_popup": "popup.html"
|
|
||||||
},
|
|
||||||
"web_accessible_resources": [
|
|
||||||
"src/images/*"
|
|
||||||
],
|
|
||||||
"commands": {
|
|
||||||
"_execute_browser_action": {
|
|
||||||
"suggested_key": {
|
|
||||||
"default": "Ctrl+M",
|
|
||||||
"mac": "Command+M"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
63
package.json
Normal file
63
package.json
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "moco-browser-extensions",
|
||||||
|
"description": "Browser plugin for MOCO",
|
||||||
|
"version": "1.0.18",
|
||||||
|
"scripts": {
|
||||||
|
"start": "yarn start:chrome",
|
||||||
|
"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",
|
||||||
|
"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": "yarn run build:firefox && yarn run build:chrome",
|
||||||
|
"test": "node_modules/.bin/jest",
|
||||||
|
"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": {
|
||||||
|
"@bugsnag/js": "^5.2.0",
|
||||||
|
"@bugsnag/plugin-react": "^5.2.0",
|
||||||
|
"axios": "^0.18.0",
|
||||||
|
"classnames": "^2.2.6",
|
||||||
|
"date-fns": "^1.30.1",
|
||||||
|
"lodash": "^4.17.11",
|
||||||
|
"mobx": "^5.5.0",
|
||||||
|
"mobx-react": "^5.2.8",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"query-string": "^6.2.0",
|
||||||
|
"react": "^16.8.0",
|
||||||
|
"react-dom": "^16.8.0",
|
||||||
|
"react-select": "^2.3.0",
|
||||||
|
"react-spring": "^8.0.7",
|
||||||
|
"url-pattern": "^1.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.2.2",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.2.2",
|
||||||
|
"@babel/plugin-proposal-decorators": "^7.2.2",
|
||||||
|
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
||||||
|
"@babel/preset-env": "^7.2.2",
|
||||||
|
"@babel/preset-react": "^7.0.0",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
|
"babel-loader": "^8.0.4",
|
||||||
|
"babel-plugin-module-resolver": "^3.1.1",
|
||||||
|
"clean-webpack-plugin": "^1.0.1",
|
||||||
|
"copy-webpack-plugin": "^4.6.0",
|
||||||
|
"copyfiles": "^2.1.0",
|
||||||
|
"css-loader": "^2.1.0",
|
||||||
|
"eslint": "^5.7.0",
|
||||||
|
"eslint-plugin-jest": "^22.2.2",
|
||||||
|
"eslint-plugin-react": "^7.11.1",
|
||||||
|
"file-loader": "^3.0.1",
|
||||||
|
"html-webpack-plugin": "^3.2.0",
|
||||||
|
"jest": "^24.1.0",
|
||||||
|
"mini-css-extract-plugin": "^0.5.0",
|
||||||
|
"node-sass": "^4.11.0",
|
||||||
|
"prettier": "^1.16.4",
|
||||||
|
"sass-loader": "^7.1.0",
|
||||||
|
"style-loader": "^0.23.1",
|
||||||
|
"webpack": "^4.15.0",
|
||||||
|
"webpack-bugsnag-plugins": "^1.3.0",
|
||||||
|
"webpack-cli": "^3.0.8",
|
||||||
|
"zip-webpack-plugin": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
8
src/background.html
Normal file
8
src/background.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
42
src/css/_button.scss
Normal file
42
src/css/_button.scss
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
button.moco-bx-btn {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: white;
|
||||||
|
background-image: none;
|
||||||
|
background-color: #7dc332;
|
||||||
|
border-color: #7dc332;
|
||||||
|
border-radius: 0;
|
||||||
|
border-style: solid;
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: #639a28;
|
||||||
|
border-color: #639a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
color: black;
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #ccc;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
border-color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& + button {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
91
src/css/_form.scss
Normal file
91
src/css/_form.scss
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
@import "button";
|
||||||
|
|
||||||
|
input {
|
||||||
|
border-radius: 0;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: white;
|
||||||
|
border-color: #cccccc;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 100%;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-error {
|
||||||
|
input, textarea {
|
||||||
|
border-color: #FB3A2F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
color: #FB3A2F;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-addon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #555555;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #eeeeee;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
border-left: none;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-select + .moco-bx-select {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[name="hours"] {
|
||||||
|
width: 48px;
|
||||||
|
outline: 0 !important;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid #38b5eb;
|
||||||
|
box-shadow: 0 0 0 1px #38b5eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea[name="description"] {
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
outline: 0 !important;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid #38b5eb;
|
||||||
|
box-shadow: 0 0 0 1px #38b5eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
43
src/css/_reset.scss
Normal file
43
src/css/_reset.scss
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
html, body, div, span, applet, object, iframe,
|
||||||
|
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||||
|
a, abbr, acronym, address, big, cite, code,
|
||||||
|
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||||
|
small, strike, strong, sub, sup, tt, var,
|
||||||
|
b, u, i, center,
|
||||||
|
dl, dt, dd, ol, ul, li,
|
||||||
|
fieldset, form, label, legend,
|
||||||
|
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||||
|
article, aside, canvas, details, embed,
|
||||||
|
figure, figcaption, footer, header, hgroup,
|
||||||
|
menu, nav, output, ruby, section, summary,
|
||||||
|
time, mark, audio, video {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
font: inherit;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
/* HTML5 display-role reset for older browsers */
|
||||||
|
article, aside, details, figcaption, figure,
|
||||||
|
footer, header, hgroup, menu, nav, section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
ol, ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
blockquote, q {
|
||||||
|
quotes: none;
|
||||||
|
}
|
||||||
|
blockquote:before, blockquote:after,
|
||||||
|
q:before, q:after {
|
||||||
|
content: '';
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
24
src/css/_spinner.scss
Normal file
24
src/css/_spinner.scss
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#moco-bx-root {
|
||||||
|
@keyframes moco-bx-spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-spinner__container {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 120px;
|
||||||
|
|
||||||
|
.moco-bx-spinner {
|
||||||
|
display: block;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
border: 2px solid #999;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: moco-bx-spinner .75s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
src/css/_variables.scss
Normal file
5
src/css/_variables.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
$font-family: Arial, sans-serif;
|
||||||
|
$font-color: #191919;
|
||||||
|
$popup-width: 420px;
|
||||||
|
$popup-height: 463px;
|
||||||
|
|
77
src/css/content.scss
Normal file
77
src/css/content.scss
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
@import "variables";
|
||||||
|
@import "button";
|
||||||
|
|
||||||
|
#moco-bx-root {
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $font-color;
|
||||||
|
|
||||||
|
.moco-bx-bubble {
|
||||||
|
box-sizing: content-box;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
height: 60px;
|
||||||
|
width: 60px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: -1px -1px 15px 4px rgba(0, 0, 0, 0.05),
|
||||||
|
2px 2px 15px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 5px;
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.moco-bx-bubble-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
img.moco-bx-logo {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-booked-hours {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: black;
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px 0 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#moco-bx-popup-root {
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $font-color;
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-popup {
|
||||||
|
position: fixed; /* Stay in place */
|
||||||
|
z-index: 2000; /* Sit on top */
|
||||||
|
padding-top: 100px; /* Location of the box */
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
height: 100%; /* Full height */
|
||||||
|
overflow: auto; /* Enable scroll if needed */
|
||||||
|
background-color: rgb(0, 0, 0); /* Fallback color */
|
||||||
|
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
|
||||||
|
|
||||||
|
.moco-bx-popup-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
src/css/options.scss
Normal file
42
src/css/options.scss
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
@import "variables";
|
||||||
|
@import "reset";
|
||||||
|
@import "form";
|
||||||
|
|
||||||
|
#moco-bx-root {
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $font-color;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
.moco-bx-options {
|
||||||
|
padding: 0rem 2rem 2rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 1rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px 12px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: #7DC332;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: #FB3A2F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
128
src/css/popup.scss
Normal file
128
src/css/popup.scss
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
@import "form";
|
||||||
|
@import "spinner";
|
||||||
|
@import "variables";
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0px;
|
||||||
|
font-family: $font-family;
|
||||||
|
color: $font-color;
|
||||||
|
|
||||||
|
#moco-bx-root {
|
||||||
|
min-width: 516px;
|
||||||
|
|
||||||
|
.moco-bx-app-container {
|
||||||
|
width: 324px;
|
||||||
|
padding: 3rem 6rem;
|
||||||
|
|
||||||
|
.moco-bx-logo__container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
img.moco-bx-logo {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
line-height: 48px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-calendar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
|
||||||
|
.moco-bx-calendar__day {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
flex: 0 0 42px;
|
||||||
|
width: 42px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.moco-bx-calendar__day-of-week {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(0, 0%, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-calendar__hours {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
color: white;
|
||||||
|
background-color: #eee;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moco-bx-calendar__day--filled {
|
||||||
|
.moco-bx-calendar__hours {
|
||||||
|
background-color: #7dc332;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moco-bx-calendar__day--week-day-6,
|
||||||
|
&.moco-bx-calendar__day--week-day-0 {
|
||||||
|
.moco-bx-calendar__hours {
|
||||||
|
background-color: #bbb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.moco-bx-calendar__day--active {
|
||||||
|
.moco-bx-calendar__hours {
|
||||||
|
background-color: #38b5eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moco-bx-error-container {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
width: 420px;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 35px;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&.moco-bx-logo {
|
||||||
|
width: 48px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
src/images/error_general.png
Normal file
BIN
src/images/error_general.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
BIN
src/images/firefox_addons.png
Normal file
BIN
src/images/firefox_addons.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
BIN
src/images/settings.png
Normal file
BIN
src/images/settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
63
src/js/api/Client.js
Normal file
63
src/js/api/Client.js
Normal 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
128
src/js/background.js
Normal 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
212
src/js/components/App.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import React, { Component } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import Spinner from "components/Spinner"
|
||||||
|
import Form from "components/Form"
|
||||||
|
import Calendar from "components/Calendar"
|
||||||
|
import { observable, computed } from "mobx"
|
||||||
|
import { Observer, observer } from "mobx-react"
|
||||||
|
import { Spring, animated, config } from "react-spring/renderprops"
|
||||||
|
import {
|
||||||
|
ERROR_UNKNOWN,
|
||||||
|
ERROR_UNAUTHORIZED,
|
||||||
|
ERROR_UPGRADE_REQUIRED,
|
||||||
|
findProjectByValue,
|
||||||
|
findProjectByIdentifier,
|
||||||
|
findTask,
|
||||||
|
formatDate
|
||||||
|
} from "utils"
|
||||||
|
import InvalidConfigurationError from "components/Errors/InvalidConfigurationError"
|
||||||
|
import UpgradeRequiredError from "components/Errors/UpgradeRequiredError"
|
||||||
|
import UnknownError from "components/Errors/UnknownError"
|
||||||
|
import { parse } from "date-fns"
|
||||||
|
import Header from "./shared/Header"
|
||||||
|
import { head } from "lodash"
|
||||||
|
import TimeInputParser from "utils/TimeInputParser"
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class App extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
loading: PropTypes.bool,
|
||||||
|
service: PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
url: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
description: PropTypes.string,
|
||||||
|
projectId: PropTypes.string,
|
||||||
|
taskId: PropTypes.string
|
||||||
|
}),
|
||||||
|
activities: PropTypes.array,
|
||||||
|
schedules: PropTypes.array,
|
||||||
|
projects: PropTypes.array,
|
||||||
|
lastProjectId: PropTypes.number,
|
||||||
|
lastTaskId: PropTypes.number,
|
||||||
|
roundTimeEntries: PropTypes.bool,
|
||||||
|
fromDate: PropTypes.string,
|
||||||
|
toDate: PropTypes.string,
|
||||||
|
errorType: PropTypes.string,
|
||||||
|
errorMessage: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
activities: [],
|
||||||
|
schedules: [],
|
||||||
|
projects: [],
|
||||||
|
roundTimeEntries: false
|
||||||
|
};
|
||||||
|
|
||||||
|
@observable changeset = {};
|
||||||
|
@observable formErrors = {};
|
||||||
|
|
||||||
|
@computed get changesetWithDefaults() {
|
||||||
|
const { service, projects, lastProjectId, lastTaskId } = this.props
|
||||||
|
|
||||||
|
const project =
|
||||||
|
findProjectByIdentifier(service?.projectId)(projects) ||
|
||||||
|
findProjectByValue(Number(lastProjectId))(projects) ||
|
||||||
|
head(projects)
|
||||||
|
|
||||||
|
const task =
|
||||||
|
findTask(service?.taskId || lastTaskId)(project) || head(project?.tasks)
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
remote_service: service?.name,
|
||||||
|
remote_id: service?.id,
|
||||||
|
remote_url: service?.url,
|
||||||
|
date: formatDate(new Date()),
|
||||||
|
assignment_id: project?.value,
|
||||||
|
task_id: task?.value,
|
||||||
|
billable: task?.billable,
|
||||||
|
hours: "",
|
||||||
|
seconds:
|
||||||
|
this.changeset.hours &&
|
||||||
|
new TimeInputParser(this.changeset.hours).parseSeconds(),
|
||||||
|
description: service?.description
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...this.changeset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.addEventListener("keydown", this.handleKeyDown)
|
||||||
|
chrome.runtime.onMessage.addListener(this.handleSetFormErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener("keydown", this.handleKeyDown)
|
||||||
|
chrome.runtime.onMessage.removeListener(this.handleSetFormErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange = event => {
|
||||||
|
const { projects } = this.props
|
||||||
|
const {
|
||||||
|
target: { name, value }
|
||||||
|
} = event
|
||||||
|
|
||||||
|
this.changeset[name] = value
|
||||||
|
|
||||||
|
if (name === "assignment_id") {
|
||||||
|
const project = findProjectByValue(value)(projects)
|
||||||
|
this.changeset.task_id = head(project?.tasks).value || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelectDate = date => {
|
||||||
|
this.changeset.date = formatDate(date)
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
const { service } = this.props
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: "createActivity",
|
||||||
|
payload: {
|
||||||
|
activity: this.changesetWithDefaults,
|
||||||
|
service
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = event => {
|
||||||
|
if (event.keyCode === 27) {
|
||||||
|
event.stopPropagation()
|
||||||
|
chrome.runtime.sendMessage({ type: "closePopup" })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSetFormErrors = ({ type, payload }) => {
|
||||||
|
if (type === "setFormErrors") {
|
||||||
|
this.formErrors = payload
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
projects,
|
||||||
|
activities,
|
||||||
|
schedules,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
errorType,
|
||||||
|
errorMessage
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spinner />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorType === ERROR_UNAUTHORIZED) {
|
||||||
|
return <InvalidConfigurationError />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorType === ERROR_UPGRADE_REQUIRED) {
|
||||||
|
return <UpgradeRequiredError />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorType === ERROR_UNKNOWN) {
|
||||||
|
return <UnknownError message={errorMessage} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spring
|
||||||
|
native
|
||||||
|
from={{ opacity: 0 }}
|
||||||
|
to={{ opacity: 1 }}
|
||||||
|
config={config.stiff}
|
||||||
|
>
|
||||||
|
{props => (
|
||||||
|
<animated.div className="moco-bx-app-container" style={props}>
|
||||||
|
<Header />
|
||||||
|
<Observer>
|
||||||
|
{() => (
|
||||||
|
<>
|
||||||
|
<Calendar
|
||||||
|
fromDate={parse(fromDate)}
|
||||||
|
toDate={parse(toDate)}
|
||||||
|
activities={activities}
|
||||||
|
schedules={schedules}
|
||||||
|
selectedDate={new Date(this.changesetWithDefaults.date)}
|
||||||
|
onChange={this.handleSelectDate}
|
||||||
|
/>
|
||||||
|
<Form
|
||||||
|
changeset={this.changesetWithDefaults}
|
||||||
|
projects={projects}
|
||||||
|
errors={this.formErrors}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
onSubmit={this.handleSubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Observer>
|
||||||
|
</animated.div>
|
||||||
|
)}
|
||||||
|
</Spring>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
23
src/js/components/Bubble.js
Normal file
23
src/js/components/Bubble.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import logoUrl from "images/logo.png"
|
||||||
|
|
||||||
|
const Bubble = ({ bookedHours, onClick }) => (
|
||||||
|
<div className="moco-bx-bubble-inner" onClick={onClick}>
|
||||||
|
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
|
||||||
|
{bookedHours > 0 ? (
|
||||||
|
<span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
Bubble.propTypes = {
|
||||||
|
bookedHours: PropTypes.number,
|
||||||
|
onClick: PropTypes.func.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
Bubble.defaultProps = {
|
||||||
|
bookedHours: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Bubble
|
40
src/js/components/Calendar/Day.js
Normal file
40
src/js/components/Calendar/Day.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React, { useCallback } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import Hours from "./Hours"
|
||||||
|
import { format, getDay } from "date-fns"
|
||||||
|
import deLocale from "date-fns/locale/de"
|
||||||
|
import cn from "classnames"
|
||||||
|
|
||||||
|
const Day = ({ date, hours, absence, active, onClick }) => {
|
||||||
|
const handleClick = useCallback(() => onClick(date), [date])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"moco-bx-calendar__day",
|
||||||
|
`moco-bx-calendar__day--week-day-${getDay(date)}`,
|
||||||
|
{
|
||||||
|
"moco-bx-calendar__day--active": active,
|
||||||
|
"moco-bx-calendar__day--filled": hours > 0,
|
||||||
|
"moco-bx-calendar__day--absence": absence
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<span className="moco-bx-calendar__day-of-week">
|
||||||
|
{format(date, "dd", { locale: deLocale })}
|
||||||
|
</span>
|
||||||
|
<Hours hours={hours} absence={absence} active={active} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Day.propTypes = {
|
||||||
|
date: PropTypes.instanceOf(Date).isRequired,
|
||||||
|
hours: PropTypes.number.isRequired,
|
||||||
|
absence: PropTypes.object,
|
||||||
|
active: PropTypes.bool.isRequired,
|
||||||
|
onClick: PropTypes.func.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Day
|
43
src/js/components/Calendar/Hours.js
Normal file
43
src/js/components/Calendar/Hours.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
|
||||||
|
const Hours = ({ hours, absence, active }) => {
|
||||||
|
let style
|
||||||
|
let content = null
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
content = hours.toFixed(1)
|
||||||
|
} else if (absence) {
|
||||||
|
if (!active) {
|
||||||
|
style = { backgroundColor: absence.assignment_color }
|
||||||
|
}
|
||||||
|
|
||||||
|
content =
|
||||||
|
absence.assignment_code === "1"
|
||||||
|
? "/"
|
||||||
|
: absence.assignment_code === "2"
|
||||||
|
? "★"
|
||||||
|
: absence.assignment_code === "3"
|
||||||
|
? "K"
|
||||||
|
: absence.assignment_code === "4"
|
||||||
|
? "✈"
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="moco-bx-calendar__hours" style={style}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Hours.propTypes = {
|
||||||
|
hours: PropTypes.number.isRequired,
|
||||||
|
absence: PropTypes.shape({
|
||||||
|
assignment_code: PropTypes.string,
|
||||||
|
assignment_color: PropTypes.string
|
||||||
|
}),
|
||||||
|
active: PropTypes.bool.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Hours
|
60
src/js/components/Calendar/index.js
Normal file
60
src/js/components/Calendar/index.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import Day from "./Day"
|
||||||
|
import { formatDate } from "utils"
|
||||||
|
import { eachDay } from "date-fns"
|
||||||
|
import { pathEq } from "lodash/fp"
|
||||||
|
|
||||||
|
const findAbsence = (date, schedules) =>
|
||||||
|
schedules.find(pathEq("date", formatDate(date)))
|
||||||
|
|
||||||
|
const hoursAtDate = (date, activities) =>
|
||||||
|
activities
|
||||||
|
.filter(pathEq("date", formatDate(date)))
|
||||||
|
.reduce((acc, activity) => acc + activity.hours, 0)
|
||||||
|
|
||||||
|
const Calendar = ({
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
selectedDate,
|
||||||
|
activities,
|
||||||
|
schedules,
|
||||||
|
onChange
|
||||||
|
}) => (
|
||||||
|
<div className="moco-bx-calendar">
|
||||||
|
{eachDay(fromDate, toDate).map(date => (
|
||||||
|
<Day
|
||||||
|
key={date}
|
||||||
|
date={date}
|
||||||
|
hours={hoursAtDate(date, activities)}
|
||||||
|
absence={findAbsence(date, schedules)}
|
||||||
|
active={formatDate(date) === formatDate(selectedDate)}
|
||||||
|
onClick={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
Calendar.propTypes = {
|
||||||
|
fromDate: PropTypes.instanceOf(Date).isRequired,
|
||||||
|
toDate: PropTypes.instanceOf(Date).isRequired,
|
||||||
|
selectedDate: PropTypes.instanceOf(Date).isRequired,
|
||||||
|
activities: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
date: PropTypes.string.isRequired,
|
||||||
|
hours: PropTypes.number.isRequired,
|
||||||
|
timer_started_at: PropTypes.string
|
||||||
|
}).isRequired
|
||||||
|
),
|
||||||
|
schedules: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
date: PropTypes.string,
|
||||||
|
assignment_code: PropTypes.string,
|
||||||
|
assignment_color: PropTypes.string
|
||||||
|
})
|
||||||
|
).isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Calendar
|
28
src/js/components/Errors/InvalidConfigurationError.js
Normal file
28
src/js/components/Errors/InvalidConfigurationError.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from "react"
|
||||||
|
import settingsUrl from "images/settings.png"
|
||||||
|
|
||||||
|
const InvalidConfigurationError = () => (
|
||||||
|
<div className="moco-bx-error-container">
|
||||||
|
<h1>Bitte Einstellungen aktualisieren</h1>
|
||||||
|
<ol>
|
||||||
|
<li>Internetadresse eintragen</li>
|
||||||
|
<li>Persönlichen API-Schlüssel eintragen</li>
|
||||||
|
</ol>
|
||||||
|
<button
|
||||||
|
className="moco-bx-btn"
|
||||||
|
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||||
|
>
|
||||||
|
Einstellungen öffnen
|
||||||
|
</button>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<img
|
||||||
|
src={chrome.extension.getURL(settingsUrl)}
|
||||||
|
alt="Browser extension configuration settings"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default InvalidConfigurationError
|
21
src/js/components/Errors/UnknownError.js
Normal file
21
src/js/components/Errors/UnknownError.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import logo from "images/logo.png"
|
||||||
|
|
||||||
|
const UnknownError = ({ message = "Unbekannter Fehler" }) => (
|
||||||
|
<div className="moco-bx-error-container">
|
||||||
|
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
||||||
|
<h1>Ups, es ist ein Fehler passiert!</h1>
|
||||||
|
<p>Bitte überprüfe deine Internetverbindung.</p>
|
||||||
|
<p>Wir wurden per Email benachrichtigt und untersuchen den Vorfall.</p>
|
||||||
|
<br />
|
||||||
|
<p>Fehlermeldung:</p>
|
||||||
|
<pre>{message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
UnknownError.propTypes = {
|
||||||
|
message: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnknownError
|
35
src/js/components/Errors/UpgradeRequiredError.js
Normal file
35
src/js/components/Errors/UpgradeRequiredError.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { isChrome } from "utils/browser"
|
||||||
|
import logo from "images/logo.png"
|
||||||
|
import firefoxAddons from "images/firefox_addons.png"
|
||||||
|
|
||||||
|
const UpgradeRequiredError = () => (
|
||||||
|
<div className="moco-bx-error-container">
|
||||||
|
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
||||||
|
<h1>Upgrade erforderlich</h1>
|
||||||
|
<p>
|
||||||
|
Die installierte MOCO Browser-Erweiterung ist veraltet — bitte
|
||||||
|
aktualisieren.
|
||||||
|
</p>
|
||||||
|
{isChrome() ? (
|
||||||
|
<button
|
||||||
|
className="moco-bx-btn"
|
||||||
|
onClick={() => chrome.runtime.sendMessage({ type: "openExtensions" })}
|
||||||
|
>
|
||||||
|
Browser-Erweiterungen öffnen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<p>Unter folgender URL:</p>
|
||||||
|
<img
|
||||||
|
className="firefox-addons"
|
||||||
|
src={firefoxAddons}
|
||||||
|
alt="about:addons"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default UpgradeRequiredError
|
113
src/js/components/Form.js
Normal file
113
src/js/components/Form.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import React, { Component } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import Select from "components/Select"
|
||||||
|
import cn from "classnames"
|
||||||
|
|
||||||
|
class Form extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
changeset: PropTypes.shape({
|
||||||
|
project: PropTypes.object,
|
||||||
|
task: PropTypes.object,
|
||||||
|
hours: PropTypes.string
|
||||||
|
}).isRequired,
|
||||||
|
errors: PropTypes.object,
|
||||||
|
projects: PropTypes.array.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
onSubmit: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
inline: true
|
||||||
|
};
|
||||||
|
|
||||||
|
isValid = () => {
|
||||||
|
const { changeset } = this.props
|
||||||
|
return ["assignment_id", "task_id", "hours", "description"]
|
||||||
|
.map(prop => changeset[prop])
|
||||||
|
.every(Boolean)
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTextareaKeyDown = event => {
|
||||||
|
const { onSubmit } = this.props
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
this.isValid() && onSubmit(event)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { projects, changeset, errors, onChange, onSubmit } = this.props
|
||||||
|
const project = Select.findOptionByValue(projects, changeset.assignment_id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div
|
||||||
|
className={cn("form-group", {
|
||||||
|
"has-error": errors.assignment_id || errors.task_id
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
className="moco-bx-select"
|
||||||
|
name="assignment_id"
|
||||||
|
placeholder="Auswählen..."
|
||||||
|
options={projects}
|
||||||
|
value={changeset.assignment_id}
|
||||||
|
hasError={!!errors.assignment_id}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className="moco-bx-select"
|
||||||
|
name="task_id"
|
||||||
|
placeholder="Auswählen..."
|
||||||
|
options={project?.tasks || []}
|
||||||
|
value={changeset.task_id}
|
||||||
|
onChange={onChange}
|
||||||
|
hasError={!!errors.task_id}
|
||||||
|
noOptionsMessage={() => "Zuerst Projekt wählen"}
|
||||||
|
/>
|
||||||
|
{errors.assignment_id ? (
|
||||||
|
<div className="form-error">{errors.assignment_id.join("; ")}</div>
|
||||||
|
) : null}
|
||||||
|
{errors.task_id ? (
|
||||||
|
<div className="form-error">{errors.task_id.join("; ")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={cn("form-group", { "has-error": errors.hours })}>
|
||||||
|
<input
|
||||||
|
name="hours"
|
||||||
|
className="form-control"
|
||||||
|
onChange={onChange}
|
||||||
|
value={changeset.hours}
|
||||||
|
placeholder="0:00"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.hours ? (
|
||||||
|
<div className="form-error">{errors.hours.join("; ")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={cn("form-group", { "has-error": errors.description })}>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
onChange={onChange}
|
||||||
|
value={changeset.description}
|
||||||
|
placeholder="Beschreibung der Tätigkeit - mind. 3 Zeichen"
|
||||||
|
maxLength={255}
|
||||||
|
rows={3}
|
||||||
|
onKeyDown={this.handleTextareaKeyDown}
|
||||||
|
/>
|
||||||
|
{errors.description ? (
|
||||||
|
<div className="form-error">{errors.description.join("; ")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="moco-bx-btn" disabled={!this.isValid()}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Form
|
103
src/js/components/Options.js
Normal file
103
src/js/components/Options.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React, { Component } from "react"
|
||||||
|
import { observable } from "mobx"
|
||||||
|
import { observer } from "mobx-react"
|
||||||
|
import { isChrome, getSettings, setStorage } from "utils/browser"
|
||||||
|
import ApiClient from "api/Client"
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class Options extends Component {
|
||||||
|
@observable subdomain = "";
|
||||||
|
@observable apiKey = "";
|
||||||
|
@observable errorMessage = null;
|
||||||
|
@observable isSuccess = false;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
getSettings().then(({ subdomain, apiKey }) => {
|
||||||
|
this.subdomain = subdomain || ""
|
||||||
|
this.apiKey = apiKey || ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange = event => {
|
||||||
|
this[event.target.name] = event.target.value.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = _event => {
|
||||||
|
this.isSuccess = false
|
||||||
|
this.errorMessage = null
|
||||||
|
setStorage({ subdomain: this.subdomain, apiKey: this.apiKey }).then(() => {
|
||||||
|
const { version } = chrome.runtime.getManifest()
|
||||||
|
const apiClient = new ApiClient({
|
||||||
|
subdomain: this.subdomain,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
version
|
||||||
|
})
|
||||||
|
apiClient
|
||||||
|
.login()
|
||||||
|
.then(() => {
|
||||||
|
this.isSuccess = true
|
||||||
|
this.closeWindow()
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.errorMessage =
|
||||||
|
error.response?.data?.message || "Anmeldung fehlgeschlagen"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
handleInputKeyDown = event => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
this.handleSubmit()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
closeWindow = () => {
|
||||||
|
isChrome() && window.close()
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="moco-bx-options">
|
||||||
|
<h2 style={{ textAlign: "center" }}>Einstellungen</h2>
|
||||||
|
{this.errorMessage && (
|
||||||
|
<div className="text-danger">{this.errorMessage}</div>
|
||||||
|
)}
|
||||||
|
{this.isSuccess && (
|
||||||
|
<div className="text-success">Anmeldung erfolgreich</div>
|
||||||
|
)}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Internetadresse</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="subdomain"
|
||||||
|
value={this.subdomain}
|
||||||
|
onKeyDown={this.handleInputKeyDown}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
<span className="input-group-addon">.mocoapp.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>API-Schlüssel</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="apiKey"
|
||||||
|
value={this.apiKey}
|
||||||
|
onKeyDown={this.handleInputKeyDown}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
<p className="text-muted">
|
||||||
|
Den API-Schlüssel findest du in deinem Profil unter
|
||||||
|
"Integrationen".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="moco-bx-btn" onClick={this.handleSubmit}>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Options
|
87
src/js/components/Popup.js
Normal file
87
src/js/components/Popup.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import React, { Component } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import queryString from "query-string"
|
||||||
|
import {
|
||||||
|
ERROR_UNKNOWN,
|
||||||
|
ERROR_UNAUTHORIZED,
|
||||||
|
ERROR_UPGRADE_REQUIRED,
|
||||||
|
serializeProps
|
||||||
|
} from "utils"
|
||||||
|
import { isChrome } from "utils/browser"
|
||||||
|
|
||||||
|
function getStyles(errorType) {
|
||||||
|
return {
|
||||||
|
width: "516px",
|
||||||
|
height:
|
||||||
|
errorType === ERROR_UNAUTHORIZED
|
||||||
|
? "834px"
|
||||||
|
: errorType === ERROR_UPGRADE_REQUIRED
|
||||||
|
? isChrome()
|
||||||
|
? "369px"
|
||||||
|
: "461px"
|
||||||
|
: errorType === ERROR_UNKNOWN
|
||||||
|
? "550px"
|
||||||
|
: "558px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Popup extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
service: PropTypes.object,
|
||||||
|
errorType: PropTypes.string,
|
||||||
|
onRequestClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRequestClose = event => {
|
||||||
|
if (event.target.classList.contains("moco-bx-popup")) {
|
||||||
|
this.props.onRequestClose()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// Document might lose focus when clicking the browser action.
|
||||||
|
// Document might be out of focus when hitting the shortcut key.
|
||||||
|
// This puts the focus back to the document and ensures that:
|
||||||
|
// - the autofocus on the hours input field is triggered
|
||||||
|
// - the ESC key closes the popup without closing anything else
|
||||||
|
window.focus()
|
||||||
|
document.activeElement?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const serializedProps = serializeProps([
|
||||||
|
"loading",
|
||||||
|
"service",
|
||||||
|
"lastProjectId",
|
||||||
|
"lastTaskId",
|
||||||
|
"roundTimeEntries",
|
||||||
|
"projects",
|
||||||
|
"activities",
|
||||||
|
"schedules",
|
||||||
|
"lastProjectId",
|
||||||
|
"lastTaskId",
|
||||||
|
"fromDate",
|
||||||
|
"toDate",
|
||||||
|
"errorType",
|
||||||
|
"errorMessage"
|
||||||
|
])(this.props)
|
||||||
|
|
||||||
|
const styles = getStyles(this.props.errorType)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="moco-bx-popup" onClick={this.handleRequestClose}>
|
||||||
|
<div className="moco-bx-popup-content" style={styles}>
|
||||||
|
<iframe
|
||||||
|
src={chrome.extension.getURL(
|
||||||
|
`popup.html?${queryString.stringify(serializedProps)}`
|
||||||
|
)}
|
||||||
|
width={styles.width}
|
||||||
|
height={styles.height}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Popup
|
132
src/js/components/Select.js
Normal file
132
src/js/components/Select.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import React, { Component } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import ReactSelect, { createFilter } from "react-select"
|
||||||
|
import {
|
||||||
|
values,
|
||||||
|
isString,
|
||||||
|
isNumber,
|
||||||
|
join,
|
||||||
|
filter,
|
||||||
|
compose,
|
||||||
|
property,
|
||||||
|
flatMap,
|
||||||
|
pathEq
|
||||||
|
} from "lodash/fp"
|
||||||
|
|
||||||
|
const hasOptionGroups = options =>
|
||||||
|
options.some(option => Boolean(option.options))
|
||||||
|
|
||||||
|
const customTheme = theme => ({
|
||||||
|
...theme,
|
||||||
|
borderRadius: 0,
|
||||||
|
spacing: {
|
||||||
|
...theme.spacing,
|
||||||
|
baseUnit: 3,
|
||||||
|
controlHeight: 32
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
...theme.colors,
|
||||||
|
primary: "#38b5eb",
|
||||||
|
primary75: "rgba(56, 181, 235, 0.25)",
|
||||||
|
primary50: "#38b5eb",
|
||||||
|
primary25: "#38b5eb"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const customStyles = props => ({
|
||||||
|
control: (base, _state) => ({
|
||||||
|
...base,
|
||||||
|
borderColor: props.hasError ? "#FB3A2F" : base.borderColor
|
||||||
|
}),
|
||||||
|
valueContainer: base => ({
|
||||||
|
...base,
|
||||||
|
padding: "4px 12px"
|
||||||
|
}),
|
||||||
|
input: base => ({
|
||||||
|
...base,
|
||||||
|
border: "0 !important",
|
||||||
|
boxShadow: "0 !important"
|
||||||
|
}),
|
||||||
|
groupHeading: (base, _state) => ({
|
||||||
|
...base,
|
||||||
|
color: "black",
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "100%",
|
||||||
|
padding: "2px 7px 4px"
|
||||||
|
}),
|
||||||
|
option: (base, state) => ({
|
||||||
|
...base,
|
||||||
|
padding: hasOptionGroups(state.options)
|
||||||
|
? "4px 7px 4px 20px"
|
||||||
|
: "4px 7px 4px",
|
||||||
|
backgroundColor: state.isFocused ? "#38b5eb" : "none",
|
||||||
|
color: state.isFocused ? "white" : "hsl(0, 0%, 20%)"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterOption = createFilter({
|
||||||
|
stringify: compose(
|
||||||
|
join(" "),
|
||||||
|
filter(value => isString(value) || isNumber(value)),
|
||||||
|
values,
|
||||||
|
property("data")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default class Select extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
|
options: PropTypes.array,
|
||||||
|
hasError: PropTypes.bool,
|
||||||
|
onChange: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
static findOptionByValue = (selectOptions, value) => {
|
||||||
|
const options = flatMap(
|
||||||
|
option => (option.options ? option.options : option),
|
||||||
|
selectOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
return options.find(pathEq("value", value)) || null
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.select = React.createRef()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange = option => {
|
||||||
|
const { name, onChange } = this.props
|
||||||
|
const { value } = option
|
||||||
|
onChange({ target: { name, value } })
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = event => {
|
||||||
|
if (!this.select.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.select.current.state.menuIsOpen && event.key === "Enter") {
|
||||||
|
this.select.current.setState({ menuIsOpen: true })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { value, ...passThroughProps } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactSelect
|
||||||
|
{...passThroughProps}
|
||||||
|
ref={this.select}
|
||||||
|
value={Select.findOptionByValue(this.props.options, value)}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
filterOption={filterOption}
|
||||||
|
theme={customTheme}
|
||||||
|
styles={customStyles(this.props)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
14
src/js/components/Spinner.js
Normal file
14
src/js/components/Spinner.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
|
const Spinner = ({ style }) => (
|
||||||
|
<div className='moco-bx-spinner__container' style={style}>
|
||||||
|
<div className='moco-bx-spinner' role='status' />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
Spinner.propTypes = {
|
||||||
|
style: PropTypes.object
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Spinner
|
13
src/js/components/shared/Header.js
Normal file
13
src/js/components/shared/Header.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import logoUrl from "images/logo.png"
|
||||||
|
|
||||||
|
const Header = () => (
|
||||||
|
<div className="moco-bx-logo__container">
|
||||||
|
<img
|
||||||
|
className="moco-bx-logo"
|
||||||
|
src={chrome.extension.getURL(logoUrl)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Header
|
115
src/js/content.js
Normal file
115
src/js/content.js
Normal 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
14
src/js/options.js
Normal 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
31
src/js/popup.js
Normal 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
97
src/js/remoteServices.js
Normal 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)" }
|
||||||
|
}
|
||||||
|
}
|
91
src/js/utils/TimeInputParser.js
Normal file
91
src/js/utils/TimeInputParser.js
Normal 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
41
src/js/utils/browser.js
Normal 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
88
src/js/utils/index.js
Normal 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}`
|
127
src/js/utils/messageHandlers.js
Normal file
127
src/js/utils/messageHandlers.js
Normal 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
99
src/js/utils/messaging.js
Normal 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
39
src/js/utils/notifier.js
Normal 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
104
src/js/utils/urlMatcher.js
Normal 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
|
||||||
|
)
|
||||||
|
}
|
45
src/manifest.json
Normal file
45
src/manifest.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "MOCO Zeiterfassung",
|
||||||
|
"short_name": "MOCO",
|
||||||
|
"description": "MOCO Zeiterfassung Plugin",
|
||||||
|
"version": "0.9.20",
|
||||||
|
"manifest_version": 2,
|
||||||
|
"description": "MOCO Time Tracking Plugin",
|
||||||
|
"icons": {
|
||||||
|
"16": "src/images/logo.png",
|
||||||
|
"32": "src/images/logo.png",
|
||||||
|
"48": "src/images/logo.png",
|
||||||
|
"128": "src/images/logo.png"
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"https://*.mocoapp.com/*",
|
||||||
|
"storage",
|
||||||
|
"tabs"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"page": "background.html"
|
||||||
|
},
|
||||||
|
"browser_action": {
|
||||||
|
"default_icon": "src/images/logo.png"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["<all_urls>"],
|
||||||
|
"js": ["content.[version].js"],
|
||||||
|
"css": ["content.css"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commands": {
|
||||||
|
"_execute_browser_action": {
|
||||||
|
"description": "MOCO-Zeiterfassung ein- und ausblenden",
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+K",
|
||||||
|
"mac": "Command+Shift+K"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web_accessible_resources": ["src/images/*", "popup.html"]
|
||||||
|
}
|
9
src/options.html
Normal file
9
src/options.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="moco-bx-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
9
src/popup.html
Normal file
9
src/popup.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="moco-bx-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
108
test/data.js
Normal file
108
test/data.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
export const projects = [
|
||||||
|
{
|
||||||
|
id: 944868981,
|
||||||
|
name: "Browser Extension",
|
||||||
|
customer_name: "Simplificator",
|
||||||
|
intern: false,
|
||||||
|
identifier: "137",
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 2733682,
|
||||||
|
name: "Bugfixing",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2733681,
|
||||||
|
name: "Development",
|
||||||
|
billable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 944724773,
|
||||||
|
name: "Development",
|
||||||
|
customer_name: "MOCO APP",
|
||||||
|
intern: false,
|
||||||
|
identifier: "116",
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 1621304,
|
||||||
|
name: "Roadmap Features",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1621310,
|
||||||
|
name: "Bugfixing",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1621305,
|
||||||
|
name: "Quickwins",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1621323,
|
||||||
|
name: "Refactorings",
|
||||||
|
billable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 944837106,
|
||||||
|
name: "Support",
|
||||||
|
customer_name: "MOCO APP",
|
||||||
|
intern: false,
|
||||||
|
identifier: "130",
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 2500080,
|
||||||
|
name: "Intercom & Mails",
|
||||||
|
billable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2500081,
|
||||||
|
name: "Demos",
|
||||||
|
billable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2506050,
|
||||||
|
name: "Calls",
|
||||||
|
billable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2500084,
|
||||||
|
name: "Importe",
|
||||||
|
billable: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 944621413,
|
||||||
|
name: "Tech Consulting",
|
||||||
|
customer_name: "sharoo",
|
||||||
|
intern: false,
|
||||||
|
identifier: "97",
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 874014,
|
||||||
|
name: "Entwicklung",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 874015,
|
||||||
|
name: "Grafik",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 874016,
|
||||||
|
name: "Konzept",
|
||||||
|
billable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 874017,
|
||||||
|
name: "Projektleitung",
|
||||||
|
billable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
36
test/utils/TimeInputParser.test.js
Normal file
36
test/utils/TimeInputParser.test.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import TimeInputParser from "../../src/js/utils/TimeInputParser"
|
||||||
|
|
||||||
|
function parseSeconds(input) {
|
||||||
|
return new TimeInputParser(input).parseSeconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("utils", () => {
|
||||||
|
describe("TimeInputParser", () => {
|
||||||
|
it("parses decimal", () => {
|
||||||
|
expect(parseSeconds("1.5")).toEqual(5400)
|
||||||
|
expect(parseSeconds("1,333")).toEqual(4799)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses time", () => {
|
||||||
|
expect(parseSeconds("2:20")).toEqual(8400)
|
||||||
|
expect(parseSeconds("0:30")).toEqual(1800)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses minutes", () => {
|
||||||
|
expect(parseSeconds("2m")).toEqual(120)
|
||||||
|
expect(parseSeconds("45min")).toEqual(2700)
|
||||||
|
expect(parseSeconds("120mins")).toEqual(7200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses range", () => {
|
||||||
|
expect(parseSeconds("10:15-12:45")).toEqual(9000)
|
||||||
|
expect(parseSeconds("8.00-12:15")).toEqual(15300)
|
||||||
|
expect(parseSeconds("1000-12.20")).toEqual(8400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses hours and minuts", () => {
|
||||||
|
expect(parseSeconds("1h 15min")).toEqual(4500)
|
||||||
|
expect(parseSeconds("2h30m")).toEqual(9000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
89
test/utils/index.test.js
Normal file
89
test/utils/index.test.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { projects } from "../data"
|
||||||
|
import {
|
||||||
|
findProjectByValue,
|
||||||
|
findProjectByIdentifier,
|
||||||
|
findTask,
|
||||||
|
groupedProjectOptions
|
||||||
|
} from "../../src/js/utils"
|
||||||
|
import { map } from "lodash/fp"
|
||||||
|
|
||||||
|
describe("utils", () => {
|
||||||
|
describe("findProjectByValue", () => {
|
||||||
|
it("finds an existing project", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByValue(944837106)(options)
|
||||||
|
expect(project.value).toEqual(944837106)
|
||||||
|
expect(project.label).toEqual("Support")
|
||||||
|
expect(project.customerName).toEqual("MOCO APP")
|
||||||
|
expect(project.tasks).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined if project is not found", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByValue(123)(options)
|
||||||
|
expect(project).toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for undefined id", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByValue(undefined)(options)
|
||||||
|
expect(project).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("findProjectByIdentifier", () => {
|
||||||
|
it("finds an existing project", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByIdentifier("130")(options)
|
||||||
|
expect(project.identifier).toEqual("130")
|
||||||
|
expect(project.label).toEqual("Support")
|
||||||
|
expect(project.customerName).toEqual("MOCO APP")
|
||||||
|
expect(project.tasks).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined if project is not found", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByIdentifier("non-existing")(options)
|
||||||
|
expect(project).toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for undefined id", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByIdentifier(undefined)(options)
|
||||||
|
expect(project).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("findTask", () => {
|
||||||
|
it("find an existing task", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByValue(944837106)(options)
|
||||||
|
const task = findTask(2506050)(project)
|
||||||
|
expect(task.value).toEqual(2506050)
|
||||||
|
expect(task.label).toEqual("(Calls)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined if task is not found", () => {
|
||||||
|
const options = groupedProjectOptions(projects)
|
||||||
|
const project = findProjectByValue(944837106)(options)
|
||||||
|
const task = findTask(123)(project)
|
||||||
|
expect(task).toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for undefined project", () => {
|
||||||
|
const task = findTask(2506050)(undefined)
|
||||||
|
expect(task).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("groupedProjectOptions", () => {
|
||||||
|
it("transforms projects into grouped options by company", () => {
|
||||||
|
const result = groupedProjectOptions(projects)
|
||||||
|
expect(map("label", result)).toEqual([
|
||||||
|
"Simplificator",
|
||||||
|
"MOCO APP",
|
||||||
|
"sharoo"
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
114
test/utils/urlMatcher.test.js
Normal file
114
test/utils/urlMatcher.test.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import remoteServices from "../../src/js/remoteServices"
|
||||||
|
import { createMatcher, createEnhancer } from "../../src/js/utils/urlMatcher"
|
||||||
|
|
||||||
|
describe("utils", () => {
|
||||||
|
describe("urlMatcher", () => {
|
||||||
|
let matcher
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
matcher = createMatcher(remoteServices)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createMatcher", () => {
|
||||||
|
it("matches host and path", () => {
|
||||||
|
const service = matcher(
|
||||||
|
"https://github.com/hundertzehn/mocoapp/pull/123"
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("github-pr")
|
||||||
|
expect(service.name).toEqual("github")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches query string", () => {
|
||||||
|
let service = matcher(
|
||||||
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail&selectedIssue=TEST1-1"
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("jira")
|
||||||
|
expect(service.name).toEqual("jira")
|
||||||
|
expect(service.org).toEqual("moco-bx")
|
||||||
|
expect(service.projectId).toEqual("TEST1")
|
||||||
|
expect(service.id).toEqual("TEST1-1")
|
||||||
|
expect(service.match.org).toEqual("moco-bx")
|
||||||
|
expect(service.match.projectId).toEqual("TEST1")
|
||||||
|
expect(service.match.id).toEqual("TEST1-1")
|
||||||
|
|
||||||
|
service = matcher(
|
||||||
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail"
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("jira")
|
||||||
|
expect(service.name).toEqual("jira")
|
||||||
|
expect(service.org).toEqual("moco-bx")
|
||||||
|
expect(service.projectId).toEqual("TEST1")
|
||||||
|
expect(service.id).toBeUndefined()
|
||||||
|
expect(service.match.org).toEqual("moco-bx")
|
||||||
|
expect(service.match.projectId).toEqual("TEST1")
|
||||||
|
expect(service.match.id).toBeUndefined()
|
||||||
|
|
||||||
|
service = matcher(
|
||||||
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail&selectedIssue="
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("jira")
|
||||||
|
expect(service.name).toEqual("jira")
|
||||||
|
expect(service.org).toEqual("moco-bx")
|
||||||
|
expect(service.projectId).toEqual("TEST1")
|
||||||
|
expect(service.id).toEqual("")
|
||||||
|
expect(service.match.org).toEqual("moco-bx")
|
||||||
|
expect(service.match.projectId).toEqual("TEST1")
|
||||||
|
expect(service.match.id).toEqual("")
|
||||||
|
|
||||||
|
service = matcher(
|
||||||
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa"
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("jira")
|
||||||
|
expect(service.name).toEqual("jira")
|
||||||
|
expect(service.match.org).toEqual("moco-bx")
|
||||||
|
expect(service.match.projectId).toBeUndefined()
|
||||||
|
expect(service.match.id).toBeUndefined()
|
||||||
|
|
||||||
|
service = matcher(
|
||||||
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&modal=detail&selectedIssue=TEST2-1"
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("jira")
|
||||||
|
expect(service.name).toEqual("jira")
|
||||||
|
expect(service.org).toEqual("moco-bx")
|
||||||
|
expect(service.projectId).toBeUndefined()
|
||||||
|
expect(service.id).toEqual("TEST2-1")
|
||||||
|
expect(service.match.org).toEqual("moco-bx")
|
||||||
|
expect(service.match.projectId).toBeUndefined()
|
||||||
|
expect(service.match.id).toEqual("TEST2-1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches url with hash", () => {
|
||||||
|
let service = matcher(
|
||||||
|
"https://www.wunderlist.com/webapp#/tasks/4771178545"
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("wunderlist")
|
||||||
|
expect(service.name).toEqual("wunderlist")
|
||||||
|
expect(service.match.id).toEqual("4771178545")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not match different host", () => {
|
||||||
|
const service = matcher(
|
||||||
|
"https://trello.com/hundertzehn/mocoapp/pull/123"
|
||||||
|
)
|
||||||
|
expect(service).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createEnhancer", () => {
|
||||||
|
it("enhances a services", () => {
|
||||||
|
const url = "https://github.com/hundertzehn/mocoapp/pull/123"
|
||||||
|
const document = {
|
||||||
|
querySelector: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ textContent: "[4321] Foo" })
|
||||||
|
}
|
||||||
|
const service = matcher(url)
|
||||||
|
const enhancedService = createEnhancer(document)(service)
|
||||||
|
expect(enhancedService.id).toEqual("github-pr.hundertzehn.mocoapp.123")
|
||||||
|
expect(enhancedService.description).toEqual("[4321] Foo")
|
||||||
|
expect(enhancedService.projectId).toEqual("4321")
|
||||||
|
expect(enhancedService.taskId).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
125
webpack.base.config.js
Normal file
125
webpack.base.config.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
const path = require("path")
|
||||||
|
const webpack = require("webpack")
|
||||||
|
const CleanWebpackPlugin = require("clean-webpack-plugin")
|
||||||
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin")
|
||||||
|
const RemoveSourceMapPlugin = require("./webpack/RemoveSourceMapPlugin")
|
||||||
|
const ZipPlugin = require("zip-webpack-plugin")
|
||||||
|
const {
|
||||||
|
BugsnagBuildReporterPlugin,
|
||||||
|
BugsnagSourceMapUploaderPlugin
|
||||||
|
} = require("webpack-bugsnag-plugins")
|
||||||
|
|
||||||
|
module.exports = env => {
|
||||||
|
const config = {
|
||||||
|
entry: {
|
||||||
|
background: "./src/js/background.js",
|
||||||
|
content: "./src/js/content.js",
|
||||||
|
popup: "./src/js/popup.js",
|
||||||
|
options: "./src/js/options.js"
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, `build/${env.browser}`),
|
||||||
|
filename: `[name].${process.env.npm_package_version}.js`
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.scss$/,
|
||||||
|
use: [
|
||||||
|
{
|
||||||
|
loader: MiniCssExtractPlugin.loader
|
||||||
|
},
|
||||||
|
"css-loader",
|
||||||
|
{
|
||||||
|
loader: "sass-loader",
|
||||||
|
options: {
|
||||||
|
includePaths: [path.join(__dirname, "src/css")]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
exclude: /node_modules/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.js$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: "babel-loader"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(jpg|png)$/,
|
||||||
|
loader: "file-loader",
|
||||||
|
options: {
|
||||||
|
name: "[path][name].[ext]"
|
||||||
|
},
|
||||||
|
exclude: /node_modules/
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CleanWebpackPlugin([`build/${env.browser}`]),
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
"process.env.NODE_ENV": JSON.stringify(env.NODE_ENV)
|
||||||
|
}),
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: "[name].css",
|
||||||
|
chunkFilename: "[id].css"
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: path.join(__dirname, "src", "background.html"),
|
||||||
|
filename: "background.html",
|
||||||
|
chunks: ["background"]
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: path.join(__dirname, "src", "popup.html"),
|
||||||
|
filename: "popup.html",
|
||||||
|
chunks: ["popup"]
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: path.join(__dirname, "src", "options.html"),
|
||||||
|
filename: "options.html",
|
||||||
|
chunks: ["options"]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
modules: [path.join(__dirname, "src/js"), "node_modules"],
|
||||||
|
alias: {
|
||||||
|
images: path.join(__dirname, "src/images")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mode: env.NODE_ENV || "development",
|
||||||
|
devtool: "cheap-module-source-map"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.NODE_ENV === "production") {
|
||||||
|
config.devtool = "source-maps"
|
||||||
|
|
||||||
|
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 ZipPlugin({
|
||||||
|
filename: `moco-bx-${env.browser}-v${
|
||||||
|
process.env.npm_package_version
|
||||||
|
}.zip`,
|
||||||
|
exclude: [/\.map$/]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
42
webpack.chrome.config.js
Normal file
42
webpack.chrome.config.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const CopyWebpackPlugin = require("copy-webpack-plugin")
|
||||||
|
const { compact } = require("lodash/fp")
|
||||||
|
|
||||||
|
const baseConfig = require("./webpack.base.config")
|
||||||
|
|
||||||
|
module.exports = env => {
|
||||||
|
const config = baseConfig(env)
|
||||||
|
|
||||||
|
config.plugins.unshift(
|
||||||
|
new CopyWebpackPlugin([
|
||||||
|
{
|
||||||
|
from: "src/manifest.json",
|
||||||
|
transform: function(content, _path) {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
content
|
||||||
|
.toString()
|
||||||
|
.replace(/\[version\]/g, process.env.npm_package_version)
|
||||||
|
)
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
...manifest,
|
||||||
|
permissions: compact([
|
||||||
|
...manifest.permissions,
|
||||||
|
env.NODE_ENV === "development"
|
||||||
|
? "http://*.mocoapp.localhost/*"
|
||||||
|
: null
|
||||||
|
]),
|
||||||
|
options_ui: {
|
||||||
|
...manifest.options_ui,
|
||||||
|
chrome_style: true
|
||||||
|
},
|
||||||
|
description: process.env.npm_package_description,
|
||||||
|
version: process.env.npm_package_version
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
45
webpack.firefox.config.js
Normal file
45
webpack.firefox.config.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const CopyWebpackPlugin = require("copy-webpack-plugin")
|
||||||
|
const { compact } = require("lodash/fp")
|
||||||
|
|
||||||
|
const baseConfig = require("./webpack.base.config")
|
||||||
|
|
||||||
|
module.exports = env => {
|
||||||
|
const config = baseConfig(env)
|
||||||
|
|
||||||
|
config.plugins.unshift(
|
||||||
|
new CopyWebpackPlugin([
|
||||||
|
{
|
||||||
|
from: "src/manifest.json",
|
||||||
|
transform: function(content, _path) {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
content
|
||||||
|
.toString()
|
||||||
|
.replace(/\[version\]/g, process.env.npm_package_version)
|
||||||
|
)
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
...manifest,
|
||||||
|
permissions: compact([
|
||||||
|
...manifest.permissions,
|
||||||
|
env.NODE_ENV === "development"
|
||||||
|
? "http://*.mocoapp.localhost/*"
|
||||||
|
: null
|
||||||
|
]),
|
||||||
|
options_ui: {
|
||||||
|
...manifest.options_ui,
|
||||||
|
browser_style: true
|
||||||
|
},
|
||||||
|
browser_specific_settings: {
|
||||||
|
gecko: { id: "browser-extension@mocoapp.com" }
|
||||||
|
},
|
||||||
|
description: process.env.npm_package_description,
|
||||||
|
version: process.env.npm_package_version
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
23
webpack/RemoveSourceMapPlugin.js
Normal file
23
webpack/RemoveSourceMapPlugin.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
module.exports = class RemoveSourceMapPlugin {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.test = options.test || /\.(js|css)$/
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(compiler) {
|
||||||
|
compiler.hooks.afterEmit.tap("RemoveSourceMapPlugin", compilation => {
|
||||||
|
Object.keys(compilation.assets)
|
||||||
|
.filter(key => this.test.test(key))
|
||||||
|
.forEach(key => {
|
||||||
|
const asset = compilation.assets[key]
|
||||||
|
const source = asset
|
||||||
|
.source()
|
||||||
|
.replace(/# sourceMappingURL=(.*\.map)/g, "# $1")
|
||||||
|
compilation.assets[key] = Object.assign(asset, {
|
||||||
|
source: function() {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user