Compare commits
166 Commits
1.0.18
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1263bc882a | ||
|
|
261db3f9db | ||
|
|
0b53743298 | ||
|
|
5b63b77102 | ||
|
|
7589e04207 | ||
|
|
1aecaec925 | ||
|
|
6f47cf9cd6 | ||
|
|
dcef794fe4 | ||
|
|
c5230e6e74 | ||
|
|
7370915c27 | ||
|
|
33fe6ad910 | ||
|
|
a8de22b796 | ||
|
|
f38e93bcd6 | ||
|
|
061a3d9a89 | ||
|
|
a13e30784c | ||
|
|
f4c747dd7e | ||
|
|
46aa91b736 | ||
|
|
956e40fc4b | ||
|
|
7b0a8276a4 | ||
|
|
3848055634 | ||
|
|
d7e4a01adc | ||
|
|
97e47ad769 | ||
|
|
5311e6ea2f | ||
|
|
86191f792b | ||
|
|
97022dbf97 | ||
|
|
446ad01343 | ||
|
|
5c531b4917 | ||
|
|
616ab6fb0f | ||
|
|
4fb256fca7 | ||
|
|
3d3d268bca | ||
|
|
b05d00e823 | ||
|
|
eac2e86733 | ||
|
|
38e33f1c55 | ||
|
|
409bb9bab5 | ||
|
|
cf1a696fec | ||
|
|
f26ed7ce02 | ||
|
|
3756848ba7 | ||
|
|
0ba606d5d8 | ||
|
|
5f0bd963be | ||
|
|
13bbaf1ad3 | ||
|
|
dc4918ad91 | ||
|
|
f763553739 | ||
|
|
c412b1711c | ||
|
|
342134a039 | ||
|
|
97dd2651ce | ||
|
|
46610a1842 | ||
|
|
768cf84080 | ||
|
|
0797e764f7 | ||
|
|
3e9f739b17 | ||
|
|
a4fc429476 | ||
|
|
fdaad0660a | ||
|
|
1d67790f47 | ||
|
|
4a2b00d984 | ||
|
|
455e3f26d2 | ||
|
|
ee80ade613 | ||
|
|
84d758167f | ||
|
|
2e37b5b9ee | ||
|
|
63714129cc | ||
|
|
cb5d81b65c | ||
|
|
ff61028b88 | ||
|
|
6486419419 | ||
|
|
c58f6fba6a | ||
|
|
d6316032db | ||
|
|
b283288337 | ||
|
|
0b4896d613 | ||
|
|
ca07be7856 | ||
|
|
8b20feb255 | ||
|
|
213c21d19c | ||
|
|
cb490a391a | ||
|
|
abc63d4361 | ||
|
|
7e64ed6599 | ||
|
|
c6e14d77d5 | ||
|
|
67640613e2 | ||
|
|
5334358c95 | ||
|
|
43e700cbf9 | ||
|
|
4da136e0d7 | ||
|
|
72828a6e9d | ||
|
|
c8fc9f9af9 | ||
|
|
da98ffcb6f | ||
|
|
2940776284 | ||
|
|
33dd3299d9 | ||
|
|
58bc2ff7fa | ||
|
|
41650f0eab | ||
|
|
0c7a32ac3e | ||
|
|
96f5d69028 | ||
|
|
78a102a6e6 | ||
|
|
400e251d88 | ||
|
|
f92db975fe | ||
|
|
1abfdf0e33 | ||
|
|
f787f88337 | ||
|
|
77653eff13 | ||
|
|
6a1725e655 | ||
|
|
a7e3199eb6 | ||
|
|
4f5531548d | ||
|
|
df70dbe047 | ||
|
|
51c9730d1a | ||
|
|
4b29fe3aaa | ||
|
|
ac4dcb259a | ||
|
|
88f7f04fa8 | ||
|
|
c310f48ae9 | ||
|
|
f061b81fc8 | ||
|
|
3387c00d07 | ||
|
|
cfb71d2877 | ||
|
|
1ed1d29c99 | ||
|
|
aa8abf381d | ||
|
|
9e1a5a713c | ||
|
|
f881e2fb3d | ||
|
|
2a5f3db563 | ||
|
|
eb2422ba7a | ||
|
|
a2f3c16aca | ||
|
|
36d5bf2a7e | ||
|
|
9ecc561d4a | ||
|
|
df1a21242b | ||
|
|
e46263657e | ||
|
|
0461b31c36 | ||
|
|
ad772b7900 | ||
|
|
fd4911ec72 | ||
|
|
3b3816067e | ||
|
|
31eb6094e8 | ||
|
|
0e29686b2d | ||
|
|
1447fd6116 | ||
|
|
1dcda94483 | ||
|
|
7e249202e5 | ||
|
|
12c8b8e3eb | ||
|
|
76d57729f4 | ||
|
|
72626a6c42 | ||
|
|
7023b4b482 | ||
|
|
53be150788 | ||
|
|
6980df91d7 | ||
|
|
83faab7fd4 | ||
|
|
8a72f242f9 | ||
|
|
5e62e16751 | ||
|
|
986fc64998 | ||
|
|
8b2e21c3cf | ||
|
|
fd04d6bf6c | ||
|
|
23c9af90b3 | ||
|
|
a9d1726707 | ||
|
|
25773cc661 | ||
|
|
cd9f94423c | ||
|
|
505e3a32ab | ||
|
|
81c7d0ca5d | ||
|
|
173a1d8e62 | ||
|
|
4bebae9abe | ||
|
|
97cea77b7a | ||
|
|
e57caa8563 | ||
|
|
e582f99a94 | ||
|
|
16d41fc2d4 | ||
|
|
1d2e336e3d | ||
|
|
1533c2261f | ||
|
|
d8398fca5f | ||
|
|
02a0bec738 | ||
|
|
0f5172a820 | ||
|
|
a3f94738b6 | ||
|
|
c153eb6c91 | ||
|
|
87aaa99276 | ||
|
|
e6b6f67814 | ||
|
|
1f8bc33830 | ||
|
|
8e55c13d72 | ||
|
|
b02be37bdd | ||
|
|
76422d7343 | ||
|
|
22ac8f4984 | ||
|
|
1b1fae6f7a | ||
|
|
f49c0bdc3d | ||
|
|
b9f417140d | ||
|
|
29db681e1c | ||
|
|
dda92746fa |
3
.babelrc
3
.babelrc
@@ -3,6 +3,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
["@babel/plugin-proposal-decorators", { "legacy": true }],
|
||||||
["@babel/plugin-proposal-class-properties", { "loose": true }],
|
["@babel/plugin-proposal-class-properties", { "loose": true }],
|
||||||
["@babel/plugin-proposal-optional-chaining"]
|
"@babel/plugin-proposal-optional-chaining",
|
||||||
|
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
APPLICATION_ID=
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": ["eslint:recommended", "plugin:react/recommended"],
|
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"commonjs": true,
|
"commonjs": true,
|
||||||
@@ -22,6 +22,11 @@
|
|||||||
},
|
},
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"strict": 0,
|
"strict": 0,
|
||||||
"semi": ["error", "never"],
|
"semi": ["error", "never"],
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
"userStore"
|
"userStore"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"prettier/prettier": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
|
.env
|
||||||
|
/.idea
|
||||||
|
|||||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
179
CHANGELOG.md
Normal file
179
CHANGELOG.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.5.2] - 2020-09-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Remember last tracked project and task on card
|
||||||
|
|
||||||
|
## [1.5.1] - 2020-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add support for Monday
|
||||||
|
|
||||||
|
## [1.5.0] - 2020-06-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow to override hosts for Jira, Youtrack and Gitlab in options (implemented by yay-digital.de)
|
||||||
|
|
||||||
|
## [1.4.0] - 2020-04-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add support for Gitlab merge requests and issues
|
||||||
|
|
||||||
|
## [1.3.4] - 2020-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Asana: read task title from single task pane
|
||||||
|
|
||||||
|
## [1.3.3] - 2019-10-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix an issue on Trello where the card closes when clicking the MOCO bubble
|
||||||
|
- Asana: read project title from page heading
|
||||||
|
|
||||||
|
## [1.3.2] - 2019-10-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Read project identifier from Trello board title
|
||||||
|
|
||||||
|
## [1.3.1] - 2019-10-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Set propper focus on timer view
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Find projects by identifier without alphanumerical characters
|
||||||
|
|
||||||
|
## [1.3.0] - 2019-10-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Start a new timer or stop a running timer
|
||||||
|
- Format time as set in time tracking
|
||||||
|
- Add support for project identifier in Github Issue, Trello, Wunderlist, Youtrack
|
||||||
|
- Find projects by identifier without alphanumerical characters
|
||||||
|
|
||||||
|
## [1.2.4] - 2019-09-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Preselect last used task per project
|
||||||
|
|
||||||
|
## [1.2.3] - 2019-06-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Description of activities are optional
|
||||||
|
|
||||||
|
## [1.2.2] - 2019-05-24
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Bugsnag client
|
||||||
|
|
||||||
|
## [1.2.1] - 2019-05-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Support EU-hosted wrike.com (app-eu.wrike.com)
|
||||||
|
|
||||||
|
## [1.2.0] - 2019-04-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add support for wrike.com
|
||||||
|
|
||||||
|
## [1.1.5] - 2019-04-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unexpected closing of Trello card when clicking on Bubble
|
||||||
|
|
||||||
|
## [1.1.4] - 2019-04-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Show customer name in the project select box
|
||||||
|
|
||||||
|
## [1.1.3] - 2019-04-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Read projected identifier in Asana's "My tasks"-view
|
||||||
|
|
||||||
|
## [1.1.2] - 2019-04-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Allow production build without BUGSNAG_API_KEY
|
||||||
|
- Hours entered in brackets must be non-billable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Read project identifier also from card title in the meistertask service
|
||||||
|
|
||||||
|
## [1.1.1] - 2019-04-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Discard projects with undefined identifier for preselecting
|
||||||
|
|
||||||
|
## [1.1.0] - 2019-03-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Read project identifier from Asana project title
|
||||||
|
- Add support for meistertask.com
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Link logo in modal to MOCO activities page
|
||||||
|
- Set full url on service, including query params
|
||||||
|
|
||||||
|
## [1.0.22] - 2019-03-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change the default value of subdomain to `unset` to have a well-formed URL.
|
||||||
|
|
||||||
|
## [1.0.21] - 2019-03-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update README with example configuration and instructions for local installation
|
||||||
|
|
||||||
|
## [1.0.20] - 2019-03-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add support for tags in description
|
||||||
|
|
||||||
|
## [1.0.19] - 2019-03-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Position Bubble in the bottom right by default
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Set default value of subdomain to `__unset__` to prevent network error if it is empty
|
||||||
|
|
||||||
|
## [1.0.18] - 2019-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- First release of version 1
|
||||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2019, hundertzehn GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
131
README.md
131
README.md
@@ -1,26 +1,117 @@
|
|||||||
mocoapp-browser-extension
|
# MOCO Browser Extension
|
||||||
=========================
|
|
||||||
|
|
||||||
Documentation
|
## Development
|
||||||
-------------
|
|
||||||
|
|
||||||
* https://checklyhq.com/blog/2018/08/creating-a-chrome-extension-in-2018-the-good-the-bad-and-the-meh/
|
- run `yarn`
|
||||||
* https://developer.chrome.com/extensions
|
- run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`)
|
||||||
* https://developer.chrome.com/extensions/api_index
|
- 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/manifest.json`
|
||||||
|
- the browser should automatically pick up your changes but from time to time it may be useful to reload the extension
|
||||||
|
|
||||||
Development
|
## Production Build
|
||||||
-----------
|
|
||||||
|
|
||||||
* run `yarn`
|
- bump version in `package.json`
|
||||||
* run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`)
|
- Update `CHANGELOG.md`
|
||||||
* load extension into browser:
|
- run `yarn build`
|
||||||
* Chrome: visit `chrome://extensions` and load unpacked extension from `build/chrome`
|
- The Chrome and Firefox extensions are available as ZIP-files in `build/chrome` and `build/firefox` respectively
|
||||||
* Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox`
|
|
||||||
* reload browser extension after change
|
|
||||||
|
|
||||||
Release
|
## Install Local Builds
|
||||||
-------
|
|
||||||
|
|
||||||
* bump version in `package.json`
|
### Chrome
|
||||||
* run `yarn build`
|
|
||||||
* upload Chrome and Firefox extensions in `build/chrome` and `build/firefox` respectively
|
- `yarn build:chrome`
|
||||||
|
- Visit `chrome://extensions`
|
||||||
|
- Enable `Developer mode`
|
||||||
|
- `Load unpacked` and select the `build/chrome` folder.
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
- `yarn build:firefox`
|
||||||
|
- Visit `about:debugging`
|
||||||
|
- Click on `Load temporary Add-on` and select the ZIP-file in `build/firefox`
|
||||||
|
|
||||||
|
Only signed extensions can be permantly installed in Firefox (unless you are using <em>Firefox Developer Edition</em>). To sign the build, proceed as described in [Getting started with web-ext](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Getting_started_with_web-ext).
|
||||||
|
|
||||||
|
You can keep the extension settings between builds by providing a stable `APPLICATION_ID` between builds. You can set an `APPLICATION_ID` in a file named `.env` or at build time as follows:
|
||||||
|
|
||||||
|
`APPLICATION_ID=my-custom-moco-extension@mycompany.com yarn build:firefox`
|
||||||
|
|
||||||
|
## Remote Service Configuration
|
||||||
|
|
||||||
|
Remote services are configured in `src/js/remoteServices.js`.
|
||||||
|
|
||||||
|
A remote service is configured as follows:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
service_key: {
|
||||||
|
name: "service_name",
|
||||||
|
host: "https://:subdomain.example.com",
|
||||||
|
urlPatterns: [
|
||||||
|
":host:/card/:id",
|
||||||
|
[/^:host:\/card\/(\d+), ["subdomain", "id"]],
|
||||||
|
],
|
||||||
|
queryParams: {
|
||||||
|
projectId: "currentList"
|
||||||
|
},
|
||||||
|
description: (document, service, { subdomain, id, projectId }) => {
|
||||||
|
const title = document
|
||||||
|
.querySelector('.title')
|
||||||
|
?.textContent
|
||||||
|
?.trim()
|
||||||
|
return `#${id} ${service.key} ${title || ""}`
|
||||||
|
},
|
||||||
|
projectId: (document, service, { subdomain, id, projectId }) => {
|
||||||
|
return projectId
|
||||||
|
},
|
||||||
|
position: { left: "50%", transform: "translate(-50%)" },
|
||||||
|
allowHostOverride: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
| ------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| service_key | `string` — Unique identifier for the service |
|
||||||
|
| service_name | `string` — Must be one of the registered services `trello`, `jira`, `asana`, `wunderlist`, `github` or `youtrack` |
|
||||||
|
| urlPatterns | `string` \| `RegEx` — A valid URL pattern or regular expression, as described in the [url-pattern](https://www.npmjs.com/package/url-pattern) package. `:host:` will be replaced with the configured host before applying the pattern (can be configured in the settings if `allowHostOverride` is true. |
|
||||||
|
| queryParams | `Object` — The object value is the name of the query parameter and the key the property it will available on, e.g. the value of the query parameter `currentList` will be available under `projectId`. Matches in `urlPatterns` have precedence over matches in `queryParams`. |
|
||||||
|
| description | `undefined` \| `string` \| `function` — The default description of the service. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and the return value set as the default description. |
|
||||||
|
| projectId | `undefined` \| `string` \| `function` — The pre-selected project of the service matching the MOCO project identifier. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and must return the MOCO project identifier or `undefined`. |
|
||||||
|
| position | `Object` — CSS properties as object styles for position the bubble. Defaults to `{ right: calc(4rem + 5px)` |
|
||||||
|
|
||||||
|
## Adding a Custom Service
|
||||||
|
|
||||||
|
1. Fork and clone this repository
|
||||||
|
2. Add your service to `src/removeServices.js`, e.g. for self-hosted Jira copy the entry with the key `jira` and update the `urlPatterns`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
"self-hosted-jira": {
|
||||||
|
name: "jira",
|
||||||
|
urlPatterns: [
|
||||||
|
"https\\://jira.my-company.com/secure/RapidBoard.jspa",
|
||||||
|
"https\\://jira.my-company.net/browse/:id",
|
||||||
|
"https\\://jira.my-company.net/jira/software/projects/:projectId/boards/:board",
|
||||||
|
"https\\://jira.my-company.net/jira/software/projects/:projectId/boards/:board/backlog"
|
||||||
|
],
|
||||||
|
queryParams: {
|
||||||
|
id: "selectedIssue",
|
||||||
|
projectId: "projectKey"
|
||||||
|
},
|
||||||
|
description: (document, service, { id }) => {
|
||||||
|
const title =
|
||||||
|
document
|
||||||
|
.querySelector('[aria-label="Edit Summary"]')
|
||||||
|
?.parentNode?.querySelector("h1")
|
||||||
|
?.textContent?.trim() ||
|
||||||
|
document
|
||||||
|
.querySelector(".ghx-selected .ghx-summary")
|
||||||
|
?.textContent?.trim()
|
||||||
|
return `#${id} ${title || ""}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build the extension (see [Production Build](#production-build)).
|
||||||
|
4. Install the extension locally (see [Install Local Builds](#install-local-builds)).
|
||||||
|
|||||||
88
package.json
88
package.json
@@ -1,63 +1,69 @@
|
|||||||
{
|
{
|
||||||
"name": "moco-browser-extensions",
|
"name": "moco-browser-extensions",
|
||||||
"description": "Browser plugin for MOCO",
|
"description": "Browser plugin for MOCO",
|
||||||
"version": "1.0.18",
|
"version": "1.5.2",
|
||||||
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "yarn start:chrome",
|
"start": "yarn start:chrome",
|
||||||
"start:chrome": "node_modules/.bin/webpack --config webpack.chrome.config.js --watch --env.browser chrome --env.NODE_ENV development",
|
"start:chrome": "node_modules/.bin/webpack --config webpack.chrome.config.js --watch --env.browser chrome --env.NODE_ENV development",
|
||||||
"start:firefox": "node_modules/.bin/webpack --config webpack.firefox.config.js --watch --env.browser firefox --env.NODE_ENV development",
|
"start:firefox": "node_modules/.bin/webpack --config webpack.firefox.config.js --watch --env.browser firefox --env.NODE_ENV development",
|
||||||
|
"zip:chrome": "zip -qr build/chrome/moco-bx-source.zip . -x .git/\\* build/\\* node_modules/\\* test/\\* .DS_Store",
|
||||||
|
"zip:firefox": "zip -qr build/firefox/moco-bx-source.zip . -x .git/\\* build/\\* node_modules/\\* test/\\* .DS_Store",
|
||||||
"build:chrome": "node_modules/.bin/webpack -p --config webpack.chrome.config.js --env.browser chrome --env.NODE_ENV production",
|
"build:chrome": "node_modules/.bin/webpack -p --config webpack.chrome.config.js --env.browser chrome --env.NODE_ENV production",
|
||||||
"build:firefox": "node_modules/.bin/webpack -p --config webpack.firefox.config.js --env.browser firefox --env.NODE_ENV production",
|
"build:firefox": "node_modules/.bin/webpack -p --config webpack.firefox.config.js --env.browser firefox --env.NODE_ENV production",
|
||||||
"build": "yarn run build:firefox && yarn run build:chrome",
|
"build": "yarn build:firefox && yarn zip:firefox && yarn build:chrome && yarn zip:chrome",
|
||||||
"test": "node_modules/.bin/jest",
|
"test": "node_modules/.bin/jest",
|
||||||
"test:watch": "node_modules/.bin/jest --watch",
|
"test:watch": "node_modules/.bin/jest --watch"
|
||||||
"release": "copyfiles main.css main.min.js background.min.js manifest.json popup.html options.html node_modules/jquery/dist/jquery.min.js node_modules/select2/select2.js src/images/* release"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bugsnag/js": "^5.2.0",
|
"@babel/polyfill": "^7.10.1",
|
||||||
"@bugsnag/plugin-react": "^5.2.0",
|
"axios": "^0.19.2",
|
||||||
"axios": "^0.18.0",
|
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"date-fns": "^1.30.1",
|
"date-fns": "^2.15.0",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
"lodash": "^4.17.11",
|
"lodash": "^4.17.11",
|
||||||
"mobx": "^5.5.0",
|
"mobx": "^5.15.4",
|
||||||
"mobx-react": "^5.2.8",
|
"mobx-react": "^6.3.1",
|
||||||
"prop-types": "^15.6.2",
|
"prop-types": "^15.6.2",
|
||||||
"query-string": "^6.2.0",
|
"query-string": "^6.12.1",
|
||||||
"react": "^16.8.0",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.8.0",
|
"react-dom": "^16.13.1",
|
||||||
"react-select": "^2.3.0",
|
"react-select": "^3.1.0",
|
||||||
"react-spring": "^8.0.7",
|
"react-spring": "^8.0.7",
|
||||||
"url-pattern": "^1.0.3"
|
"url-pattern": "^1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.2.2",
|
"@babel/core": "^7.10.2",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.2.2",
|
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||||
"@babel/plugin-proposal-decorators": "^7.2.2",
|
"@babel/plugin-proposal-decorators": "^7.10.1",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.2.0",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3",
|
||||||
"@babel/preset-env": "^7.2.2",
|
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
|
||||||
"@babel/preset-react": "^7.0.0",
|
"@babel/preset-env": "^7.11.0",
|
||||||
"babel-eslint": "^10.0.1",
|
"@babel/preset-react": "^7.10.1",
|
||||||
"babel-loader": "^8.0.4",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-plugin-module-resolver": "^3.1.1",
|
"babel-loader": "^8.1.0",
|
||||||
"clean-webpack-plugin": "^1.0.1",
|
"babel-plugin-module-resolver": "^4.0.0",
|
||||||
"copy-webpack-plugin": "^4.6.0",
|
"clean-webpack-plugin": "^3.0.0",
|
||||||
"copyfiles": "^2.1.0",
|
"copy-webpack-plugin": "^6.0.2",
|
||||||
"css-loader": "^2.1.0",
|
"copyfiles": "^2.3.0",
|
||||||
"eslint": "^5.7.0",
|
"css-loader": "^4.2.0",
|
||||||
"eslint-plugin-jest": "^22.2.2",
|
"eslint": "7.6.0",
|
||||||
"eslint-plugin-react": "^7.11.1",
|
"eslint-config-prettier": "^6.11.0",
|
||||||
"file-loader": "^3.0.1",
|
"eslint-plugin-jest": "^23.13.2",
|
||||||
"html-webpack-plugin": "^3.2.0",
|
"eslint-plugin-prettier": "^3.1.3",
|
||||||
"jest": "^24.1.0",
|
"eslint-plugin-react": "^7.20.0",
|
||||||
"mini-css-extract-plugin": "^0.5.0",
|
"file-loader": "^6.0.0",
|
||||||
"node-sass": "^4.11.0",
|
"html-webpack-plugin": "^4.3.0",
|
||||||
"prettier": "^1.16.4",
|
"jest": "^26.0.1",
|
||||||
"sass-loader": "^7.1.0",
|
"mini-css-extract-plugin": "^0.9.0",
|
||||||
"style-loader": "^0.23.1",
|
"node-sass": "^4.14.1",
|
||||||
"webpack": "^4.15.0",
|
"prettier": "^2.0.5",
|
||||||
"webpack-bugsnag-plugins": "^1.3.0",
|
"sass-loader": "^9.0.2",
|
||||||
"webpack-cli": "^3.0.8",
|
"style-loader": "^1.2.1",
|
||||||
|
"svg-inline-loader": "^0.8.2",
|
||||||
|
"uuid": "^8.3.0",
|
||||||
|
"webpack": "^4.43.0",
|
||||||
|
"webpack-cli": "^3.3.11",
|
||||||
"zip-webpack-plugin": "^3.0.0"
|
"zip-webpack-plugin": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,25 @@ button.moco-bx-btn {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: white;
|
color: white;
|
||||||
background-image: none;
|
background-image: none;
|
||||||
background-color: #7dc332;
|
background-color: $green;
|
||||||
border-color: #7dc332;
|
border-color: $green;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-focus-inner {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background-color: #639a28;
|
background-color: $green-dark;
|
||||||
border-color: #639a28;
|
border-color: $green-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -40,3 +48,17 @@ button.moco-bx-btn {
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.moco-bx-btn__secondary {
|
||||||
|
color: $blue;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: $blue;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import "variables";
|
||||||
@import "button";
|
@import "button";
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@@ -15,7 +16,8 @@ input {
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, textarea {
|
input,
|
||||||
|
textarea {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-color: #cccccc;
|
border-color: #cccccc;
|
||||||
@@ -24,6 +26,10 @@ input {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
@@ -31,13 +37,14 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.has-error {
|
&.has-error {
|
||||||
input, textarea {
|
input,
|
||||||
border-color: #FB3A2F;
|
textarea {
|
||||||
|
border-color: $red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: #FB3A2F;
|
color: $red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
.input-group {
|
||||||
@@ -56,8 +63,13 @@ input {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
background-color: #eeeeee;
|
background-color: #eeeeee;
|
||||||
border: 1px solid #cccccc;
|
border: 1px solid #cccccc;
|
||||||
border-left: none;
|
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
|
&--right {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
&--left {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,8 +83,8 @@ input[name="hours"] {
|
|||||||
outline: 0 !important;
|
outline: 0 !important;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border: 1px solid #38b5eb;
|
border: 1px solid $blue;
|
||||||
box-shadow: 0 0 0 1px #38b5eb;
|
box-shadow: 0 0 0 1px $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +96,7 @@ textarea[name="description"] {
|
|||||||
outline: 0 !important;
|
outline: 0 !important;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border: 1px solid #38b5eb;
|
border: 1px solid $blue;
|
||||||
box-shadow: 0 0 0 1px #38b5eb;
|
box-shadow: 0 0 0 1px $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
$font-family: Arial, sans-serif;
|
$font-family: Roboto, Arial, sans-serif;
|
||||||
$font-color: #191919;
|
$font-color: #191919;
|
||||||
$popup-width: 420px;
|
$popup-width: 420px;
|
||||||
$popup-height: 463px;
|
$popup-height: 463px;
|
||||||
|
|
||||||
|
$green: #7dc332;
|
||||||
|
$green-dark: #639a28;
|
||||||
|
$blue: #38b5eb;
|
||||||
|
$red: #fb3a2f;
|
||||||
|
$gray-base: #a3a3a3;
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
#moco-bx-root {
|
#moco-bx-root {
|
||||||
font-family: $font-family;
|
font-family: $font-family;
|
||||||
color: $font-color;
|
color: $font-color;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
|
.text-red {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
.moco-bx-bubble {
|
.moco-bx-bubble {
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
@@ -13,8 +18,7 @@
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: -1px -1px 15px 4px rgba(0, 0, 0, 0.05),
|
box-shadow: -1px -1px 15px 4px rgba(0, 0, 0, 0.05), 2px 2px 15px 4px rgba(0, 0, 0, 0.05);
|
||||||
2px 2px 15px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
#moco-bx-popup-root {
|
#moco-bx-popup-root {
|
||||||
font-family: $font-family;
|
font-family: $font-family;
|
||||||
color: $font-color;
|
color: $font-color;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
iframe {
|
iframe {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -58,16 +63,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.moco-bx-popup {
|
.moco-bx-popup {
|
||||||
position: fixed; /* Stay in place */
|
position: fixed;
|
||||||
z-index: 2000; /* Sit on top */
|
z-index: 2000;
|
||||||
padding-top: 100px; /* Location of the box */
|
padding-top: 100px;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%; /* Full width */
|
width: 100%;
|
||||||
height: 100%; /* Full height */
|
height: 100%;
|
||||||
overflow: auto; /* Enable scroll if needed */
|
overflow: auto;
|
||||||
background-color: rgb(0, 0, 0); /* Fallback color */
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
|
z-index: 9999;
|
||||||
|
|
||||||
.moco-bx-popup-content {
|
.moco-bx-popup-content {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
.moco-bx-options {
|
.moco-bx-options {
|
||||||
padding: 0rem 2rem 2rem;
|
padding: 0rem 2rem 2rem;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $blue;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
@@ -20,6 +25,11 @@
|
|||||||
margin: 1rem 0 2rem;
|
margin: 1rem 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 1rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
@@ -32,11 +42,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-success {
|
.text-success {
|
||||||
color: #7DC332;
|
color: $green;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-danger {
|
.text-danger {
|
||||||
color: #FB3A2F;
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__host-overrides {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
@import "variables";
|
||||||
@import "form";
|
@import "form";
|
||||||
@import "spinner";
|
@import "spinner";
|
||||||
@import "variables";
|
|
||||||
|
|
||||||
html {
|
html {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -14,33 +14,48 @@ html {
|
|||||||
#moco-bx-root {
|
#moco-bx-root {
|
||||||
min-width: 516px;
|
min-width: 516px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: $gray-base;
|
||||||
|
}
|
||||||
|
|
||||||
.moco-bx-app-container {
|
.moco-bx-app-container {
|
||||||
width: 324px;
|
width: 324px;
|
||||||
padding: 3rem 6rem;
|
padding: 3rem 6rem;
|
||||||
|
|
||||||
.moco-bx-logo__container {
|
.moco-bx-logo__container {
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
img.moco-bx-logo {
|
img.moco-bx-logo {
|
||||||
flex: 0 0 48px;
|
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
line-height: 48px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.moco-bx-calendar {
|
.moco-bx-calendar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
.moco-bx-calendar__day {
|
.moco-bx-calendar__day {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -66,12 +81,11 @@ html {
|
|||||||
flex: 0 0 42px;
|
flex: 0 0 42px;
|
||||||
color: white;
|
color: white;
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.moco-bx-calendar__day--filled {
|
&.moco-bx-calendar__day--filled {
|
||||||
.moco-bx-calendar__hours {
|
.moco-bx-calendar__hours {
|
||||||
background-color: #7dc332;
|
background-color: $green;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,43 +98,73 @@ html {
|
|||||||
|
|
||||||
&.moco-bx-calendar__day--active {
|
&.moco-bx-calendar__day--active {
|
||||||
.moco-bx-calendar__hours {
|
.moco-bx-calendar__hours {
|
||||||
background-color: #38b5eb;
|
background-color: $blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.moco-bx-timer-view {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 3rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
max-height: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.moco-bx-single-line {
|
||||||
|
display: inline-block;
|
||||||
|
max-height: 19px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop-timer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
background-color: $red;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.moco-bx-error-container {
|
.moco-bx-error-container {
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
width: 420px;
|
width: 420px;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 35px;
|
|
||||||
font-weight: normal;
|
|
||||||
margin-top: 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: auto;
|
margin-top: 1.5rem;
|
||||||
max-width: 100%;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
&.moco-bx-logo {
|
&.moco-bx-logo {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
&.firefox-addons {
|
||||||
text-align: left;
|
margin-top: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/images/icons/stopwatch-light.svg
Normal file
1
src/images/icons/stopwatch-light.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="stopwatch" class="svg-inline--fa fa-stopwatch fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M393.3 141.3l17.5-17.5c4.7-4.7 4.7-12.3 0-17l-5.7-5.7c-4.7-4.7-12.3-4.7-17 0l-17.5 17.5c-35.8-31-81.5-50.9-131.7-54.2V32h25c6.6 0 12-5.4 12-12v-8c0-6.6-5.4-12-12-12h-80c-6.6 0-12 5.4-12 12v8c0 6.6 5.4 12 12 12h23v32.6C91.2 73.3 0 170 0 288c0 123.7 100.3 224 224 224s224-100.3 224-224c0-56.1-20.6-107.4-54.7-146.7zM224 480c-106.1 0-192-85.9-192-192S117.9 96 224 96s192 85.9 192 192-85.9 192-192 192zm4-128h-8c-6.6 0-12-5.4-12-12V172c0-6.6 5.4-12 12-12h8c6.6 0 12 5.4 12 12v168c0 6.6-5.4 12-12 12z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 733 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
BIN
src/images/moco-32x32.png
Normal file
BIN
src/images/moco-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/images/moco-timer-32x32.png
Normal file
BIN
src/images/moco-timer-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -3,19 +3,15 @@ import { formatDate } from "utils"
|
|||||||
|
|
||||||
const baseURL = subdomain => {
|
const baseURL = subdomain => {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
return `https://${encodeURIComponent(
|
return `https://${encodeURIComponent(subdomain)}.mocoapp.com/api/browser_extensions`
|
||||||
subdomain
|
|
||||||
)}.mocoapp.com/api/browser_extensions`
|
|
||||||
} else {
|
} else {
|
||||||
return `http://${encodeURIComponent(
|
return `http://${encodeURIComponent(subdomain)}.mocoapp.localhost:3000/api/browser_extensions`
|
||||||
subdomain
|
|
||||||
)}.mocoapp.localhost:3001/api/browser_extensions`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Client {
|
export default class Client {
|
||||||
#client;
|
#client
|
||||||
#apiKey;
|
#apiKey
|
||||||
|
|
||||||
constructor({ subdomain, apiKey, version }) {
|
constructor({ subdomain, apiKey, version }) {
|
||||||
this.#apiKey = apiKey
|
this.#apiKey = apiKey
|
||||||
@@ -25,9 +21,9 @@ export default class Client {
|
|||||||
headers: {
|
headers: {
|
||||||
common: {
|
common: {
|
||||||
"x-api-key": apiKey,
|
"x-api-key": apiKey,
|
||||||
"x-extension-version": version
|
"x-extension-version": version,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,29 +31,31 @@ export default class Client {
|
|||||||
this.#client.post("session", {
|
this.#client.post("session", {
|
||||||
api_key: this.#apiKey,
|
api_key: this.#apiKey,
|
||||||
remote_service: service?.name,
|
remote_service: service?.name,
|
||||||
remote_id: service?.id
|
remote_id: service?.id,
|
||||||
});
|
})
|
||||||
|
|
||||||
projects = () => this.#client.get("projects");
|
projects = () => this.#client.get("projects")
|
||||||
|
|
||||||
schedules = (fromDate, toDate) =>
|
schedules = (fromDate, toDate) =>
|
||||||
this.#client.get("schedules", {
|
this.#client.get("schedules", {
|
||||||
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` }
|
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` },
|
||||||
});
|
})
|
||||||
|
|
||||||
activities = (fromDate, toDate) =>
|
activities = (fromDate, toDate) =>
|
||||||
this.#client.get("activities", {
|
this.#client.get("activities", {
|
||||||
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` }
|
params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` },
|
||||||
});
|
})
|
||||||
|
|
||||||
bookedHours = service => {
|
activitiesStatus = service => {
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return Promise.resolve({ data: { hours: 0 } })
|
return Promise.resolve({ data: { hours: 0 } })
|
||||||
}
|
}
|
||||||
return this.#client.get("activities/tags", {
|
return this.#client.get("activities/status", {
|
||||||
params: { selection: [service.id], remote_service: service.name }
|
params: { remote_id: service.id, remote_service: service.name },
|
||||||
})
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
createActivity = activity => this.#client.post("activities", { activity });
|
createActivity = activity => this.#client.post("activities", { activity })
|
||||||
|
|
||||||
|
stopTimer = timedActivity => this.#client.get(`activities/${timedActivity.id}/stop_timer`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
|
import "@babel/polyfill"
|
||||||
import ApiClient from "api/Client"
|
import ApiClient from "api/Client"
|
||||||
import {
|
import { isChrome, getCurrentTab, getSettings, isBrowserTab } from "utils/browser"
|
||||||
isChrome,
|
|
||||||
getCurrentTab,
|
|
||||||
getSettings,
|
|
||||||
isBrowserTab
|
|
||||||
} from "utils/browser"
|
|
||||||
import { BackgroundMessenger } from "utils/messaging"
|
import { BackgroundMessenger } from "utils/messaging"
|
||||||
import {
|
import { tabUpdated, settingsChanged, togglePopup, openPopup } from "utils/messageHandlers"
|
||||||
tabUpdated,
|
import { isNil } from "lodash"
|
||||||
settingsChanged,
|
|
||||||
togglePopup
|
|
||||||
} from "utils/messageHandlers"
|
|
||||||
|
|
||||||
const messenger = new BackgroundMessenger()
|
const messenger = new BackgroundMessenger()
|
||||||
|
|
||||||
|
function timerStoppedForCurrentService(service, timedActivity) {
|
||||||
|
return timedActivity.service_id && timedActivity.service_id === service?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBubble({ tab, settings, service, timedActivity }) {
|
||||||
|
const apiClient = new ApiClient(settings)
|
||||||
|
apiClient
|
||||||
|
.activitiesStatus(service)
|
||||||
|
.then(({ data }) => {
|
||||||
|
messenger.postMessage(tab, {
|
||||||
|
type: "showBubble",
|
||||||
|
payload: {
|
||||||
|
bookedSeconds: data.seconds,
|
||||||
|
timedActivity: data.timed_activity,
|
||||||
|
settingTimeTrackingHHMM: settings.settingTimeTrackingHHMM,
|
||||||
|
service,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (isNil(timedActivity) || timerStoppedForCurrentService(service, timedActivity)) {
|
||||||
|
messenger.postMessage(tab, { type: "closePopup" })
|
||||||
|
} else {
|
||||||
|
openPopup(tab, { service, messenger })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
messenger.on("togglePopup", () => {
|
messenger.on("togglePopup", () => {
|
||||||
getCurrentTab().then(tab => {
|
getCurrentTab().then(tab => {
|
||||||
if (tab && !isBrowserTab(tab)) {
|
if (tab && !isBrowserTab(tab)) {
|
||||||
@@ -39,23 +60,12 @@ chrome.runtime.onMessage.addListener(action => {
|
|||||||
const apiClient = new ApiClient(settings)
|
const apiClient = new ApiClient(settings)
|
||||||
apiClient
|
apiClient
|
||||||
.createActivity(activity)
|
.createActivity(activity)
|
||||||
.then(() => {
|
.then(() => resetBubble({ tab, settings, service }))
|
||||||
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 => {
|
.catch(error => {
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: "setFormErrors",
|
type: "setFormErrors",
|
||||||
payload: error.response.data
|
payload: error.response.data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -63,6 +73,19 @@ chrome.runtime.onMessage.addListener(action => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === "stopTimer") {
|
||||||
|
const { timedActivity, service } = action.payload
|
||||||
|
getCurrentTab().then(tab => {
|
||||||
|
getSettings().then(settings => {
|
||||||
|
const apiClient = new ApiClient(settings)
|
||||||
|
apiClient
|
||||||
|
.stopTimer(timedActivity)
|
||||||
|
.then(() => resetBubble({ tab, settings, service, timedActivity }))
|
||||||
|
.catch(() => null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === "openOptions") {
|
if (action.type === "openOptions") {
|
||||||
let url
|
let url
|
||||||
if (isChrome()) {
|
if (isChrome()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import PropTypes from "prop-types"
|
|||||||
import Spinner from "components/Spinner"
|
import Spinner from "components/Spinner"
|
||||||
import Form from "components/Form"
|
import Form from "components/Form"
|
||||||
import Calendar from "components/Calendar"
|
import Calendar from "components/Calendar"
|
||||||
|
import TimerView from "components/App/TimerView"
|
||||||
import { observable, computed } from "mobx"
|
import { observable, computed } from "mobx"
|
||||||
import { Observer, observer } from "mobx-react"
|
import { Observer, observer } from "mobx-react"
|
||||||
import { Spring, animated, config } from "react-spring/renderprops"
|
import { Spring, animated, config } from "react-spring/renderprops"
|
||||||
@@ -10,18 +11,21 @@ import {
|
|||||||
ERROR_UNKNOWN,
|
ERROR_UNKNOWN,
|
||||||
ERROR_UNAUTHORIZED,
|
ERROR_UNAUTHORIZED,
|
||||||
ERROR_UPGRADE_REQUIRED,
|
ERROR_UPGRADE_REQUIRED,
|
||||||
|
extractAndSetTag,
|
||||||
findProjectByValue,
|
findProjectByValue,
|
||||||
findProjectByIdentifier,
|
findProjectByIdentifier,
|
||||||
findTask,
|
findTask,
|
||||||
formatDate
|
defaultTask,
|
||||||
|
formatDate,
|
||||||
} from "utils"
|
} from "utils"
|
||||||
|
import { parseISO } from "date-fns"
|
||||||
import InvalidConfigurationError from "components/Errors/InvalidConfigurationError"
|
import InvalidConfigurationError from "components/Errors/InvalidConfigurationError"
|
||||||
import UpgradeRequiredError from "components/Errors/UpgradeRequiredError"
|
import UpgradeRequiredError from "components/Errors/UpgradeRequiredError"
|
||||||
import UnknownError from "components/Errors/UnknownError"
|
import UnknownError from "components/Errors/UnknownError"
|
||||||
import { parse } from "date-fns"
|
|
||||||
import Header from "./shared/Header"
|
import Header from "./shared/Header"
|
||||||
import { head } from "lodash"
|
import { head } from "lodash"
|
||||||
import TimeInputParser from "utils/TimeInputParser"
|
import TimeInputParser from "utils/TimeInputParser"
|
||||||
|
import {get} from "lodash/fp";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class App extends Component {
|
class App extends Component {
|
||||||
@@ -33,64 +37,82 @@ class App extends Component {
|
|||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
description: PropTypes.string,
|
description: PropTypes.string,
|
||||||
projectId: PropTypes.string,
|
projectId: PropTypes.string,
|
||||||
taskId: PropTypes.string
|
taskId: PropTypes.string,
|
||||||
}),
|
}),
|
||||||
|
subdomain: PropTypes.string,
|
||||||
activities: PropTypes.array,
|
activities: PropTypes.array,
|
||||||
schedules: PropTypes.array,
|
schedules: PropTypes.array,
|
||||||
projects: PropTypes.array,
|
projects: PropTypes.array,
|
||||||
|
timedActivity: PropTypes.shape({
|
||||||
|
customer_name: PropTypes.string.isRequired,
|
||||||
|
assignment_name: PropTypes.string.isRequired,
|
||||||
|
task_name: PropTypes.string.isRequired,
|
||||||
|
timer_started_at: PropTypes.string.isRequired,
|
||||||
|
seconds: PropTypes.number.isRequired,
|
||||||
|
}),
|
||||||
lastProjectId: PropTypes.number,
|
lastProjectId: PropTypes.number,
|
||||||
lastTaskId: PropTypes.number,
|
lastTaskId: PropTypes.number,
|
||||||
roundTimeEntries: PropTypes.bool,
|
|
||||||
fromDate: PropTypes.string,
|
fromDate: PropTypes.string,
|
||||||
toDate: PropTypes.string,
|
toDate: PropTypes.string,
|
||||||
errorType: PropTypes.string,
|
errorType: PropTypes.string,
|
||||||
errorMessage: PropTypes.string
|
errorMessage: PropTypes.string,
|
||||||
};
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
activities: [],
|
activities: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
roundTimeEntries: false
|
}
|
||||||
};
|
|
||||||
|
|
||||||
@observable changeset = {};
|
@observable changeset = {}
|
||||||
@observable formErrors = {};
|
@observable formErrors = {}
|
||||||
|
|
||||||
|
@computed get project() {
|
||||||
|
const { service, projects, lastProjectId } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
findProjectByValue(this.changeset.assignment_id)(projects) ||
|
||||||
|
findProjectByValue(Number(lastProjectId))(projects) ||
|
||||||
|
findProjectByIdentifier(service?.projectId)(projects) ||
|
||||||
|
head(projects.flatMap(get("options")))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed get task() {
|
||||||
|
const { service, lastTaskId } = this.props
|
||||||
|
return (
|
||||||
|
findTask(this.changeset.task_id || service?.taskId || lastTaskId)(this.project) ||
|
||||||
|
defaultTask(this.project?.tasks)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed get billable() {
|
||||||
|
return /\(.+\)/.test(this.changeset.hours) === true ? false : !!this.task?.billable
|
||||||
|
}
|
||||||
|
|
||||||
@computed get changesetWithDefaults() {
|
@computed get changesetWithDefaults() {
|
||||||
const { service, projects, lastProjectId, lastTaskId } = this.props
|
const { service } = 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 = {
|
const defaults = {
|
||||||
remote_service: service?.name,
|
remote_service: service?.name,
|
||||||
remote_id: service?.id,
|
remote_id: service?.id,
|
||||||
remote_url: service?.url,
|
remote_url: service?.url,
|
||||||
date: formatDate(new Date()),
|
date: formatDate(new Date()),
|
||||||
assignment_id: project?.value,
|
assignment_id: this.project?.value,
|
||||||
task_id: task?.value,
|
task_id: this.task?.value,
|
||||||
billable: task?.billable,
|
billable: this.billable,
|
||||||
hours: "",
|
hours: "",
|
||||||
seconds:
|
seconds: new TimeInputParser(this.changeset.hours).parseSeconds(),
|
||||||
this.changeset.hours &&
|
description: service?.description || "",
|
||||||
new TimeInputParser(this.changeset.hours).parseSeconds(),
|
tag: "",
|
||||||
description: service?.description
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...defaults, ...this.changeset }
|
||||||
...defaults,
|
|
||||||
...this.changeset
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
window.addEventListener("keydown", this.handleKeyDown)
|
window.addEventListener("keydown", this.handleKeyDown)
|
||||||
|
parent.postMessage({ __mocoBX: { iFrameHeight: window.document.body.scrollHeight } }, "*")
|
||||||
chrome.runtime.onMessage.addListener(this.handleSetFormErrors)
|
chrome.runtime.onMessage.addListener(this.handleSetFormErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,60 +121,71 @@ class App extends Component {
|
|||||||
chrome.runtime.onMessage.removeListener(this.handleSetFormErrors)
|
chrome.runtime.onMessage.removeListener(this.handleSetFormErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange = event => {
|
handleChange = (event) => {
|
||||||
const { projects } = this.props
|
const { projects } = this.props
|
||||||
const {
|
const {
|
||||||
target: { name, value }
|
target: { name, value },
|
||||||
} = event
|
} = event
|
||||||
|
|
||||||
this.changeset[name] = value
|
this.changeset[name] = value
|
||||||
|
|
||||||
if (name === "assignment_id") {
|
if (name === "assignment_id") {
|
||||||
const project = findProjectByValue(value)(projects)
|
const project = findProjectByValue(value)(projects)
|
||||||
this.changeset.task_id = head(project?.tasks).value || null
|
this.changeset.task_id = defaultTask(project?.tasks)?.value
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
handleSelectDate = date => {
|
handleSelectDate = (date) => {
|
||||||
this.changeset.date = formatDate(date)
|
this.changeset.date = formatDate(date)
|
||||||
};
|
}
|
||||||
|
|
||||||
handleSubmit = event => {
|
handleStopTimer = (timedActivity) => {
|
||||||
|
const { service } = this.props
|
||||||
|
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: "stopTimer",
|
||||||
|
payload: { timedActivity, service },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit = (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const { service } = this.props
|
const { service } = this.props
|
||||||
|
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
type: "createActivity",
|
type: "createActivity",
|
||||||
payload: {
|
payload: {
|
||||||
activity: this.changesetWithDefaults,
|
activity: extractAndSetTag(this.changesetWithDefaults),
|
||||||
service
|
service,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
handleKeyDown = event => {
|
handleKeyDown = (event) => {
|
||||||
if (event.keyCode === 27) {
|
if (event.keyCode === 27) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
chrome.runtime.sendMessage({ type: "closePopup" })
|
chrome.runtime.sendMessage({ type: "closePopup" })
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
handleSetFormErrors = ({ type, payload }) => {
|
handleSetFormErrors = ({ type, payload }) => {
|
||||||
if (type === "setFormErrors") {
|
if (type === "setFormErrors") {
|
||||||
this.formErrors = payload
|
this.formErrors = payload
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
|
subdomain,
|
||||||
projects,
|
projects,
|
||||||
|
timedActivity,
|
||||||
activities,
|
activities,
|
||||||
schedules,
|
schedules,
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
errorType,
|
errorType,
|
||||||
errorMessage
|
errorMessage,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -172,35 +205,34 @@ class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Spring
|
<Spring native from={{ opacity: 0 }} to={{ opacity: 1 }} config={config.stiff}>
|
||||||
native
|
{(props) => (
|
||||||
from={{ opacity: 0 }}
|
|
||||||
to={{ opacity: 1 }}
|
|
||||||
config={config.stiff}
|
|
||||||
>
|
|
||||||
{props => (
|
|
||||||
<animated.div className="moco-bx-app-container" style={props}>
|
<animated.div className="moco-bx-app-container" style={props}>
|
||||||
<Header />
|
<Header subdomain={subdomain} />
|
||||||
<Observer>
|
<Observer>
|
||||||
{() => (
|
{() =>
|
||||||
<>
|
timedActivity ? (
|
||||||
<Calendar
|
<TimerView timedActivity={timedActivity} onStopTimer={this.handleStopTimer} />
|
||||||
fromDate={parse(fromDate)}
|
) : (
|
||||||
toDate={parse(toDate)}
|
<>
|
||||||
activities={activities}
|
<Calendar
|
||||||
schedules={schedules}
|
fromDate={parseISO(fromDate)}
|
||||||
selectedDate={new Date(this.changesetWithDefaults.date)}
|
toDate={parseISO(toDate)}
|
||||||
onChange={this.handleSelectDate}
|
activities={activities}
|
||||||
/>
|
schedules={schedules}
|
||||||
<Form
|
selectedDate={new Date(this.changesetWithDefaults.date)}
|
||||||
changeset={this.changesetWithDefaults}
|
onChange={this.handleSelectDate}
|
||||||
projects={projects}
|
/>
|
||||||
errors={this.formErrors}
|
<Form
|
||||||
onChange={this.handleChange}
|
changeset={this.changesetWithDefaults}
|
||||||
onSubmit={this.handleSubmit}
|
projects={projects}
|
||||||
/>
|
errors={this.formErrors}
|
||||||
</>
|
onChange={this.handleChange}
|
||||||
)}
|
onSubmit={this.handleSubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
</Observer>
|
</Observer>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
45
src/js/components/App/TimerView.js
Normal file
45
src/js/components/App/TimerView.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import Timer from "components/shared/Timer"
|
||||||
|
import { parseISO } from "date-fns"
|
||||||
|
import StopWatch from "components/shared/StopWatch"
|
||||||
|
|
||||||
|
export default function TimerView({ timedActivity, onStopTimer }) {
|
||||||
|
const handleStopTimer = () => {
|
||||||
|
onStopTimer(timedActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="moco-bx-timer-view">
|
||||||
|
<p>
|
||||||
|
<span className="moco-bx-single-line text-secondary">{timedActivity.customer_name}</span>
|
||||||
|
<br />
|
||||||
|
<span className="moco-bx-single-line">{timedActivity.assignment_name}</span>
|
||||||
|
<br />
|
||||||
|
<span className="moco-bx-single-line">{timedActivity.task_name}</span>
|
||||||
|
</p>
|
||||||
|
<h2>{timedActivity.description}</h2>
|
||||||
|
<Timer
|
||||||
|
className="timer text-red"
|
||||||
|
startedAt={parseISO(timedActivity.timer_started_at)}
|
||||||
|
offset={timedActivity.seconds}
|
||||||
|
style={{ fontSize: "36px", display: "inline-block" }}
|
||||||
|
/>
|
||||||
|
<button className="moco-bx-btn btn-stop-timer" onClick={handleStopTimer} autoFocus>
|
||||||
|
<StopWatch />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TimerView.propTypes = {
|
||||||
|
timedActivity: PropTypes.shape({
|
||||||
|
customer_name: PropTypes.string.isRequired,
|
||||||
|
assignment_name: PropTypes.string.isRequired,
|
||||||
|
task_name: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.string,
|
||||||
|
timer_started_at: PropTypes.string.isRequired,
|
||||||
|
seconds: PropTypes.number.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
onStopTimer: PropTypes.func.isRequired,
|
||||||
|
}
|
||||||
@@ -1,23 +1,46 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import PropTypes from "prop-types"
|
import PropTypes from "prop-types"
|
||||||
import logoUrl from "images/logo.png"
|
import mocoLogo from "images/moco-32x32.png"
|
||||||
|
import mocoTimerLogo from "images/moco-timer-32x32.png"
|
||||||
|
import { parseISO } from "date-fns"
|
||||||
|
import { formatDuration } from "utils"
|
||||||
|
import Timer from "./shared/Timer"
|
||||||
|
|
||||||
const Bubble = ({ bookedHours, onClick }) => (
|
const Bubble = ({ bookedSeconds, timedActivity, settingTimeTrackingHHMM }) => {
|
||||||
<div className="moco-bx-bubble-inner" onClick={onClick}>
|
const logo = timedActivity ? mocoTimerLogo : mocoLogo
|
||||||
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
|
|
||||||
{bookedHours > 0 ? (
|
return (
|
||||||
<span className="moco-bx-booked-hours">{bookedHours.toFixed(2)}</span>
|
<div className="moco-bx-bubble-inner">
|
||||||
) : null}
|
<img className="moco-bx-logo" src={chrome.extension.getURL(logo)} />
|
||||||
</div>
|
{!timedActivity && bookedSeconds > 0 && (
|
||||||
)
|
<span className="moco-bx-booked-hours">
|
||||||
|
{formatDuration(bookedSeconds, { settingTimeTrackingHHMM, showSeconds: false })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{timedActivity && (
|
||||||
|
<Timer
|
||||||
|
className="text-red"
|
||||||
|
startedAt={parseISO(timedActivity.timer_started_at)}
|
||||||
|
offset={timedActivity.seconds}
|
||||||
|
style={{ marginBottom: "3px", fontSize: "12px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Bubble.propTypes = {
|
Bubble.propTypes = {
|
||||||
bookedHours: PropTypes.number,
|
bookedSeconds: PropTypes.number,
|
||||||
onClick: PropTypes.func.isRequired
|
timedActivity: PropTypes.shape({
|
||||||
|
timer_started_at: PropTypes.string.isRequired,
|
||||||
|
seconds: PropTypes.number.isRequired,
|
||||||
|
}),
|
||||||
|
settingTimeTrackingHHMM: PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
Bubble.defaultProps = {
|
Bubble.defaultProps = {
|
||||||
bookedHours: 0
|
bookedSeconds: 0,
|
||||||
|
settingTimeTrackingHHMM: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Bubble
|
export default Bubble
|
||||||
|
|||||||
@@ -10,15 +10,11 @@ const Day = ({ date, hours, absence, active, onClick }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("moco-bx-calendar__day", `moco-bx-calendar__day--week-day-${getDay(date)}`, {
|
||||||
"moco-bx-calendar__day",
|
"moco-bx-calendar__day--active": active,
|
||||||
`moco-bx-calendar__day--week-day-${getDay(date)}`,
|
"moco-bx-calendar__day--filled": hours > 0,
|
||||||
{
|
"moco-bx-calendar__day--absence": absence,
|
||||||
"moco-bx-calendar__day--active": active,
|
})}
|
||||||
"moco-bx-calendar__day--filled": hours > 0,
|
|
||||||
"moco-bx-calendar__day--absence": absence
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<span className="moco-bx-calendar__day-of-week">
|
<span className="moco-bx-calendar__day-of-week">
|
||||||
@@ -34,7 +30,7 @@ Day.propTypes = {
|
|||||||
hours: PropTypes.number.isRequired,
|
hours: PropTypes.number.isRequired,
|
||||||
absence: PropTypes.object,
|
absence: PropTypes.object,
|
||||||
active: PropTypes.bool.isRequired,
|
active: PropTypes.bool.isRequired,
|
||||||
onClick: PropTypes.func.isRequired
|
onClick: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Day
|
export default Day
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ Hours.propTypes = {
|
|||||||
hours: PropTypes.number.isRequired,
|
hours: PropTypes.number.isRequired,
|
||||||
absence: PropTypes.shape({
|
absence: PropTypes.shape({
|
||||||
assignment_code: PropTypes.string,
|
assignment_code: PropTypes.string,
|
||||||
assignment_color: PropTypes.string
|
assignment_color: PropTypes.string,
|
||||||
}),
|
}),
|
||||||
active: PropTypes.bool.isRequired
|
active: PropTypes.bool.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Hours
|
export default Hours
|
||||||
|
|||||||
@@ -2,27 +2,19 @@ import React from "react"
|
|||||||
import PropTypes from "prop-types"
|
import PropTypes from "prop-types"
|
||||||
import Day from "./Day"
|
import Day from "./Day"
|
||||||
import { formatDate } from "utils"
|
import { formatDate } from "utils"
|
||||||
import { eachDay } from "date-fns"
|
import { eachDayOfInterval } from "date-fns"
|
||||||
import { pathEq } from "lodash/fp"
|
import { pathEq } from "lodash/fp"
|
||||||
|
|
||||||
const findAbsence = (date, schedules) =>
|
const findAbsence = (date, schedules) => schedules.find(pathEq("date", formatDate(date)))
|
||||||
schedules.find(pathEq("date", formatDate(date)))
|
|
||||||
|
|
||||||
const hoursAtDate = (date, activities) =>
|
const hoursAtDate = (date, activities) =>
|
||||||
activities
|
activities
|
||||||
.filter(pathEq("date", formatDate(date)))
|
.filter(pathEq("date", formatDate(date)))
|
||||||
.reduce((acc, activity) => acc + activity.hours, 0)
|
.reduce((acc, activity) => acc + activity.hours, 0)
|
||||||
|
|
||||||
const Calendar = ({
|
const Calendar = ({ fromDate, toDate, selectedDate, activities, schedules, onChange }) => (
|
||||||
fromDate,
|
|
||||||
toDate,
|
|
||||||
selectedDate,
|
|
||||||
activities,
|
|
||||||
schedules,
|
|
||||||
onChange
|
|
||||||
}) => (
|
|
||||||
<div className="moco-bx-calendar">
|
<div className="moco-bx-calendar">
|
||||||
{eachDay(fromDate, toDate).map(date => (
|
{eachDayOfInterval({ start: fromDate, end: toDate }).map(date => (
|
||||||
<Day
|
<Day
|
||||||
key={date}
|
key={date}
|
||||||
date={date}
|
date={date}
|
||||||
@@ -44,17 +36,17 @@ Calendar.propTypes = {
|
|||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.number.isRequired,
|
||||||
date: PropTypes.string.isRequired,
|
date: PropTypes.string.isRequired,
|
||||||
hours: PropTypes.number.isRequired,
|
hours: PropTypes.number.isRequired,
|
||||||
timer_started_at: PropTypes.string
|
timer_started_at: PropTypes.string,
|
||||||
}).isRequired
|
}).isRequired,
|
||||||
),
|
),
|
||||||
schedules: PropTypes.arrayOf(
|
schedules: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
date: PropTypes.string,
|
date: PropTypes.string,
|
||||||
assignment_code: PropTypes.string,
|
assignment_code: PropTypes.string,
|
||||||
assignment_color: PropTypes.string
|
assignment_color: PropTypes.string,
|
||||||
})
|
}),
|
||||||
).isRequired,
|
).isRequired,
|
||||||
onChange: PropTypes.func.isRequired
|
onChange: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Calendar
|
export default Calendar
|
||||||
|
|||||||
@@ -3,25 +3,23 @@ import settingsUrl from "images/settings.png"
|
|||||||
|
|
||||||
const InvalidConfigurationError = () => (
|
const InvalidConfigurationError = () => (
|
||||||
<div className="moco-bx-error-container">
|
<div className="moco-bx-error-container">
|
||||||
<h1>Bitte Einstellungen aktualisieren</h1>
|
<h1>MOCO verbinden</h1>
|
||||||
<ol>
|
<p>
|
||||||
<li>Internetadresse eintragen</li>
|
Dazu trägst Du in den Einstellungen Deine Account-Internetadresse und Deinen API-Schlüssel
|
||||||
<li>Persönlichen API-Schlüssel eintragen</li>
|
ein.
|
||||||
</ol>
|
</p>
|
||||||
|
<img
|
||||||
|
src={chrome.extension.getURL(settingsUrl)}
|
||||||
|
alt="Browser extension configuration settings"
|
||||||
|
style={{ cursor: "pointer", width: "185px", height: "195px" }}
|
||||||
|
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className="moco-bx-btn"
|
className="moco-bx-btn"
|
||||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
||||||
>
|
>
|
||||||
Einstellungen öffnen
|
Weiter zu den Einstellungen
|
||||||
</button>
|
</button>
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<img
|
|
||||||
src={chrome.extension.getURL(settingsUrl)}
|
|
||||||
alt="Browser extension configuration settings"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import PropTypes from "prop-types"
|
import PropTypes from "prop-types"
|
||||||
import logo from "images/logo.png"
|
import logo from "images/moco-159x159.png"
|
||||||
|
|
||||||
const UnknownError = ({ message = "Unbekannter Fehler" }) => (
|
const UnknownError = ({ message = "Unbekannter Fehler" }) => (
|
||||||
<div className="moco-bx-error-container">
|
<div className="moco-bx-error-container">
|
||||||
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
<img
|
||||||
|
className="moco-bx-logo"
|
||||||
|
src={logo}
|
||||||
|
style={{ width: "48px", height: "48px" }}
|
||||||
|
alt="MOCO logo"
|
||||||
|
/>
|
||||||
<h1>Ups, es ist ein Fehler passiert!</h1>
|
<h1>Ups, es ist ein Fehler passiert!</h1>
|
||||||
<p>Bitte überprüfe deine Internetverbindung.</p>
|
<p>Bitte überprüfe deine Internetverbindung.</p>
|
||||||
<p>Wir wurden per Email benachrichtigt und untersuchen den Vorfall.</p>
|
|
||||||
<br />
|
|
||||||
<p>Fehlermeldung:</p>
|
<p>Fehlermeldung:</p>
|
||||||
<pre>{message}</pre>
|
<pre>{message}</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
UnknownError.propTypes = {
|
UnknownError.propTypes = {
|
||||||
message: PropTypes.string
|
message: PropTypes.string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UnknownError
|
export default UnknownError
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { isChrome } from "utils/browser"
|
import { isChrome } from "utils/browser"
|
||||||
import logo from "images/logo.png"
|
import logo from "images/moco-159x159.png"
|
||||||
import firefoxAddons from "images/firefox_addons.png"
|
import firefoxAddons from "images/firefox_addons.png"
|
||||||
|
|
||||||
const UpgradeRequiredError = () => (
|
const UpgradeRequiredError = () => (
|
||||||
<div className="moco-bx-error-container">
|
<div className="moco-bx-error-container">
|
||||||
<img className="moco-bx-logo" src={logo} alt="MOCO logo" />
|
<img
|
||||||
<h1>Upgrade erforderlich</h1>
|
className="moco-bx-logo"
|
||||||
<p>
|
src={logo}
|
||||||
Die installierte MOCO Browser-Erweiterung ist veraltet — bitte
|
style={{ width: "48px", height: "48px" }}
|
||||||
aktualisieren.
|
alt="MOCO logo"
|
||||||
</p>
|
/>
|
||||||
|
<h1>Bitte aktualisieren</h1>
|
||||||
|
<p>Die installierte MOCO Browser-Erweiterung ist veraltet — bitte aktualisieren.</p>
|
||||||
{isChrome() ? (
|
{isChrome() ? (
|
||||||
<button
|
<button
|
||||||
className="moco-bx-btn"
|
className="moco-bx-btn"
|
||||||
@@ -20,11 +22,11 @@ const UpgradeRequiredError = () => (
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<br />
|
|
||||||
<p>Unter folgender URL:</p>
|
<p>Unter folgender URL:</p>
|
||||||
<img
|
<img
|
||||||
className="firefox-addons"
|
className="firefox-addons"
|
||||||
src={firefoxAddons}
|
src={firefoxAddons}
|
||||||
|
style={{ width: "292px", height: "40px" }}
|
||||||
alt="about:addons"
|
alt="about:addons"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,31 +1,71 @@
|
|||||||
import React, { Component } from "react"
|
import React, { Component } from "react"
|
||||||
import PropTypes from "prop-types"
|
import PropTypes from "prop-types"
|
||||||
import Select from "components/Select"
|
import Select from "components/Select"
|
||||||
|
import { formatDate } from "utils"
|
||||||
import cn from "classnames"
|
import cn from "classnames"
|
||||||
|
import StopWatch from "components/shared/StopWatch"
|
||||||
|
|
||||||
class Form extends Component {
|
class Form extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
changeset: PropTypes.shape({
|
changeset: PropTypes.shape({
|
||||||
project: PropTypes.object,
|
assignment_id: PropTypes.number.isRequired,
|
||||||
task: PropTypes.object,
|
billable: PropTypes.bool.isRequired,
|
||||||
hours: PropTypes.string
|
date: PropTypes.string.isRequired,
|
||||||
|
task_id: PropTypes.number.isRequired,
|
||||||
|
description: PropTypes.string,
|
||||||
|
remote_id: PropTypes.string,
|
||||||
|
remote_service: PropTypes.string,
|
||||||
|
remote_url: PropTypes.string,
|
||||||
|
seconds: PropTypes.number,
|
||||||
|
hours: PropTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
errors: PropTypes.object,
|
errors: PropTypes.object,
|
||||||
projects: PropTypes.array.isRequired,
|
projects: PropTypes.array.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onSubmit: PropTypes.func.isRequired
|
onSubmit: PropTypes.func.isRequired,
|
||||||
};
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
inline: true
|
inline: true,
|
||||||
};
|
}
|
||||||
|
|
||||||
isValid = () => {
|
isValid() {
|
||||||
const { changeset } = this.props
|
const { changeset } = this.props
|
||||||
return ["assignment_id", "task_id", "hours", "description"]
|
return (
|
||||||
.map(prop => changeset[prop])
|
["assignment_id", "task_id"].map(prop => changeset[prop]).every(Boolean) &&
|
||||||
.every(Boolean)
|
(changeset.date === formatDate(new Date()) || changeset.seconds > 0)
|
||||||
};
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get isTimerStartable() {
|
||||||
|
const {
|
||||||
|
changeset: { seconds, date },
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
return date === formatDate(new Date()) && seconds === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonStyle() {
|
||||||
|
const styleMap = {
|
||||||
|
true: {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: "60px",
|
||||||
|
height: "60px",
|
||||||
|
marginTop: "22px",
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
border: "none",
|
||||||
|
width: "50px",
|
||||||
|
height: "36px",
|
||||||
|
marginTop: "35px",
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleMap[this.isTimerStartable]
|
||||||
|
}
|
||||||
|
|
||||||
handleTextareaKeyDown = event => {
|
handleTextareaKeyDown = event => {
|
||||||
const { onSubmit } = this.props
|
const { onSubmit } = this.props
|
||||||
@@ -34,7 +74,7 @@ class Form extends Component {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
this.isValid() && onSubmit(event)
|
this.isValid() && onSubmit(event)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { projects, changeset, errors, onChange, onSubmit } = this.props
|
const { projects, changeset, errors, onChange, onSubmit } = this.props
|
||||||
@@ -44,7 +84,7 @@ class Form extends Component {
|
|||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div
|
<div
|
||||||
className={cn("form-group", {
|
className={cn("form-group", {
|
||||||
"has-error": errors.assignment_id || errors.task_id
|
"has-error": errors.assignment_id || errors.task_id,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
@@ -69,9 +109,7 @@ class Form extends Component {
|
|||||||
{errors.assignment_id ? (
|
{errors.assignment_id ? (
|
||||||
<div className="form-error">{errors.assignment_id.join("; ")}</div>
|
<div className="form-error">{errors.assignment_id.join("; ")}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{errors.task_id ? (
|
{errors.task_id ? <div className="form-error">{errors.task_id.join("; ")}</div> : null}
|
||||||
<div className="form-error">{errors.task_id.join("; ")}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("form-group", { "has-error": errors.hours })}>
|
<div className={cn("form-group", { "has-error": errors.hours })}>
|
||||||
<input
|
<input
|
||||||
@@ -83,16 +121,14 @@ class Form extends Component {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{errors.hours ? (
|
{errors.hours ? <div className="form-error">{errors.hours.join("; ")}</div> : null}
|
||||||
<div className="form-error">{errors.hours.join("; ")}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("form-group", { "has-error": errors.description })}>
|
<div className={cn("form-group", { "has-error": errors.description })}>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={changeset.description}
|
value={changeset.description}
|
||||||
placeholder="Beschreibung der Tätigkeit - mind. 3 Zeichen"
|
placeholder="Beschreibung der Tätigkeit – optional"
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
rows={3}
|
rows={3}
|
||||||
onKeyDown={this.handleTextareaKeyDown}
|
onKeyDown={this.handleTextareaKeyDown}
|
||||||
@@ -102,8 +138,13 @@ class Form extends Component {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="moco-bx-btn" disabled={!this.isValid()}>
|
<button
|
||||||
OK
|
type="submit"
|
||||||
|
className="moco-bx-btn"
|
||||||
|
disabled={!this.isValid()}
|
||||||
|
style={this.buttonStyle()}
|
||||||
|
>
|
||||||
|
{this.isTimerStartable ? <StopWatch /> : "OK"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,68 +3,96 @@ import { observable } from "mobx"
|
|||||||
import { observer } from "mobx-react"
|
import { observer } from "mobx-react"
|
||||||
import { isChrome, getSettings, setStorage } from "utils/browser"
|
import { isChrome, getSettings, setStorage } from "utils/browser"
|
||||||
import ApiClient from "api/Client"
|
import ApiClient from "api/Client"
|
||||||
|
import { pipe, toPairs, fromPairs, map } from "lodash/fp"
|
||||||
|
|
||||||
|
function upperCaseFirstLetter(input) {
|
||||||
|
return input[0].toUpperCase() + input.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePathFromUrl(url) {
|
||||||
|
return url.replace(/(\.[a-z]+)\/.*$/, "$1")
|
||||||
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Options extends Component {
|
class Options extends Component {
|
||||||
@observable subdomain = "";
|
@observable subdomain = ""
|
||||||
@observable apiKey = "";
|
@observable apiKey = ""
|
||||||
@observable errorMessage = null;
|
@observable hostOverrides = {}
|
||||||
@observable isSuccess = false;
|
@observable errorMessage = null
|
||||||
|
@observable isSuccess = false
|
||||||
|
@observable showHostOverrideOptions = false
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
getSettings().then(({ subdomain, apiKey }) => {
|
getSettings(false).then((settings) => {
|
||||||
this.subdomain = subdomain || ""
|
this.subdomain = settings.subdomain || ""
|
||||||
this.apiKey = apiKey || ""
|
this.apiKey = settings.apiKey || ""
|
||||||
|
this.hostOverrides = settings.hostOverrides
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange = event => {
|
handleChange = (event) => {
|
||||||
this[event.target.name] = event.target.value.trim()
|
this[event.target.name] = event.target.value.trim()
|
||||||
};
|
}
|
||||||
|
|
||||||
handleSubmit = _event => {
|
handleChangeHostOverrides = (event) => {
|
||||||
|
this.hostOverrides[event.target.name] = event.target.value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleHostOverrideOptions = () => {
|
||||||
|
this.showHostOverrideOptions = !this.showHostOverrideOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit = (_event) => {
|
||||||
this.isSuccess = false
|
this.isSuccess = false
|
||||||
this.errorMessage = null
|
this.errorMessage = null
|
||||||
setStorage({ subdomain: this.subdomain, apiKey: this.apiKey }).then(() => {
|
|
||||||
|
setStorage({
|
||||||
|
subdomain: this.subdomain,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
settingTimeTrackingHHMM: false,
|
||||||
|
hostOverrides: pipe(
|
||||||
|
toPairs,
|
||||||
|
map(([key, url]) => [key, removePathFromUrl(url)]),
|
||||||
|
fromPairs,
|
||||||
|
)(this.hostOverrides),
|
||||||
|
}).then(() => {
|
||||||
const { version } = chrome.runtime.getManifest()
|
const { version } = chrome.runtime.getManifest()
|
||||||
const apiClient = new ApiClient({
|
const apiClient = new ApiClient({
|
||||||
subdomain: this.subdomain,
|
subdomain: this.subdomain,
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
version
|
version,
|
||||||
})
|
})
|
||||||
apiClient
|
apiClient
|
||||||
.login()
|
.login()
|
||||||
|
.then(({ data }) =>
|
||||||
|
setStorage({ settingTimeTrackingHHMM: data.setting_time_tracking_hh_mm }),
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isSuccess = true
|
this.isSuccess = true
|
||||||
this.closeWindow()
|
this.closeWindow()
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
this.errorMessage =
|
this.errorMessage = error.response?.data?.message || "Anmeldung fehlgeschlagen"
|
||||||
error.response?.data?.message || "Anmeldung fehlgeschlagen"
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
handleInputKeyDown = event => {
|
handleInputKeyDown = (event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
this.handleSubmit()
|
this.handleSubmit()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
closeWindow = () => {
|
closeWindow = () => {
|
||||||
isChrome() && window.close()
|
isChrome() && window.close()
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="moco-bx-options">
|
<div className="moco-bx-options">
|
||||||
<h2 style={{ textAlign: "center" }}>Einstellungen</h2>
|
<h2 style={{ textAlign: "center" }}>Einstellungen</h2>
|
||||||
{this.errorMessage && (
|
{this.errorMessage && <div className="text-danger">{this.errorMessage}</div>}
|
||||||
<div className="text-danger">{this.errorMessage}</div>
|
{this.isSuccess && <div className="text-success">Anmeldung erfolgreich</div>}
|
||||||
)}
|
|
||||||
{this.isSuccess && (
|
|
||||||
<div className="text-success">Anmeldung erfolgreich</div>
|
|
||||||
)}
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Internetadresse</label>
|
<label>Internetadresse</label>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
@@ -73,9 +101,9 @@ class Options extends Component {
|
|||||||
name="subdomain"
|
name="subdomain"
|
||||||
value={this.subdomain}
|
value={this.subdomain}
|
||||||
onKeyDown={this.handleInputKeyDown}
|
onKeyDown={this.handleInputKeyDown}
|
||||||
onChange={this.onChange}
|
onChange={this.handleChange}
|
||||||
/>
|
/>
|
||||||
<span className="input-group-addon">.mocoapp.com</span>
|
<span className="input-group-addon input-group-addon--right">.mocoapp.com</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -85,13 +113,59 @@ class Options extends Component {
|
|||||||
name="apiKey"
|
name="apiKey"
|
||||||
value={this.apiKey}
|
value={this.apiKey}
|
||||||
onKeyDown={this.handleInputKeyDown}
|
onKeyDown={this.handleInputKeyDown}
|
||||||
onChange={this.onChange}
|
onChange={this.handleChange}
|
||||||
/>
|
/>
|
||||||
<p className="text-muted">
|
<p className="text-muted">
|
||||||
Den API-Schlüssel findest du in deinem Profil unter
|
Den API-Schlüssel findest du in deinem Profil unter "Integrationen".
|
||||||
"Integrationen".
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!this.showHostOverrideOptions && (
|
||||||
|
<div className="moco-bx-options__host-overrides">
|
||||||
|
<a href="#" className="moco-bx-btn__secondary" onClick={this.toggleHostOverrideOptions}>
|
||||||
|
Service-URLs überschreiben?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{this.showHostOverrideOptions && (
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h3 style={{ marginBottom: 0 }}>Service-URLs</h3>
|
||||||
|
<small>
|
||||||
|
Doppelpunkt für Platzhalter verwenden, z.B.{" "}
|
||||||
|
<span style={{ backgroundColor: "rgba(100, 100, 100, 0.1)" }}>:org</span>. Siehe{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/hundertzehn/mocoapp-browser-extension#remote-service-configuration"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Online-Doku
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</small>
|
||||||
|
<br />
|
||||||
|
{pipe(
|
||||||
|
Object.entries,
|
||||||
|
Array.from,
|
||||||
|
)(this.hostOverrides).map(([name, host]) => (
|
||||||
|
<div className="form-group" key={name} style={{ margin: "0.5rem 0" }}>
|
||||||
|
<div className="input-group">
|
||||||
|
<span
|
||||||
|
className="input-group-addon input-group-addon--left"
|
||||||
|
style={{ display: "inline-block", width: "70px", textAlign: "left" }}
|
||||||
|
>
|
||||||
|
{upperCaseFirstLetter(name)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
value={host}
|
||||||
|
onKeyDown={this.handleInputKeyDown}
|
||||||
|
onChange={this.handleChangeHostOverrides}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className="moco-bx-btn" onClick={this.handleSubmit}>
|
<button className="moco-bx-btn" onClick={this.handleSubmit}>
|
||||||
OK
|
OK
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,87 +1,65 @@
|
|||||||
import React, { Component } from "react"
|
import React, { useEffect, useRef, forwardRef } from "react"
|
||||||
import PropTypes from "prop-types"
|
import PropTypes from "prop-types"
|
||||||
import queryString from "query-string"
|
import queryString from "query-string"
|
||||||
import {
|
import { serializeProps } from "utils"
|
||||||
ERROR_UNKNOWN,
|
|
||||||
ERROR_UNAUTHORIZED,
|
|
||||||
ERROR_UPGRADE_REQUIRED,
|
|
||||||
serializeProps
|
|
||||||
} from "utils"
|
|
||||||
import { isChrome } from "utils/browser"
|
|
||||||
|
|
||||||
function getStyles(errorType) {
|
const Popup = forwardRef((props, ref) => {
|
||||||
return {
|
const iFrameRef = useRef()
|
||||||
width: "516px",
|
|
||||||
height:
|
|
||||||
errorType === ERROR_UNAUTHORIZED
|
|
||||||
? "834px"
|
|
||||||
: errorType === ERROR_UPGRADE_REQUIRED
|
|
||||||
? isChrome()
|
|
||||||
? "369px"
|
|
||||||
: "461px"
|
|
||||||
: errorType === ERROR_UNKNOWN
|
|
||||||
? "550px"
|
|
||||||
: "558px"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Popup extends Component {
|
const handleRequestClose = event => {
|
||||||
static propTypes = {
|
|
||||||
service: PropTypes.object,
|
|
||||||
errorType: PropTypes.string,
|
|
||||||
onRequestClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRequestClose = event => {
|
|
||||||
if (event.target.classList.contains("moco-bx-popup")) {
|
if (event.target.classList.contains("moco-bx-popup")) {
|
||||||
this.props.onRequestClose()
|
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 handleMessage = event => {
|
||||||
const serializedProps = serializeProps([
|
if (iFrameRef.current && event.data?.__mocoBX?.iFrameHeight > 300) {
|
||||||
"loading",
|
iFrameRef.current.style.height = `${event.data.__mocoBX.iFrameHeight}px`
|
||||||
"service",
|
}
|
||||||
"lastProjectId",
|
}
|
||||||
"lastTaskId",
|
|
||||||
"roundTimeEntries",
|
|
||||||
"projects",
|
|
||||||
"activities",
|
|
||||||
"schedules",
|
|
||||||
"lastProjectId",
|
|
||||||
"lastTaskId",
|
|
||||||
"fromDate",
|
|
||||||
"toDate",
|
|
||||||
"errorType",
|
|
||||||
"errorMessage"
|
|
||||||
])(this.props)
|
|
||||||
|
|
||||||
const styles = getStyles(this.props.errorType)
|
useEffect(() => {
|
||||||
|
window.addEventListener("message", handleMessage)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleMessage)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
const serializedProps = serializeProps([
|
||||||
<div className="moco-bx-popup" onClick={this.handleRequestClose}>
|
"loading",
|
||||||
<div className="moco-bx-popup-content" style={styles}>
|
"service",
|
||||||
<iframe
|
"subdomain",
|
||||||
src={chrome.extension.getURL(
|
"projects",
|
||||||
`popup.html?${queryString.stringify(serializedProps)}`
|
"activities",
|
||||||
)}
|
"schedules",
|
||||||
width={styles.width}
|
"timedActivity",
|
||||||
height={styles.height}
|
"lastProjectId",
|
||||||
/>
|
"lastTaskId",
|
||||||
</div>
|
"fromDate",
|
||||||
|
"toDate",
|
||||||
|
"errorType",
|
||||||
|
"errorMessage",
|
||||||
|
])(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="moco-bx-popup" onClick={handleRequestClose}>
|
||||||
|
<div className="moco-bx-popup-content" style={{ width: "516px" }}>
|
||||||
|
<iframe
|
||||||
|
ref={iFrameRef}
|
||||||
|
src={chrome.extension.getURL(`popup.html?${queryString.stringify(serializedProps)}`)}
|
||||||
|
style={{ width: "516px", height: "576px", transition: "height 0.1s ease-in-out" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Popup.displayName = "Popup"
|
||||||
|
|
||||||
|
Popup.propTypes = {
|
||||||
|
service: PropTypes.object,
|
||||||
|
errorType: PropTypes.string,
|
||||||
|
onRequestClose: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Popup
|
export default Popup
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { Component } from "react"
|
import React, { Component } from "react"
|
||||||
import PropTypes from "prop-types"
|
import PropTypes from "prop-types"
|
||||||
import ReactSelect, { createFilter } from "react-select"
|
import ReactSelect, { createFilter, components } from "react-select"
|
||||||
import {
|
import {
|
||||||
values,
|
values,
|
||||||
isString,
|
isString,
|
||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
compose,
|
compose,
|
||||||
property,
|
property,
|
||||||
flatMap,
|
flatMap,
|
||||||
pathEq
|
pathEq,
|
||||||
|
isNil,
|
||||||
} from "lodash/fp"
|
} from "lodash/fp"
|
||||||
|
|
||||||
const hasOptionGroups = options =>
|
const hasOptionGroups = options => options.some(option => Boolean(option.options))
|
||||||
options.some(option => Boolean(option.options))
|
|
||||||
|
|
||||||
const customTheme = theme => ({
|
const customTheme = theme => ({
|
||||||
...theme,
|
...theme,
|
||||||
@@ -22,30 +22,30 @@ const customTheme = theme => ({
|
|||||||
spacing: {
|
spacing: {
|
||||||
...theme.spacing,
|
...theme.spacing,
|
||||||
baseUnit: 3,
|
baseUnit: 3,
|
||||||
controlHeight: 32
|
controlHeight: 32,
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
...theme.colors,
|
...theme.colors,
|
||||||
primary: "#38b5eb",
|
primary: "#38b5eb",
|
||||||
primary75: "rgba(56, 181, 235, 0.25)",
|
primary75: "rgba(56, 181, 235, 0.25)",
|
||||||
primary50: "#38b5eb",
|
primary50: "#38b5eb",
|
||||||
primary25: "#38b5eb"
|
primary25: "#38b5eb",
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const customStyles = props => ({
|
const customStyles = props => ({
|
||||||
control: (base, _state) => ({
|
control: (base, _state) => ({
|
||||||
...base,
|
...base,
|
||||||
borderColor: props.hasError ? "#FB3A2F" : base.borderColor
|
borderColor: props.hasError ? "#fb3a2f" : base.borderColor,
|
||||||
}),
|
}),
|
||||||
valueContainer: base => ({
|
valueContainer: base => ({
|
||||||
...base,
|
...base,
|
||||||
padding: "4px 12px"
|
padding: "4px 12px",
|
||||||
}),
|
}),
|
||||||
input: base => ({
|
input: base => ({
|
||||||
...base,
|
...base,
|
||||||
border: "0 !important",
|
border: "0 !important",
|
||||||
boxShadow: "0 !important"
|
boxShadow: "0 !important",
|
||||||
}),
|
}),
|
||||||
groupHeading: (base, _state) => ({
|
groupHeading: (base, _state) => ({
|
||||||
...base,
|
...base,
|
||||||
@@ -53,16 +53,14 @@ const customStyles = props => ({
|
|||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
fontSize: "100%",
|
fontSize: "100%",
|
||||||
padding: "2px 7px 4px"
|
padding: "2px 7px 4px",
|
||||||
}),
|
}),
|
||||||
option: (base, state) => ({
|
option: (base, state) => ({
|
||||||
...base,
|
...base,
|
||||||
padding: hasOptionGroups(state.options)
|
padding: hasOptionGroups(state.options) ? "4px 7px 4px 20px" : "4px 7px 4px",
|
||||||
? "4px 7px 4px 20px"
|
|
||||||
: "4px 7px 4px",
|
|
||||||
backgroundColor: state.isFocused ? "#38b5eb" : "none",
|
backgroundColor: state.isFocused ? "#38b5eb" : "none",
|
||||||
color: state.isFocused ? "white" : "hsl(0, 0%, 20%)"
|
color: state.isFocused ? "white" : "hsl(0, 0%, 20%)",
|
||||||
})
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterOption = createFilter({
|
const filterOption = createFilter({
|
||||||
@@ -70,27 +68,38 @@ const filterOption = createFilter({
|
|||||||
join(" "),
|
join(" "),
|
||||||
filter(value => isString(value) || isNumber(value)),
|
filter(value => isString(value) || isNumber(value)),
|
||||||
values,
|
values,
|
||||||
property("data")
|
property("data"),
|
||||||
)
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
SingleValue.propTypes = {
|
||||||
|
children: PropTypes.string.isRequired,
|
||||||
|
data: PropTypes.shape({
|
||||||
|
customerName: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleValue({ children, ...props }) {
|
||||||
|
const label = isNil(props.data.customerName)
|
||||||
|
? children
|
||||||
|
: `${props.data.customerName}: ${children}`
|
||||||
|
return <components.SingleValue {...props}>{label}</components.SingleValue>
|
||||||
|
}
|
||||||
|
|
||||||
export default class Select extends Component {
|
export default class Select extends Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||||
options: PropTypes.array,
|
options: PropTypes.array,
|
||||||
hasError: PropTypes.bool,
|
hasError: PropTypes.bool,
|
||||||
onChange: PropTypes.func.isRequired
|
onChange: PropTypes.func.isRequired,
|
||||||
};
|
}
|
||||||
|
|
||||||
static findOptionByValue = (selectOptions, value) => {
|
static findOptionByValue = (selectOptions, value) => {
|
||||||
const options = flatMap(
|
const options = flatMap(option => (option.options ? option.options : option), selectOptions)
|
||||||
option => (option.options ? option.options : option),
|
|
||||||
selectOptions
|
|
||||||
)
|
|
||||||
|
|
||||||
return options.find(pathEq("value", value)) || null
|
return options.find(pathEq("value", value)) || null
|
||||||
};
|
}
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
@@ -101,7 +110,7 @@ export default class Select extends Component {
|
|||||||
const { name, onChange } = this.props
|
const { name, onChange } = this.props
|
||||||
const { value } = option
|
const { value } = option
|
||||||
onChange({ target: { name, value } })
|
onChange({ target: { name, value } })
|
||||||
};
|
}
|
||||||
|
|
||||||
handleKeyDown = event => {
|
handleKeyDown = event => {
|
||||||
if (!this.select.current) {
|
if (!this.select.current) {
|
||||||
@@ -111,7 +120,7 @@ export default class Select extends Component {
|
|||||||
if (!this.select.current.state.menuIsOpen && event.key === "Enter") {
|
if (!this.select.current.state.menuIsOpen && event.key === "Enter") {
|
||||||
this.select.current.setState({ menuIsOpen: true })
|
this.select.current.setState({ menuIsOpen: true })
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { value, ...passThroughProps } = this.props
|
const { value, ...passThroughProps } = this.props
|
||||||
@@ -121,11 +130,12 @@ export default class Select extends Component {
|
|||||||
{...passThroughProps}
|
{...passThroughProps}
|
||||||
ref={this.select}
|
ref={this.select}
|
||||||
value={Select.findOptionByValue(this.props.options, value)}
|
value={Select.findOptionByValue(this.props.options, value)}
|
||||||
onChange={this.handleChange}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
filterOption={filterOption}
|
filterOption={filterOption}
|
||||||
theme={customTheme}
|
theme={customTheme}
|
||||||
styles={customStyles(this.props)}
|
styles={customStyles(this.props)}
|
||||||
|
components={{ SingleValue }}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react'
|
import React from "react"
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from "prop-types"
|
||||||
|
|
||||||
const Spinner = ({ style }) => (
|
const Spinner = ({ style }) => (
|
||||||
<div className='moco-bx-spinner__container' style={style}>
|
<div className="moco-bx-spinner__container" style={style}>
|
||||||
<div className='moco-bx-spinner' role='status' />
|
<div className="moco-bx-spinner" role="status" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
Spinner.propTypes = {
|
Spinner.propTypes = {
|
||||||
style: PropTypes.object
|
style: PropTypes.object,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Spinner
|
export default Spinner
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React from 'react'
|
import React from "react"
|
||||||
import logoUrl from "images/logo.png"
|
import PropTypes from "prop-types"
|
||||||
|
import logoUrl from "images/moco-159x159.png"
|
||||||
|
|
||||||
const Header = () => (
|
const Header = ({ subdomain }) => (
|
||||||
<div className="moco-bx-logo__container">
|
<div className="moco-bx-logo__container">
|
||||||
<img
|
<a
|
||||||
className="moco-bx-logo"
|
href={`https://${subdomain}.mocoapp.com/activities`}
|
||||||
src={chrome.extension.getURL(logoUrl)}
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<img className="moco-bx-logo" src={chrome.extension.getURL(logoUrl)} />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Header.propTypes = {
|
||||||
|
subdomain: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
export default Header
|
export default Header
|
||||||
|
|||||||
11
src/js/components/shared/StopWatch.js
Normal file
11
src/js/components/shared/StopWatch.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react"
|
||||||
|
import stopWatch from "images/icons/stopwatch-light.svg"
|
||||||
|
|
||||||
|
export default function StopWatch() {
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
dangerouslySetInnerHTML={{ __html: stopWatch }}
|
||||||
|
style={{ width: "22px", color: "white", display: "inline-block" }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/js/components/shared/Timer.js
Normal file
29
src/js/components/shared/Timer.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { useState } from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import { useInterval } from "./hooks"
|
||||||
|
import { differenceInSeconds } from "date-fns"
|
||||||
|
import { formatDuration } from "utils"
|
||||||
|
|
||||||
|
Timer.propTypes = {
|
||||||
|
startedAt: PropTypes.instanceOf(Date).isRequired,
|
||||||
|
offset: PropTypes.number,
|
||||||
|
onTick: PropTypes.func,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Timer({ startedAt, offset = 0, onTick, ...domProps }) {
|
||||||
|
const [timerLabel, setTimerLabel] = useState(formattedTimerLabel(startedAt, offset))
|
||||||
|
|
||||||
|
useInterval(() => {
|
||||||
|
setTimerLabel(formattedTimerLabel(startedAt, offset))
|
||||||
|
onTick && onTick()
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return <span {...domProps}>{timerLabel}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formattedTimerLabel(startedAt, offset) {
|
||||||
|
const seconds = differenceInSeconds(new Date(), startedAt) + offset
|
||||||
|
return formatDuration(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timer
|
||||||
18
src/js/components/shared/hooks.js
Normal file
18
src/js/components/shared/hooks.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
export function useInterval(callback, delay) {
|
||||||
|
const savedCallback = useRef()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savedCallback.current = callback
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function tick() {
|
||||||
|
savedCallback.current()
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = setInterval(tick, delay)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [delay])
|
||||||
|
}
|
||||||
@@ -5,59 +5,64 @@ import Bubble from "./components/Bubble"
|
|||||||
import Popup from "components/Popup"
|
import Popup from "components/Popup"
|
||||||
import { createServiceFinder } from "utils/urlMatcher"
|
import { createServiceFinder } from "utils/urlMatcher"
|
||||||
import remoteServices from "./remoteServices"
|
import remoteServices from "./remoteServices"
|
||||||
import { ErrorBoundary } from "utils/notifier"
|
|
||||||
import { ContentMessenger } from "utils/messaging"
|
import { ContentMessenger } from "utils/messaging"
|
||||||
import "../css/content.scss"
|
import "../css/content.scss"
|
||||||
|
import { getSettings } from "./utils/browser"
|
||||||
|
|
||||||
const popupRef = createRef()
|
const popupRef = createRef()
|
||||||
const findService = createServiceFinder(remoteServices)(document)
|
|
||||||
|
|
||||||
chrome.runtime.onConnect.addListener(function(port) {
|
let findService
|
||||||
|
getSettings().then((settings) => {
|
||||||
|
findService = createServiceFinder(remoteServices, settings.hostOverrides)(document)
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.runtime.onConnect.addListener(function (port) {
|
||||||
const messenger = new ContentMessenger(port)
|
const messenger = new ContentMessenger(port)
|
||||||
|
|
||||||
|
function clickHandler(event) {
|
||||||
|
if (event.target.closest(".moco-bx-bubble")) {
|
||||||
|
event.stopPropagation()
|
||||||
|
messenger.postMessage({ type: "togglePopup" })
|
||||||
|
}
|
||||||
|
}
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
messenger.stop()
|
messenger.stop()
|
||||||
|
window.removeEventListener("click", clickHandler, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateBubble({ service, bookedHours } = {}) {
|
function updateBubble({ service, bookedSeconds, settingTimeTrackingHHMM, timedActivity } = {}) {
|
||||||
if (!document.getElementById("moco-bx-root")) {
|
if (!document.getElementById("moco-bx-root")) {
|
||||||
const domRoot = document.createElement("div")
|
const domRoot = document.createElement("div")
|
||||||
domRoot.setAttribute("id", "moco-bx-root")
|
domRoot.setAttribute("id", "moco-bx-root")
|
||||||
document.body.appendChild(domRoot)
|
document.body.appendChild(domRoot)
|
||||||
|
window.addEventListener("click", clickHandler, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<ErrorBoundary>
|
<Transition
|
||||||
<Transition
|
native
|
||||||
native
|
items={service}
|
||||||
items={service}
|
from={{ opacity: "0" }}
|
||||||
from={{ opacity: "0" }}
|
enter={{ opacity: "1" }}
|
||||||
enter={{ opacity: "1" }}
|
leave={{ opacity: "0" }}
|
||||||
leave={{ opacity: "0" }}
|
config={config.stiff}
|
||||||
config={config.stiff}
|
>
|
||||||
>
|
{(service) =>
|
||||||
{service =>
|
service &&
|
||||||
service &&
|
// eslint-disable-next-line react/display-name
|
||||||
// eslint-disable-next-line react/display-name
|
((props) => (
|
||||||
(props => (
|
<animated.div className="moco-bx-bubble" style={{ ...props, ...service.position }}>
|
||||||
<animated.div
|
<Bubble
|
||||||
className="moco-bx-bubble"
|
key={service.url}
|
||||||
style={{ ...props, ...service.position }}
|
bookedSeconds={bookedSeconds}
|
||||||
>
|
settingTimeTrackingHHMM={settingTimeTrackingHHMM}
|
||||||
<Bubble
|
timedActivity={timedActivity}
|
||||||
key={service.url}
|
/>
|
||||||
bookedHours={bookedHours}
|
</animated.div>
|
||||||
onClick={event => {
|
))
|
||||||
event.stopPropagation()
|
}
|
||||||
messenger.postMessage({ type: "togglePopup" })
|
</Transition>,
|
||||||
}}
|
document.getElementById("moco-bx-root"),
|
||||||
/>
|
|
||||||
</animated.div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Transition>
|
|
||||||
</ErrorBoundary>,
|
|
||||||
document.getElementById("moco-bx-root")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +74,8 @@ chrome.runtime.onConnect.addListener(function(port) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<ErrorBoundary>
|
<Popup ref={popupRef} {...payload} onRequestClose={closePopup} />,
|
||||||
<Popup ref={popupRef} {...payload} onRequestClose={closePopup} />
|
document.getElementById("moco-bx-popup-root"),
|
||||||
</ErrorBoundary>,
|
|
||||||
document.getElementById("moco-bx-popup-root")
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +92,12 @@ chrome.runtime.onConnect.addListener(function(port) {
|
|||||||
const service = findService(window.location.href)
|
const service = findService(window.location.href)
|
||||||
messenger.postMessage({
|
messenger.postMessage({
|
||||||
type: "newService",
|
type: "newService",
|
||||||
payload: { isOpen: !!popupRef.current, service }
|
payload: { isOpen: !!popupRef.current, service },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
messenger.on("showBubble", ({ payload: { service, bookedHours } }) => {
|
messenger.on("showBubble", ({ payload }) => {
|
||||||
updateBubble({ service, bookedHours })
|
updateBubble(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
messenger.on("hideBubble", () => {
|
messenger.on("hideBubble", () => {
|
||||||
@@ -108,8 +111,4 @@ chrome.runtime.onConnect.addListener(function(port) {
|
|||||||
messenger.on("closePopup", () => {
|
messenger.on("closePopup", () => {
|
||||||
closePopup()
|
closePopup()
|
||||||
})
|
})
|
||||||
|
|
||||||
messenger.on("activityCreated", () => {
|
|
||||||
closePopup()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import React from 'react'
|
import React from "react"
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from "react-dom"
|
||||||
import Options from './components/Options'
|
import Options from "./components/Options"
|
||||||
import { ErrorBoundary } from 'utils/notifier'
|
import "../css/options.scss"
|
||||||
import '../css/options.scss'
|
|
||||||
|
|
||||||
const domContainer = document.querySelector('#moco-bx-root')
|
const domContainer = document.querySelector("#moco-bx-root")
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(<Options />, domContainer)
|
||||||
<ErrorBoundary>
|
|
||||||
<Options />
|
|
||||||
</ErrorBoundary>,
|
|
||||||
domContainer
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,29 +3,22 @@ import ReactDOM from "react-dom"
|
|||||||
import App from "./components/App"
|
import App from "./components/App"
|
||||||
import queryString from "query-string"
|
import queryString from "query-string"
|
||||||
import { parseProps } from "utils"
|
import { parseProps } from "utils"
|
||||||
import { ErrorBoundary } from "utils/notifier"
|
|
||||||
import "../css/popup.scss"
|
import "../css/popup.scss"
|
||||||
|
|
||||||
const parsedProps = parseProps([
|
const parsedProps = parseProps([
|
||||||
"loading",
|
"loading",
|
||||||
"service",
|
"service",
|
||||||
|
"subdomain",
|
||||||
"projects",
|
"projects",
|
||||||
"activities",
|
"activities",
|
||||||
"schedules",
|
"schedules",
|
||||||
"lastProjectId",
|
"timedActivity",
|
||||||
"lastTaskId",
|
|
||||||
"roundTimeEntries",
|
|
||||||
"lastProjectId",
|
"lastProjectId",
|
||||||
"lastTaskId",
|
"lastTaskId",
|
||||||
"fromDate",
|
"fromDate",
|
||||||
"toDate",
|
"toDate",
|
||||||
"errorType",
|
"errorType",
|
||||||
"errorMessage"
|
"errorMessage",
|
||||||
])(queryString.parse(location.search))
|
])(queryString.parse(location.search))
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(<App {...parsedProps} />, document.querySelector("#moco-bx-root"))
|
||||||
<ErrorBoundary>
|
|
||||||
<App {...parsedProps} />
|
|
||||||
</ErrorBoundary>,
|
|
||||||
document.querySelector("#moco-bx-root")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,61 +1,58 @@
|
|||||||
|
const projectRegex = /\[([\w-]+)\]/
|
||||||
|
|
||||||
|
const projectIdentifierBySelector = (selector) => (document) =>
|
||||||
|
document.querySelector(selector)?.textContent?.trim()?.match(projectRegex)?.[1]
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
asana: {
|
asana: {
|
||||||
name: "asana",
|
name: "asana",
|
||||||
|
host: "https://app.asana.com",
|
||||||
urlPatterns: [
|
urlPatterns: [
|
||||||
[/^https:\/\/app.asana.com\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]],
|
[/^:host:\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]],
|
||||||
[
|
[/^:host:\/0\/search\/([^/]+)\/(\d+)/, ["domainUserId", "id"]],
|
||||||
/^https:\/\/app.asana.com\/0\/search\/([^/]+)\/(\d+)/,
|
|
||||||
["domainUserId", "id"]
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
description: document =>
|
description: (document) =>
|
||||||
document
|
document.querySelector(".ItemRow--highlighted textarea")?.textContent?.trim() ||
|
||||||
.querySelector(".ItemRow--highlighted textarea")
|
document.querySelector(".ItemRow--focused textarea")?.textContent?.trim() ||
|
||||||
?.textContent?.trim() ||
|
document.querySelector(".SingleTaskPane textarea")?.textContent?.trim() ||
|
||||||
document
|
document.querySelector(".SingleTaskTitleInput-taskName textarea")?.textContent?.trim(),
|
||||||
.querySelector(".ItemRow--focused textarea")
|
projectId: projectIdentifierBySelector(".TopbarPageHeaderStructure-titleRow h1"),
|
||||||
?.textContent?.trim() ||
|
allowHostOverride: false,
|
||||||
document.querySelector(".SingleTaskPane textarea")?.textContent?.trim()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"github-pr": {
|
"github-pr": {
|
||||||
name: "github",
|
name: "github",
|
||||||
urlPatterns: ["https\\://github.com/:org/:repo/pull/:id(/:tab)"],
|
host: "https://github.com",
|
||||||
id: (document, service, { org, repo, id }) =>
|
urlPatterns: [":host:/:org/:repo/pull/:id(/:tab)"],
|
||||||
[service.key, org, repo, id].join("."),
|
id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."),
|
||||||
description: (document, service, { org, repo, id }) =>
|
description: (document) => document.querySelector(".js-issue-title")?.textContent?.trim(),
|
||||||
document.querySelector(".js-issue-title")?.textContent?.trim(),
|
projectId: projectIdentifierBySelector(".js-issue-title"),
|
||||||
projectId: document => {
|
allowHostOverride: false,
|
||||||
const match = document
|
|
||||||
.querySelector(".js-issue-title")
|
|
||||||
?.textContent.trim()
|
|
||||||
?.match(/^\[(\d+)\]/)
|
|
||||||
return match && match[1]
|
|
||||||
},
|
|
||||||
position: { right: "2rem" }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"github-issue": {
|
"github-issue": {
|
||||||
name: "github",
|
name: "github",
|
||||||
urlPatterns: ["https\\://github.com/:org/:repo/issues/:id"],
|
host: "https://github.com",
|
||||||
id: (document, service, { org, repo, id }) =>
|
urlPatterns: [":host:/:org/:repo/issues/:id"],
|
||||||
[service.key, org, repo, id].join("."),
|
id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."),
|
||||||
description: (document, service, { org, repo, id }) =>
|
description: (document, service, { org, repo, id }) =>
|
||||||
document.querySelector(".js-issue-title")?.textContent?.trim(),
|
document.querySelector(".js-issue-title")?.textContent?.trim(),
|
||||||
position: { right: "2rem" }
|
projectId: projectIdentifierBySelector(".js-issue-title"),
|
||||||
|
allowHostOverride: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
jira: {
|
jira: {
|
||||||
name: "jira",
|
name: "jira",
|
||||||
|
host: "https://:org.atlassian.net",
|
||||||
urlPatterns: [
|
urlPatterns: [
|
||||||
"https\\://:org.atlassian.net/secure/RapidBoard.jspa",
|
":host:/secure/RapidBoard.jspa",
|
||||||
"https\\://:org.atlassian.net/browse/:id",
|
":host:/browse/:id",
|
||||||
"https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board",
|
":host:/jira/software/projects/:projectId/boards/:board",
|
||||||
"https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board/backlog"
|
":host:/jira/software/projects/:projectId/boards/:board/backlog",
|
||||||
],
|
],
|
||||||
queryParams: {
|
queryParams: {
|
||||||
id: "selectedIssue",
|
id: "selectedIssue",
|
||||||
projectId: "projectKey"
|
projectId: "projectKey",
|
||||||
},
|
},
|
||||||
description: (document, service, { id }) => {
|
description: (document, service, { id }) => {
|
||||||
const title =
|
const title =
|
||||||
@@ -63,35 +60,115 @@ export default {
|
|||||||
.querySelector('[aria-label="Edit Summary"]')
|
.querySelector('[aria-label="Edit Summary"]')
|
||||||
?.parentNode?.querySelector("h1")
|
?.parentNode?.querySelector("h1")
|
||||||
?.textContent?.trim() ||
|
?.textContent?.trim() ||
|
||||||
document
|
document.querySelector(".ghx-selected .ghx-summary")?.textContent?.trim()
|
||||||
.querySelector(".ghx-selected .ghx-summary")
|
return `#${id} ${title || ""}`
|
||||||
?.textContent?.trim()
|
},
|
||||||
return `[${id}] ${title || ""}`
|
allowHostOverride: true,
|
||||||
}
|
},
|
||||||
|
|
||||||
|
meistertask: {
|
||||||
|
name: "meistertask",
|
||||||
|
host: "https://www.meistertask.com",
|
||||||
|
urlPatterns: [":host:/app/task/:id/:slug"],
|
||||||
|
description: (document) => {
|
||||||
|
const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}"
|
||||||
|
const data = JSON.parse(json)
|
||||||
|
return data.taskName
|
||||||
|
},
|
||||||
|
projectId: (document) => {
|
||||||
|
const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}"
|
||||||
|
const data = JSON.parse(json)
|
||||||
|
const match = data.taskName?.match(projectRegex) || data.projectName?.match(projectRegex)
|
||||||
|
return match && match[1]
|
||||||
|
},
|
||||||
|
allowHostOverride: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
trello: {
|
trello: {
|
||||||
name: "trello",
|
name: "trello",
|
||||||
urlPatterns: ["https\\://trello.com/c/:id/:title"],
|
host: "https://trello.com",
|
||||||
|
urlPatterns: [":host:/c/:id/:title"],
|
||||||
description: (document, service, { title }) =>
|
description: (document, service, { title }) =>
|
||||||
document.querySelector(".js-title-helper")?.textContent?.trim() || title,
|
document.querySelector(".js-title-helper")?.textContent?.trim() || title,
|
||||||
position: { right: "calc(2rem + 4px)" }
|
projectId: (document) =>
|
||||||
|
projectIdentifierBySelector(".js-title-helper")(document) ||
|
||||||
|
projectIdentifierBySelector(".js-board-editing-target")(document),
|
||||||
|
allowHostOverride: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
youtrack: {
|
youtrack: {
|
||||||
name: "youtrack",
|
name: "youtrack",
|
||||||
urlPatterns: ["https\\://:org.myjetbrains.com/youtrack/issue/:id"],
|
host: "https://:org.myjetbrains.com",
|
||||||
description: document =>
|
urlPatterns: [":host:/youtrack/issue/:id"],
|
||||||
document.querySelector("yt-issue-body h1")?.textContent?.trim()
|
description: (document) => document.querySelector("yt-issue-body h1")?.textContent?.trim(),
|
||||||
|
projectId: projectIdentifierBySelector("yt-issue-body h1"),
|
||||||
|
allowHostOverride: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
wrike: {
|
||||||
|
name: "wrike",
|
||||||
|
host: "https://www.wrike.com",
|
||||||
|
urlPatterns: [
|
||||||
|
":host:/workspace.htm#path=mywork",
|
||||||
|
":host:/workspace.htm#path=folder",
|
||||||
|
"https\\://app-eu.wrike.com/workspace.htm#path=mywork",
|
||||||
|
"https\\://app-eu.wrike.com/workspace.htm#path=folder",
|
||||||
|
],
|
||||||
|
queryParams: {
|
||||||
|
id: ["t", "ot"],
|
||||||
|
},
|
||||||
|
description: (document) => document.querySelector(".title-field-ghost")?.textContent?.trim(),
|
||||||
|
projectId: projectIdentifierBySelector(".header-title__main"),
|
||||||
|
allowHostOverride: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
wunderlist: {
|
wunderlist: {
|
||||||
name: "wunderlist",
|
name: "wunderlist",
|
||||||
urlPatterns: ["https\\://www.wunderlist.com/(webapp)#/tasks/:id(/*)"],
|
host: "https://www.wunderlist.com",
|
||||||
description: document =>
|
urlPatterns: [":host:/(webapp)#/tasks/:id(/*)"],
|
||||||
|
description: (document) =>
|
||||||
document
|
document
|
||||||
.querySelector(".taskItem.selected .taskItem-titleWrapper-title")
|
.querySelector(".taskItem.selected .taskItem-titleWrapper-title")
|
||||||
?.textContent?.trim(),
|
?.textContent?.trim(),
|
||||||
position: { right: "calc(2rem + 4px)" }
|
projectId: projectIdentifierBySelector(".taskItem.selected .taskItem-titleWrapper-title"),
|
||||||
}
|
allowHostOverride: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
"gitlab-mr": {
|
||||||
|
name: "gitlab",
|
||||||
|
host: "https://gitlab.com",
|
||||||
|
urlPatterns: [
|
||||||
|
":host:/:org/:group/:projectId/-/merge_requests/:id",
|
||||||
|
":host:/:org/:projectId/-/merge_requests/:id",
|
||||||
|
],
|
||||||
|
description: (document, service, { id }) => {
|
||||||
|
const title = document.querySelector("h2.title")?.textContent?.trim()
|
||||||
|
return `#${id} ${title || ""}`.trim()
|
||||||
|
},
|
||||||
|
allowHostOverride: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"gitlab-issues": {
|
||||||
|
name: "gitlab",
|
||||||
|
host: "https://gitlab.com",
|
||||||
|
urlPatterns: [
|
||||||
|
":host:/:org/:group/:projectId/-/issues/:id",
|
||||||
|
":host:/:org/:projectId/-/issues/:id",
|
||||||
|
],
|
||||||
|
description: (document, service, { id }) => {
|
||||||
|
const title = document.querySelector("h2.title")?.textContent?.trim()
|
||||||
|
return `#${id} ${title || ""}`.trim()
|
||||||
|
},
|
||||||
|
allowHostOverride: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
monday: {
|
||||||
|
name: "monday",
|
||||||
|
host: "https://:org.monday.com",
|
||||||
|
urlPatterns: [":host:/boards/:board/pulses/:id"],
|
||||||
|
description: (document, service, { id }) => {
|
||||||
|
return document.querySelector(".pulse_title")?.textContent?.trim()
|
||||||
|
},
|
||||||
|
allowHostOverride: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
export default class TimeInputParser {
|
export default class TimeInputParser {
|
||||||
#input;
|
#input
|
||||||
|
|
||||||
constructor(input) {
|
constructor(input) {
|
||||||
this.#input = input.toLowerCase().replace(/[\s()]/g, "")
|
this.#input = (input ?? "").toLowerCase().replace(/[\s()]/g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
parseSeconds() {
|
parseSeconds() {
|
||||||
if (this.#isDecimal()) {
|
if (this.#input === "") {
|
||||||
|
return 0
|
||||||
|
} else if (this.#isDecimal()) {
|
||||||
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
|
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
|
||||||
} else if (this.#isTime()) {
|
} else if (this.#isTime()) {
|
||||||
return this.#parseTimeAsSeconds()
|
return this.#parseTimeAsSeconds()
|
||||||
@@ -25,11 +27,11 @@ export default class TimeInputParser {
|
|||||||
const calculated = hours * 3600 + minutes * 60
|
const calculated = hours * 3600 + minutes * 60
|
||||||
|
|
||||||
return isNegative ? -calculated : calculated
|
return isNegative ? -calculated : calculated
|
||||||
};
|
}
|
||||||
|
|
||||||
#parseDecimal = () => {
|
#parseDecimal = () => {
|
||||||
return this.#input.replace(/[.,]/g, ".")
|
return this.#input.replace(/[.,]/g, ".")
|
||||||
};
|
}
|
||||||
|
|
||||||
#parseTimeAsSeconds = () => {
|
#parseTimeAsSeconds = () => {
|
||||||
const match = this.#isTime()
|
const match = this.#isTime()
|
||||||
@@ -39,12 +41,12 @@ export default class TimeInputParser {
|
|||||||
const minutes = parseInt(match[3])
|
const minutes = parseInt(match[3])
|
||||||
|
|
||||||
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
|
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
|
||||||
};
|
}
|
||||||
|
|
||||||
#parseMinutesAsSeconds = () => {
|
#parseMinutesAsSeconds = () => {
|
||||||
const minutes = parseInt(this.#isMinutes()[1])
|
const minutes = parseInt(this.#isMinutes()[1])
|
||||||
return minutes * 60
|
return minutes * 60
|
||||||
};
|
}
|
||||||
|
|
||||||
#parseRange = () => {
|
#parseRange = () => {
|
||||||
const match = this.#isRange()
|
const match = this.#isRange()
|
||||||
@@ -54,7 +56,7 @@ export default class TimeInputParser {
|
|||||||
const to_hours = parseInt(match[3])
|
const to_hours = parseInt(match[3])
|
||||||
const to_minutes = parseInt(match[4])
|
const to_minutes = parseInt(match[4])
|
||||||
return (to_hours - from_hours) * 3600 + (to_minutes - from_minutes) * 60
|
return (to_hours - from_hours) * 3600 + (to_minutes - from_minutes) * 60
|
||||||
};
|
}
|
||||||
|
|
||||||
#parseHoursAndMinutes = () => {
|
#parseHoursAndMinutes = () => {
|
||||||
const match = this.#isHoursAndMinutes()
|
const match = this.#isHoursAndMinutes()
|
||||||
@@ -64,28 +66,26 @@ export default class TimeInputParser {
|
|||||||
const minutes = parseInt(match[3])
|
const minutes = parseInt(match[3])
|
||||||
|
|
||||||
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
|
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
|
||||||
};
|
}
|
||||||
|
|
||||||
#isDecimal = () => {
|
#isDecimal = () => {
|
||||||
return this.#input.match(/^([-]?[0-9]{0,2})[.,]{1}([0-9]{1,2})$/)
|
return this.#input.match(/^([-]?[0-9]{0,2})[.,]{1}([0-9]{1,2})$/)
|
||||||
};
|
}
|
||||||
|
|
||||||
#isTime = () => {
|
#isTime = () => {
|
||||||
return this.#input.match(/^([-]?)([0-9]{1,2}):([0-9]{2})$/)
|
return this.#input.match(/^([-]?)([0-9]{1,2}):([0-9]{2})$/)
|
||||||
};
|
}
|
||||||
|
|
||||||
#isMinutes = () => {
|
#isMinutes = () => {
|
||||||
return this.#input.match(/^([-]?[0-9]{1,3})(m|mins?)$/)
|
return this.#input.match(/^([-]?[0-9]{1,3})(m|mins?)$/)
|
||||||
};
|
}
|
||||||
|
|
||||||
#isRange = () => {
|
#isRange = () => {
|
||||||
return this.#input.match(
|
return this.#input.match(/^([0-9]{1,2})[:.]{0,1}([0-9]{2})-([0-9]{1,2})[:.]{0,1}([0-9]{2})$/)
|
||||||
/^([0-9]{1,2})[:.]{0,1}([0-9]{2})-([0-9]{1,2})[:.]{0,1}([0-9]{2})$/
|
}
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
#isHoursAndMinutes = () => {
|
#isHoursAndMinutes = () => {
|
||||||
// 1h 14m(in)
|
// 1h 14m(in)
|
||||||
return this.#input.match(/^([-]?)([0-9]{1,2})h([0-9]{1,2})(m|mins?)$/)
|
return this.#input.match(/^([-]?)([0-9]{1,2})h([0-9]{1,2})(m|mins?)$/)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
|
import { head, pick, reduce, filter, prop, pipe } from "lodash/fp"
|
||||||
|
import remoteServices from "../remoteServices"
|
||||||
|
|
||||||
|
const DEFAULT_SUBDOMAIN = "unset"
|
||||||
|
|
||||||
export const isChrome = () => typeof browser === "undefined" && chrome
|
export const isChrome = () => typeof browser === "undefined" && chrome
|
||||||
export const isFirefox = () => typeof browser !== "undefined" && chrome
|
export const isFirefox = () => typeof browser !== "undefined" && chrome
|
||||||
import { head } from "lodash/fp"
|
|
||||||
|
|
||||||
export const getSettings = () => {
|
export const defaultHostOverrides = pipe(
|
||||||
const keys = ["subdomain", "apiKey"]
|
filter(prop("allowHostOverride")),
|
||||||
|
reduce((acc, remoteService) => {
|
||||||
|
acc[remoteService.name] = remoteService.host
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
|
)(remoteServices)
|
||||||
|
|
||||||
|
// We pick only the keys defined in `defaultHostOverrides`, so that
|
||||||
|
// deleted host overrides get cleared from the settings
|
||||||
|
const getHostOverrides = (settings) => ({
|
||||||
|
...defaultHostOverrides,
|
||||||
|
...pick(Object.keys(defaultHostOverrides), settings.hostOverrides || {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getSettings = (withDefaultSubdomain = true) => {
|
||||||
|
const keys = ["subdomain", "apiKey", "settingTimeTrackingHHMM", "hostOverrides"]
|
||||||
const { version } = chrome.runtime.getManifest()
|
const { version } = chrome.runtime.getManifest()
|
||||||
if (isChrome()) {
|
if (isChrome()) {
|
||||||
return new Promise(resolve => {
|
return new Promise((resolve) => {
|
||||||
chrome.storage.sync.get(keys, data => {
|
chrome.storage.sync.get(keys, (settings) => {
|
||||||
resolve({ ...data, version })
|
if (withDefaultSubdomain) {
|
||||||
|
settings.subdomain = settings.subdomain || DEFAULT_SUBDOMAIN
|
||||||
|
}
|
||||||
|
settings.hostOverrides = getHostOverrides(settings)
|
||||||
|
resolve({ ...settings, version })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
return browser.storage.sync.get(keys).then(data => ({ ...data, version }))
|
return browser.storage.sync.get(keys).then((settings) => {
|
||||||
|
if (withDefaultSubdomain) {
|
||||||
|
settings.subdomain = settings.subdomain || DEFAULT_SUBDOMAIN
|
||||||
|
}
|
||||||
|
settings.hostOverrides = getHostOverrides(settings)
|
||||||
|
return { ...settings, version }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setStorage = items => {
|
export const setStorage = (items) => {
|
||||||
if (isChrome()) {
|
if (isChrome()) {
|
||||||
return new Promise(resolve => {
|
return new Promise((resolve) => {
|
||||||
chrome.storage.sync.set(items, resolve)
|
chrome.storage.sync.set(items, resolve)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -26,9 +55,9 @@ export const setStorage = items => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const queryTabs = queryInfo => {
|
export const queryTabs = (queryInfo) => {
|
||||||
if (isChrome()) {
|
if (isChrome()) {
|
||||||
return new Promise(resolve => chrome.tabs.query(queryInfo, resolve))
|
return new Promise((resolve) => chrome.tabs.query(queryInfo, resolve))
|
||||||
} else {
|
} else {
|
||||||
return browser.tabs.query(queryInfo)
|
return browser.tabs.query(queryInfo)
|
||||||
}
|
}
|
||||||
@@ -38,4 +67,4 @@ export const getCurrentTab = () => {
|
|||||||
return queryTabs({ currentWindow: true, active: true }).then(head)
|
return queryTabs({ currentWindow: true, active: true }).then(head)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isBrowserTab = tab => /^(?:chrome|about):/.test(tab.url)
|
export const isBrowserTab = (tab) => /^(?:chrome|about):/.test(tab.url)
|
||||||
|
|||||||
@@ -9,71 +9,71 @@ import {
|
|||||||
get,
|
get,
|
||||||
find,
|
find,
|
||||||
curry,
|
curry,
|
||||||
pick
|
pick,
|
||||||
|
head,
|
||||||
|
defaultTo,
|
||||||
|
padCharsStart,
|
||||||
} from "lodash/fp"
|
} from "lodash/fp"
|
||||||
|
import { startOfWeek, endOfWeek } from "date-fns"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
|
|
||||||
const nilToArray = input => input || []
|
const nilToArray = (input) => input || []
|
||||||
|
|
||||||
export const ERROR_UNAUTHORIZED = "unauthorized"
|
export const ERROR_UNAUTHORIZED = "unauthorized"
|
||||||
export const ERROR_UPGRADE_REQUIRED = "upgrade-required"
|
export const ERROR_UPGRADE_REQUIRED = "upgrade-required"
|
||||||
export const ERROR_UNKNOWN = "unknown"
|
export const ERROR_UNKNOWN = "unknown"
|
||||||
|
|
||||||
export const noop = () => null
|
export const noop = () => null
|
||||||
|
export const asArray = (input) => (Array.isArray(input) ? input : [input])
|
||||||
|
|
||||||
|
export const findProjectBy = (prop) => (val) => (projects) => {
|
||||||
|
if (!val) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return compose(find(pathEq(prop, val)), flatMap(get("options")))(projects)
|
||||||
|
}
|
||||||
|
|
||||||
export const findProjectBy = prop => val =>
|
|
||||||
compose(
|
|
||||||
find(pathEq(prop, val)),
|
|
||||||
flatMap(get("options"))
|
|
||||||
)
|
|
||||||
export const findProjectByIdentifier = findProjectBy("identifier")
|
export const findProjectByIdentifier = findProjectBy("identifier")
|
||||||
export const findProjectByValue = findProjectBy("value")
|
export const findProjectByValue = findProjectBy("value")
|
||||||
|
|
||||||
export const findTask = id =>
|
export const findTask = (id) => compose(find(pathEq("value", Number(id))), get("tasks"))
|
||||||
compose(
|
|
||||||
find(pathEq("value", Number(id))),
|
export const defaultTask = (tasks) =>
|
||||||
get("tasks")
|
compose(defaultTo(head(tasks)), find(pathEq("isDefault", true)), nilToArray)(tasks)
|
||||||
)
|
|
||||||
|
|
||||||
function taskOptions(tasks) {
|
function taskOptions(tasks) {
|
||||||
return tasks.map(({ id, name, billable }) => ({
|
return tasks.map(({ id, name, billable, default: isDefault }) => ({
|
||||||
label: billable ? name : `(${name})`,
|
label: billable ? name : `(${name})`,
|
||||||
value: id,
|
value: id,
|
||||||
billable
|
billable,
|
||||||
|
isDefault,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectOptions(projects) {
|
export function projectOptions(projects) {
|
||||||
return projects.map(project => ({
|
return projects.map((project) => ({
|
||||||
value: project.id,
|
value: project.id,
|
||||||
label: project.intern ? `(${project.name})` : project.name,
|
label: project.intern ? `(${project.name})` : project.name,
|
||||||
identifier: project.identifier,
|
identifier: project.identifier,
|
||||||
customerName: project.customer_name,
|
customerName: project.customer_name,
|
||||||
tasks: taskOptions(project.tasks)
|
tasks: taskOptions(project.tasks),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const groupedProjectOptions = compose(
|
export const groupedProjectOptions = compose(
|
||||||
map(([customerName, projects]) => ({
|
map(([customerName, projects]) => ({
|
||||||
label: customerName,
|
label: customerName,
|
||||||
options: projectOptions(projects)
|
options: projectOptions(projects),
|
||||||
})),
|
})),
|
||||||
toPairs,
|
toPairs,
|
||||||
groupBy("customer_name"),
|
groupBy("customer_name"),
|
||||||
nilToArray
|
nilToArray,
|
||||||
)
|
)
|
||||||
|
|
||||||
export const serializeProps = attrs =>
|
export const serializeProps = (attrs) => compose(mapValues(JSON.stringify), pick(attrs))
|
||||||
compose(
|
|
||||||
mapValues(JSON.stringify),
|
|
||||||
pick(attrs)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const parseProps = attrs =>
|
export const parseProps = (attrs) => compose(mapValues(JSON.parse), pick(attrs))
|
||||||
compose(
|
|
||||||
mapValues(JSON.parse),
|
|
||||||
pick(attrs)
|
|
||||||
)
|
|
||||||
|
|
||||||
export const trace = curry((tag, value) => {
|
export const trace = curry((tag, value) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -82,7 +82,40 @@ export const trace = curry((tag, value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const weekStartsOn = 1
|
export const weekStartsOn = 1
|
||||||
export const formatDate = date => format(date, "YYYY-MM-DD")
|
export const formatDate = (date) => format(date, "yyyy-MM-dd")
|
||||||
|
export const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn })
|
||||||
|
export const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn })
|
||||||
|
|
||||||
export const extensionSettingsUrl = () =>
|
export const extensionSettingsUrl = () => `chrome://extensions/?id=${chrome.runtime.id}`
|
||||||
`chrome://extensions/?id=${chrome.runtime.id}`
|
|
||||||
|
export const extractAndSetTag = (changeset) => {
|
||||||
|
let { description } = changeset
|
||||||
|
const match = description.match(/^#(\S+)/)
|
||||||
|
if (!match) {
|
||||||
|
return changeset
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...changeset,
|
||||||
|
description: description.replace(/^#\S+\s/, ""),
|
||||||
|
tag: match[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDuration = (
|
||||||
|
durationInSeconds,
|
||||||
|
{ settingTimeTrackingHHMM = true, showSeconds = true } = {},
|
||||||
|
) => {
|
||||||
|
if (settingTimeTrackingHHMM) {
|
||||||
|
const hours = Math.floor(durationInSeconds / 3600)
|
||||||
|
const minutes = Math.floor((durationInSeconds % 3600) / 60)
|
||||||
|
const result = `${hours}:${padCharsStart("0", 2, minutes)}`
|
||||||
|
if (!showSeconds) {
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
const seconds = durationInSeconds % 60
|
||||||
|
return result + `:${padCharsStart("0", 2, seconds)}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return (durationInSeconds / 3600).toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,45 +4,55 @@ import {
|
|||||||
ERROR_UPGRADE_REQUIRED,
|
ERROR_UPGRADE_REQUIRED,
|
||||||
ERROR_UNKNOWN,
|
ERROR_UNKNOWN,
|
||||||
groupedProjectOptions,
|
groupedProjectOptions,
|
||||||
weekStartsOn
|
getStartOfWeek,
|
||||||
|
getEndOfWeek,
|
||||||
} from "utils"
|
} from "utils"
|
||||||
import { get, forEach, reject, isNil } from "lodash/fp"
|
import { get, forEach, reject, isNil } from "lodash/fp"
|
||||||
import { startOfWeek, endOfWeek } from "date-fns"
|
|
||||||
import { createMatcher } from "utils/urlMatcher"
|
import { createMatcher } from "utils/urlMatcher"
|
||||||
import remoteServices from "remoteServices"
|
import remoteServices from "remoteServices"
|
||||||
import { queryTabs, isBrowserTab, getSettings } from "utils/browser"
|
import { queryTabs, isBrowserTab, getSettings, setStorage } from "utils/browser"
|
||||||
|
|
||||||
const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn })
|
let matcher
|
||||||
const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn })
|
|
||||||
const matcher = createMatcher(remoteServices)
|
const initMatcher = (settings) => {
|
||||||
|
matcher = createMatcher(remoteServices, settings.hostOverrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettings().then((settings) => {
|
||||||
|
initMatcher(settings)
|
||||||
|
})
|
||||||
|
|
||||||
export function tabUpdated(tab, { messenger, settings }) {
|
export function tabUpdated(tab, { messenger, settings }) {
|
||||||
messenger.connectTab(tab)
|
messenger.connectTab(tab)
|
||||||
|
|
||||||
const service = matcher(tab.url)
|
const service = matcher(tab.url)
|
||||||
|
const apiClient = new ApiClient(settings)
|
||||||
|
|
||||||
if (service?.match?.id) {
|
if (service?.match?.id) {
|
||||||
messenger.postMessage(tab, { type: "requestService" })
|
messenger.postMessage(tab, { type: "requestService" })
|
||||||
|
|
||||||
messenger.once("newService", ({ payload: { service } }) => {
|
messenger.once("newService", ({ payload: { service } }) => {
|
||||||
const apiClient = new ApiClient(settings)
|
|
||||||
apiClient
|
apiClient
|
||||||
.bookedHours(service)
|
.activitiesStatus(service)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
messenger.postMessage(tab, {
|
messenger.postMessage(tab, {
|
||||||
type: "showBubble",
|
type: "showBubble",
|
||||||
payload: {
|
payload: {
|
||||||
bookedHours: parseFloat(data[0]?.hours) || 0,
|
bookedSeconds: data.seconds,
|
||||||
service
|
settingTimeTrackingHHMM: settings.settingTimeTrackingHHMM,
|
||||||
}
|
timedActivity: data.timed_activity,
|
||||||
|
service,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
messenger.postMessage(tab, {
|
messenger.postMessage(tab, {
|
||||||
type: "showBubble",
|
type: "showBubble",
|
||||||
payload: {
|
payload: {
|
||||||
bookedHours: 0,
|
bookedSeconds: 0,
|
||||||
service
|
settingTimeTrackingHHMM: settings.settingTimeTrackingHHMM,
|
||||||
}
|
service,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -52,13 +62,15 @@ export function tabUpdated(tab, { messenger, settings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function settingsChanged(settings, { messenger }) {
|
export function settingsChanged(settings, { messenger }) {
|
||||||
|
initMatcher(settings)
|
||||||
|
|
||||||
queryTabs({ currentWindow: true })
|
queryTabs({ currentWindow: true })
|
||||||
.then(reject(isBrowserTab))
|
.then(reject(isBrowserTab))
|
||||||
.then(
|
.then(
|
||||||
forEach(tab => {
|
forEach(tab => {
|
||||||
messenger.postMessage(tab, { type: "closePopup" })
|
messenger.postMessage(tab, { type: "closePopup" })
|
||||||
tabUpdated(tab, { settings, messenger })
|
tabUpdated(tab, { settings, messenger })
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,52 +88,61 @@ export function togglePopup(tab, { messenger }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPopup(tab, { service, messenger }) {
|
export async function openPopup(tab, { service, messenger }) {
|
||||||
messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } })
|
messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } })
|
||||||
|
|
||||||
const fromDate = getStartOfWeek()
|
const fromDate = getStartOfWeek()
|
||||||
const toDate = getEndOfWeek()
|
const toDate = getEndOfWeek()
|
||||||
getSettings()
|
const settings = await getSettings()
|
||||||
.then(settings => new ApiClient(settings))
|
const apiClient = new ApiClient(settings)
|
||||||
.then(apiClient =>
|
const responses = []
|
||||||
Promise.all([
|
try {
|
||||||
apiClient.login(service),
|
responses.push(await apiClient.login(service))
|
||||||
apiClient.projects(),
|
// we can forgo the following calls if a timed activity exists
|
||||||
apiClient.activities(fromDate, toDate),
|
if (!responses[0].data.timed_activity) {
|
||||||
apiClient.schedules(fromDate, toDate)
|
responses.push(
|
||||||
])
|
...(await Promise.all([
|
||||||
)
|
apiClient.projects(),
|
||||||
.then(responses => {
|
apiClient.activities(fromDate, toDate),
|
||||||
const action = {
|
apiClient.schedules(fromDate, toDate),
|
||||||
type: "openPopup",
|
])),
|
||||||
payload: {
|
)
|
||||||
service,
|
}
|
||||||
lastProjectId: get("[0].data.last_project_id", responses),
|
|
||||||
lastTaskId: get("[0].data.last_task_id", responses),
|
const settingTimeTrackingHHMM = get("[0].data.setting_time_tracking_hh_mm", responses)
|
||||||
roundTimeEntries: get("[0].data.round_time_entries", responses),
|
!isNil(settingTimeTrackingHHMM) && setStorage({ settingTimeTrackingHHMM })
|
||||||
projects: groupedProjectOptions(get("[1].data.projects", responses)),
|
|
||||||
activities: get("[2].data", responses),
|
const action = {
|
||||||
schedules: get("[3].data", responses),
|
type: "openPopup",
|
||||||
fromDate,
|
payload: {
|
||||||
toDate,
|
service,
|
||||||
loading: false
|
subdomain: settings.subdomain,
|
||||||
}
|
timedActivity: get("[0].data.timed_activity", responses),
|
||||||
}
|
lastProjectId: get("[0].data.last_project_id", responses),
|
||||||
messenger.postMessage(tab, action)
|
lastTaskId: get("[0].data.last_task_id", responses),
|
||||||
})
|
roundTimeEntries: get("[0].data.round_time_entries", responses),
|
||||||
.catch(error => {
|
projects: groupedProjectOptions(get("[1].data.projects", responses)),
|
||||||
let errorType, errorMessage
|
activities: get("[2].data", responses),
|
||||||
if (error.response?.status === 401) {
|
schedules: get("[3].data", responses),
|
||||||
errorType = ERROR_UNAUTHORIZED
|
fromDate,
|
||||||
} else if (error.response?.status === 426) {
|
toDate,
|
||||||
errorType = ERROR_UPGRADE_REQUIRED
|
loading: false,
|
||||||
} else {
|
},
|
||||||
errorType = ERROR_UNKNOWN
|
}
|
||||||
errorMessage = error.message
|
messenger.postMessage(tab, action)
|
||||||
}
|
} catch (error) {
|
||||||
messenger.postMessage(tab, {
|
let errorType, errorMessage
|
||||||
type: "openPopup",
|
if (error.response?.status === 401) {
|
||||||
payload: { errorType, errorMessage }
|
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 },
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
export class BackgroundMessenger {
|
export class BackgroundMessenger {
|
||||||
#ports = new Map();
|
#ports = new Map()
|
||||||
#handlers = new Map();
|
#handlers = new Map()
|
||||||
#onceHandlers = new Map();
|
#onceHandlers = new Map()
|
||||||
|
|
||||||
#handler = action => {
|
#handler = action => {
|
||||||
const handler = this.#handlers.get(action.type)
|
const handler = this.#handlers.get(action.type)
|
||||||
if (handler) {
|
if (handler) {
|
||||||
handler(action)
|
handler(action)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
#onceHandler = action => {
|
#onceHandler = action => {
|
||||||
const handler = this.#onceHandlers.get(action.type)
|
const handler = this.#onceHandlers.get(action.type)
|
||||||
@@ -16,7 +16,7 @@ export class BackgroundMessenger {
|
|||||||
if (handler) {
|
if (handler) {
|
||||||
handler(action)
|
handler(action)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
#registerPort = (tabId, port) => {
|
#registerPort = (tabId, port) => {
|
||||||
this.#ports.set(tabId, port)
|
this.#ports.set(tabId, port)
|
||||||
@@ -25,14 +25,14 @@ export class BackgroundMessenger {
|
|||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
this.#unregisterPort(tabId, port)
|
this.#unregisterPort(tabId, port)
|
||||||
})
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
#unregisterPort = (tabId, port) => {
|
#unregisterPort = (tabId, port) => {
|
||||||
port.onMessage.removeListener(this.#handler)
|
port.onMessage.removeListener(this.#handler)
|
||||||
port.onMessage.removeListener(this.#onceHandler)
|
port.onMessage.removeListener(this.#onceHandler)
|
||||||
port.disconnect()
|
port.disconnect()
|
||||||
this.#ports.delete(tabId)
|
this.#ports.delete(tabId)
|
||||||
};
|
}
|
||||||
|
|
||||||
connectTab = tab => {
|
connectTab = tab => {
|
||||||
const currentPort = this.#ports.get(tab.id)
|
const currentPort = this.#ports.get(tab.id)
|
||||||
@@ -40,41 +40,41 @@ export class BackgroundMessenger {
|
|||||||
const port = chrome.tabs.connect(tab.id)
|
const port = chrome.tabs.connect(tab.id)
|
||||||
this.#registerPort(tab.id, port)
|
this.#registerPort(tab.id, port)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
disconnectTab = tabId => {
|
disconnectTab = tabId => {
|
||||||
const port = this.#ports.get(tabId)
|
const port = this.#ports.get(tabId)
|
||||||
if (port) {
|
if (port) {
|
||||||
this.#unregisterPort(tabId, port)
|
this.#unregisterPort(tabId, port)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
postMessage = (tab, action) => {
|
postMessage = (tab, action) => {
|
||||||
const port = this.#ports.get(tab.id)
|
const port = this.#ports.get(tab.id)
|
||||||
if (port) {
|
if (port) {
|
||||||
port.postMessage(action)
|
port.postMessage(action)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
once = (type, handler) => {
|
once = (type, handler) => {
|
||||||
this.#onceHandlers.set(type, handler)
|
this.#onceHandlers.set(type, handler)
|
||||||
};
|
}
|
||||||
|
|
||||||
on = (type, handler) => {
|
on = (type, handler) => {
|
||||||
this.#handlers.set(type, handler)
|
this.#handlers.set(type, handler)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ContentMessenger {
|
export class ContentMessenger {
|
||||||
#port;
|
#port
|
||||||
#handlers = new Map();
|
#handlers = new Map()
|
||||||
|
|
||||||
#handler = action => {
|
#handler = action => {
|
||||||
const handler = this.#handlers.get(action.type)
|
const handler = this.#handlers.get(action.type)
|
||||||
if (handler) {
|
if (handler) {
|
||||||
handler(action)
|
handler(action)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
constructor(port) {
|
constructor(port) {
|
||||||
this.#port = port
|
this.#port = port
|
||||||
@@ -85,15 +85,15 @@ export class ContentMessenger {
|
|||||||
if (this.#port) {
|
if (this.#port) {
|
||||||
this.#port.postMessage(action)
|
this.#port.postMessage(action)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
on = (type, handler) => {
|
on = (type, handler) => {
|
||||||
this.#handlers.set(type, handler)
|
this.#handlers.set(type, handler)
|
||||||
};
|
}
|
||||||
|
|
||||||
stop = () => {
|
stop = () => {
|
||||||
this.#port.onMessage.removeListener(this.#handler)
|
this.#port.onMessage.removeListener(this.#handler)
|
||||||
this.#port = null
|
this.#port = null
|
||||||
this.#handlers.clear()
|
this.#handlers.clear()
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import UrlPattern from "url-pattern"
|
import UrlPattern from "url-pattern"
|
||||||
import {
|
import { isFunction, isUndefined, compose, toPairs, map, pipe, isNil, reduce } from "lodash/fp"
|
||||||
isFunction,
|
import { asArray } from "./index"
|
||||||
isUndefined,
|
|
||||||
compose,
|
|
||||||
toPairs,
|
|
||||||
map,
|
|
||||||
pipe
|
|
||||||
} from "lodash/fp"
|
|
||||||
import queryString from "query-string"
|
import queryString from "query-string"
|
||||||
|
|
||||||
const extractQueryParams = (queryParams, query) => {
|
function parseUrl(url) {
|
||||||
return toPairs(queryParams).reduce((acc, [key, param]) => {
|
const urlObject = new URL(url)
|
||||||
acc[key] = query[param]
|
const { origin, pathname, search } = urlObject
|
||||||
|
let { hash } = urlObject
|
||||||
|
const query = {
|
||||||
|
...queryString.parse(search),
|
||||||
|
...queryString.parse(hash),
|
||||||
|
}
|
||||||
|
if (hash) {
|
||||||
|
hash = hash.match(/#[^&]+/)[0]
|
||||||
|
}
|
||||||
|
return { origin, pathname, hash, query }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQueryParams(queryParams, query) {
|
||||||
|
return toPairs(queryParams).reduce((acc, [key, params]) => {
|
||||||
|
const param = asArray(params).find((param) => !isNil(query[param]))
|
||||||
|
if (param) {
|
||||||
|
acc[key] = query[param]
|
||||||
|
}
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEvaluator = args => fnOrValue => {
|
const createEvaluator = (args) => (fnOrValue) => {
|
||||||
if (isUndefined(fnOrValue)) {
|
if (isUndefined(fnOrValue)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -28,21 +39,35 @@ const createEvaluator = args => fnOrValue => {
|
|||||||
return fnOrValue
|
return fnOrValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prepareHostForRegExp = (host) => {
|
||||||
|
return host.replace(":", "\\:")
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceHostInPattern = (host, pattern) => {
|
||||||
|
if (typeof pattern === "string") {
|
||||||
|
return pattern.replace(":host:", prepareHostForRegExp(host))
|
||||||
|
} else if (pattern instanceof RegExp) {
|
||||||
|
return new RegExp(pattern.source.replace(":host:", prepareHostForRegExp(host)))
|
||||||
|
} else {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parseServices = compose(
|
const parseServices = compose(
|
||||||
map(([key, config]) => ({
|
map(([key, config]) => ({
|
||||||
...config,
|
...config,
|
||||||
key,
|
key,
|
||||||
patterns: config.urlPatterns.map(pattern => {
|
patterns: config.urlPatterns.map((pattern) => {
|
||||||
if (Array.isArray(pattern)) {
|
if (Array.isArray(pattern)) {
|
||||||
return new UrlPattern(...pattern)
|
return new UrlPattern(...pattern.map((p) => replaceHostInPattern(config.host, p)))
|
||||||
}
|
}
|
||||||
return new UrlPattern(pattern)
|
return new UrlPattern(replaceHostInPattern(config.host, pattern))
|
||||||
})
|
}),
|
||||||
})),
|
})),
|
||||||
toPairs
|
toPairs,
|
||||||
)
|
)
|
||||||
|
|
||||||
export const createEnhancer = document => service => {
|
export const createEnhancer = (document) => (service) => {
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,48 +82,55 @@ export const createEnhancer = document => service => {
|
|||||||
description: evaluate(service.description),
|
description: evaluate(service.description),
|
||||||
projectId: evaluate(service.projectId),
|
projectId: evaluate(service.projectId),
|
||||||
taskId: evaluate(service.taskId),
|
taskId: evaluate(service.taskId),
|
||||||
position: service.position || { left: "50%", transform: "translateX(-50%)" }
|
position: service.position || { right: "calc(2rem + 5px)" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createMatcher = remoteServices => {
|
const applyHostOverrides = (remoteServices, hostOverrides) =>
|
||||||
const services = parseServices(remoteServices)
|
pipe(
|
||||||
return tabUrl => {
|
toPairs,
|
||||||
const { origin, pathname, hash, search } = new URL(tabUrl)
|
reduce((acc, [key, remoteService]) => {
|
||||||
|
acc[key] = {
|
||||||
|
...remoteService,
|
||||||
|
key,
|
||||||
|
host: hostOverrides[remoteService.name] || remoteService.host,
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
|
)(remoteServices)
|
||||||
|
|
||||||
|
export const createMatcher = (remoteServices, hostOverrides) => {
|
||||||
|
const services = parseServices(applyHostOverrides(remoteServices, hostOverrides))
|
||||||
|
|
||||||
|
return (tabUrl) => {
|
||||||
|
const { origin, pathname, hash, query } = parseUrl(tabUrl)
|
||||||
const url = `${origin}${pathname}${hash}`
|
const url = `${origin}${pathname}${hash}`
|
||||||
const query = queryString.parse(search)
|
const service = services.find((service) => {
|
||||||
const service = services.find(service =>
|
return service.patterns.some((pattern) => pattern.match(url))
|
||||||
service.patterns.some(pattern => pattern.match(url))
|
})
|
||||||
)
|
|
||||||
|
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const pattern = service.patterns.find(pattern => pattern.match(url))
|
const pattern = service.patterns.find((pattern) => pattern.match(url))
|
||||||
let match = pattern.match(url)
|
let match = pattern.match(url)
|
||||||
if (service.queryParams) {
|
if (service.queryParams) {
|
||||||
const extractedQueryParams = extractQueryParams(
|
const extractedQueryParams = extractQueryParams(service.queryParams, query)
|
||||||
service.queryParams,
|
|
||||||
query
|
|
||||||
)
|
|
||||||
match = { ...extractedQueryParams, ...match }
|
match = { ...extractedQueryParams, ...match }
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...match,
|
...match,
|
||||||
...service,
|
...service,
|
||||||
url,
|
url: tabUrl,
|
||||||
match
|
match,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createServiceFinder = remoteServices => document => {
|
export const createServiceFinder = (remoteServices, hostOverrides) => (document) => {
|
||||||
const matcher = createMatcher(remoteServices)
|
const matcher = createMatcher(remoteServices, hostOverrides)
|
||||||
const enhancer = createEnhancer(document)
|
const enhancer = createEnhancer(document)
|
||||||
return pipe(
|
return pipe(matcher, enhancer)
|
||||||
matcher,
|
|
||||||
enhancer
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,21 @@
|
|||||||
"description": "MOCO Zeiterfassung Plugin",
|
"description": "MOCO Zeiterfassung Plugin",
|
||||||
"version": "0.9.20",
|
"version": "0.9.20",
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"description": "MOCO Time Tracking Plugin",
|
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "src/images/logo.png",
|
"16": "src/images/moco-32x32.png",
|
||||||
"32": "src/images/logo.png",
|
"32": "src/images/moco-32x32.png",
|
||||||
"48": "src/images/logo.png",
|
"48": "src/images/moco-159x159.png",
|
||||||
"128": "src/images/logo.png"
|
"128": "src/images/moco-159x159.png"
|
||||||
},
|
},
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
"page": "options.html"
|
"page": "options.html"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": ["https://*.mocoapp.com/*", "storage", "tabs"],
|
||||||
"https://*.mocoapp.com/*",
|
|
||||||
"storage",
|
|
||||||
"tabs"
|
|
||||||
],
|
|
||||||
"background": {
|
"background": {
|
||||||
"page": "background.html"
|
"page": "background.html"
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_icon": "src/images/logo.png"
|
"default_icon": "src/images/moco-32x32.png"
|
||||||
},
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
|
|||||||
60
test/data.js
60
test/data.js
@@ -9,14 +9,16 @@ export const projects = [
|
|||||||
{
|
{
|
||||||
id: 2733682,
|
id: 2733682,
|
||||||
name: "Bugfixing",
|
name: "Bugfixing",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2733681,
|
id: 2733681,
|
||||||
name: "Development",
|
name: "Development",
|
||||||
billable: true
|
billable: true,
|
||||||
}
|
default: true,
|
||||||
]
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 944724773,
|
id: 944724773,
|
||||||
@@ -28,24 +30,28 @@ export const projects = [
|
|||||||
{
|
{
|
||||||
id: 1621304,
|
id: 1621304,
|
||||||
name: "Roadmap Features",
|
name: "Roadmap Features",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1621310,
|
id: 1621310,
|
||||||
name: "Bugfixing",
|
name: "Bugfixing",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1621305,
|
id: 1621305,
|
||||||
name: "Quickwins",
|
name: "Quickwins",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1621323,
|
id: 1621323,
|
||||||
name: "Refactorings",
|
name: "Refactorings",
|
||||||
billable: true
|
billable: true,
|
||||||
}
|
default: false,
|
||||||
]
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 944837106,
|
id: 944837106,
|
||||||
@@ -57,24 +63,28 @@ export const projects = [
|
|||||||
{
|
{
|
||||||
id: 2500080,
|
id: 2500080,
|
||||||
name: "Intercom & Mails",
|
name: "Intercom & Mails",
|
||||||
billable: false
|
billable: false,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2500081,
|
id: 2500081,
|
||||||
name: "Demos",
|
name: "Demos",
|
||||||
billable: false
|
billable: false,
|
||||||
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2506050,
|
id: 2506050,
|
||||||
name: "Calls",
|
name: "Calls",
|
||||||
billable: false
|
billable: false,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2500084,
|
id: 2500084,
|
||||||
name: "Importe",
|
name: "Importe",
|
||||||
billable: false
|
billable: false,
|
||||||
}
|
default: false,
|
||||||
]
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 944621413,
|
id: 944621413,
|
||||||
@@ -86,23 +96,27 @@ export const projects = [
|
|||||||
{
|
{
|
||||||
id: 874014,
|
id: 874014,
|
||||||
name: "Entwicklung",
|
name: "Entwicklung",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 874015,
|
id: 874015,
|
||||||
name: "Grafik",
|
name: "Grafik",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 874016,
|
id: 874016,
|
||||||
name: "Konzept",
|
name: "Konzept",
|
||||||
billable: true
|
billable: true,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 874017,
|
id: 874017,
|
||||||
name: "Projektleitung",
|
name: "Projektleitung",
|
||||||
billable: true
|
billable: true,
|
||||||
}
|
default: false,
|
||||||
]
|
},
|
||||||
}
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,15 +3,26 @@ import {
|
|||||||
findProjectByValue,
|
findProjectByValue,
|
||||||
findProjectByIdentifier,
|
findProjectByIdentifier,
|
||||||
findTask,
|
findTask,
|
||||||
groupedProjectOptions
|
defaultTask,
|
||||||
|
groupedProjectOptions,
|
||||||
|
extractAndSetTag,
|
||||||
|
formatDuration,
|
||||||
} from "../../src/js/utils"
|
} from "../../src/js/utils"
|
||||||
import { map } from "lodash/fp"
|
import { map, compose } from "lodash/fp"
|
||||||
|
|
||||||
|
const getProjectBy = finder => key =>
|
||||||
|
compose(
|
||||||
|
finder(key),
|
||||||
|
groupedProjectOptions,
|
||||||
|
)(projects)
|
||||||
|
|
||||||
|
const getProjectByValue = getProjectBy(findProjectByValue)
|
||||||
|
const getProjectByIdentifier = getProjectBy(findProjectByIdentifier)
|
||||||
|
|
||||||
describe("utils", () => {
|
describe("utils", () => {
|
||||||
describe("findProjectByValue", () => {
|
describe("findProjectByValue", () => {
|
||||||
it("finds an existing project", () => {
|
it("finds an existing project", () => {
|
||||||
const options = groupedProjectOptions(projects)
|
const project = getProjectByValue(944837106)
|
||||||
const project = findProjectByValue(944837106)(options)
|
|
||||||
expect(project.value).toEqual(944837106)
|
expect(project.value).toEqual(944837106)
|
||||||
expect(project.label).toEqual("Support")
|
expect(project.label).toEqual("Support")
|
||||||
expect(project.customerName).toEqual("MOCO APP")
|
expect(project.customerName).toEqual("MOCO APP")
|
||||||
@@ -19,14 +30,12 @@ describe("utils", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("returns undefined if project is not found", () => {
|
it("returns undefined if project is not found", () => {
|
||||||
const options = groupedProjectOptions(projects)
|
const project = getProjectByValue(123)
|
||||||
const project = findProjectByValue(123)(options)
|
|
||||||
expect(project).toBe(undefined)
|
expect(project).toBe(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns undefined for undefined id", () => {
|
it("returns undefined for undefined id", () => {
|
||||||
const options = groupedProjectOptions(projects)
|
const project = getProjectByValue(undefined)
|
||||||
const project = findProjectByValue(undefined)(options)
|
|
||||||
expect(project).toBe(undefined)
|
expect(project).toBe(undefined)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -56,16 +65,14 @@ describe("utils", () => {
|
|||||||
|
|
||||||
describe("findTask", () => {
|
describe("findTask", () => {
|
||||||
it("find an existing task", () => {
|
it("find an existing task", () => {
|
||||||
const options = groupedProjectOptions(projects)
|
const project = getProjectByValue(944837106)
|
||||||
const project = findProjectByValue(944837106)(options)
|
|
||||||
const task = findTask(2506050)(project)
|
const task = findTask(2506050)(project)
|
||||||
expect(task.value).toEqual(2506050)
|
expect(task.value).toEqual(2506050)
|
||||||
expect(task.label).toEqual("(Calls)")
|
expect(task.label).toEqual("(Calls)")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns undefined if task is not found", () => {
|
it("returns undefined if task is not found", () => {
|
||||||
const options = groupedProjectOptions(projects)
|
const project = getProjectByValue(944837106)
|
||||||
const project = findProjectByValue(944837106)(options)
|
|
||||||
const task = findTask(123)(project)
|
const task = findTask(123)(project)
|
||||||
expect(task).toBe(undefined)
|
expect(task).toBe(undefined)
|
||||||
})
|
})
|
||||||
@@ -76,14 +83,81 @@ describe("utils", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("defaultTask", () => {
|
||||||
|
it("find a default task", () => {
|
||||||
|
const project = getProjectByValue(944837106)
|
||||||
|
const task = defaultTask(project.tasks)
|
||||||
|
expect(task.label).toBe("(Demos)")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns first task if no default is defined", () => {
|
||||||
|
const project = getProjectByValue(944621413)
|
||||||
|
const task = defaultTask(project.tasks)
|
||||||
|
expect(task.label).toBe("Entwicklung")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("return undefined if no tasks given", () => {
|
||||||
|
let task = defaultTask(null)
|
||||||
|
expect(task).toBeUndefined()
|
||||||
|
|
||||||
|
task = defaultTask([])
|
||||||
|
expect(task).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("groupedProjectOptions", () => {
|
describe("groupedProjectOptions", () => {
|
||||||
it("transforms projects into grouped options by company", () => {
|
it("transforms projects into grouped options by company", () => {
|
||||||
const result = groupedProjectOptions(projects)
|
const result = groupedProjectOptions(projects)
|
||||||
expect(map("label", result)).toEqual([
|
expect(map("label", result)).toEqual(["Simplificator", "MOCO APP", "sharoo"])
|
||||||
"Simplificator",
|
})
|
||||||
"MOCO APP",
|
})
|
||||||
"sharoo"
|
|
||||||
])
|
describe("extractAndSetTag", () => {
|
||||||
|
it("sets the correct tag and updates description", () => {
|
||||||
|
const changeset = {
|
||||||
|
description: "#meeting Lorem ipsum",
|
||||||
|
tag: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(extractAndSetTag(changeset)).toEqual({
|
||||||
|
description: "Lorem ipsum",
|
||||||
|
tag: "meeting",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("only matches tag at the beginning", () => {
|
||||||
|
const changeset = {
|
||||||
|
description: "Lorem #meeting ipsum",
|
||||||
|
tag: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(extractAndSetTag(changeset)).toEqual(changeset)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the changeset if not tag is set", () => {
|
||||||
|
const changeset = {
|
||||||
|
description: "Without tag",
|
||||||
|
tag: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(extractAndSetTag(changeset)).toEqual(changeset)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("formatDuration", () => {
|
||||||
|
it("format with defaults", () => {
|
||||||
|
expect(formatDuration(3600)).toBe("1:00:00")
|
||||||
|
expect(formatDuration(3661)).toBe("1:01:01")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("format without seconds", () => {
|
||||||
|
expect(formatDuration(3600, { showSeconds: false })).toBe("1:00")
|
||||||
|
expect(formatDuration(3661, { showSeconds: false })).toBe("1:01")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("format in decimals", () => {
|
||||||
|
expect(formatDuration(3600, { settingTimeTrackingHHMM: false })).toBe("1.00")
|
||||||
|
expect(formatDuration(3661, { settingTimeTrackingHHMM: false })).toBe("1.02")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,21 +6,19 @@ describe("utils", () => {
|
|||||||
let matcher
|
let matcher
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
matcher = createMatcher(remoteServices)
|
matcher = createMatcher(remoteServices, {})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createMatcher", () => {
|
describe("createMatcher", () => {
|
||||||
it("matches host and path", () => {
|
it("matches host and path", () => {
|
||||||
const service = matcher(
|
const service = matcher("https://github.com/hundertzehn/mocoapp/pull/123")
|
||||||
"https://github.com/hundertzehn/mocoapp/pull/123"
|
|
||||||
)
|
|
||||||
expect(service.key).toEqual("github-pr")
|
expect(service.key).toEqual("github-pr")
|
||||||
expect(service.name).toEqual("github")
|
expect(service.name).toEqual("github")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("matches query string", () => {
|
it("matches query string", () => {
|
||||||
let service = matcher(
|
let service = matcher(
|
||||||
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail&selectedIssue=TEST1-1"
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail&selectedIssue=TEST1-1",
|
||||||
)
|
)
|
||||||
expect(service.key).toEqual("jira")
|
expect(service.key).toEqual("jira")
|
||||||
expect(service.name).toEqual("jira")
|
expect(service.name).toEqual("jira")
|
||||||
@@ -32,7 +30,7 @@ describe("utils", () => {
|
|||||||
expect(service.match.id).toEqual("TEST1-1")
|
expect(service.match.id).toEqual("TEST1-1")
|
||||||
|
|
||||||
service = matcher(
|
service = matcher(
|
||||||
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail"
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail",
|
||||||
)
|
)
|
||||||
expect(service.key).toEqual("jira")
|
expect(service.key).toEqual("jira")
|
||||||
expect(service.name).toEqual("jira")
|
expect(service.name).toEqual("jira")
|
||||||
@@ -44,7 +42,7 @@ describe("utils", () => {
|
|||||||
expect(service.match.id).toBeUndefined()
|
expect(service.match.id).toBeUndefined()
|
||||||
|
|
||||||
service = matcher(
|
service = matcher(
|
||||||
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail&selectedIssue="
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&projectKey=TEST1&modal=detail&selectedIssue=",
|
||||||
)
|
)
|
||||||
expect(service.key).toEqual("jira")
|
expect(service.key).toEqual("jira")
|
||||||
expect(service.name).toEqual("jira")
|
expect(service.name).toEqual("jira")
|
||||||
@@ -55,9 +53,7 @@ describe("utils", () => {
|
|||||||
expect(service.match.projectId).toEqual("TEST1")
|
expect(service.match.projectId).toEqual("TEST1")
|
||||||
expect(service.match.id).toEqual("")
|
expect(service.match.id).toEqual("")
|
||||||
|
|
||||||
service = matcher(
|
service = matcher("https://moco-bx.atlassian.net/secure/RapidBoard.jspa")
|
||||||
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa"
|
|
||||||
)
|
|
||||||
expect(service.key).toEqual("jira")
|
expect(service.key).toEqual("jira")
|
||||||
expect(service.name).toEqual("jira")
|
expect(service.name).toEqual("jira")
|
||||||
expect(service.match.org).toEqual("moco-bx")
|
expect(service.match.org).toEqual("moco-bx")
|
||||||
@@ -65,7 +61,7 @@ describe("utils", () => {
|
|||||||
expect(service.match.id).toBeUndefined()
|
expect(service.match.id).toBeUndefined()
|
||||||
|
|
||||||
service = matcher(
|
service = matcher(
|
||||||
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&modal=detail&selectedIssue=TEST2-1"
|
"https://moco-bx.atlassian.net/secure/RapidBoard.jspa?rapidView=2&modal=detail&selectedIssue=TEST2-1",
|
||||||
)
|
)
|
||||||
expect(service.key).toEqual("jira")
|
expect(service.key).toEqual("jira")
|
||||||
expect(service.name).toEqual("jira")
|
expect(service.name).toEqual("jira")
|
||||||
@@ -78,29 +74,84 @@ describe("utils", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("matches url with hash", () => {
|
it("matches url with hash", () => {
|
||||||
let service = matcher(
|
let service = matcher("https://www.wunderlist.com/webapp#/tasks/4771178545")
|
||||||
"https://www.wunderlist.com/webapp#/tasks/4771178545"
|
|
||||||
)
|
|
||||||
expect(service.key).toEqual("wunderlist")
|
expect(service.key).toEqual("wunderlist")
|
||||||
expect(service.name).toEqual("wunderlist")
|
expect(service.name).toEqual("wunderlist")
|
||||||
expect(service.match.id).toEqual("4771178545")
|
expect(service.match.id).toEqual("4771178545")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("does not match different host", () => {
|
it("does not match different host", () => {
|
||||||
const service = matcher(
|
const service = matcher("https://trello.com/hundertzehn/mocoapp/pull/123")
|
||||||
"https://trello.com/hundertzehn/mocoapp/pull/123"
|
|
||||||
)
|
|
||||||
expect(service).toBeFalsy()
|
expect(service).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("matches query string in the hash", () => {
|
||||||
|
const service = matcher(
|
||||||
|
"https://www.wrike.com/workspace.htm?acc=2771711#path=folder&id=342769537&p=342762920&a=2771711&c=board&ot=342769562&so=10&bso=10&sd=0&st=space-342762920",
|
||||||
|
)
|
||||||
|
expect(service.key).toEqual("wrike")
|
||||||
|
expect(service.name).toEqual("wrike")
|
||||||
|
expect(service.id).toEqual("342769562")
|
||||||
|
expect(service.match.id).toEqual("342769562")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches query parameter with different names", () => {
|
||||||
|
expect(
|
||||||
|
matcher(
|
||||||
|
"https://www.wrike.com/workspace.htm?acc=2771711#path=mywork&id=342769537&p=342762920&a=2771711&c=board&ot=1234&so=10&bso=10&sd=0&st=space-342762920",
|
||||||
|
).id,
|
||||||
|
).toEqual("1234")
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matcher(
|
||||||
|
"https://www.wrike.com/workspace.htm?acc=2771711#path=folder&id=342769537&p=342762920&a=2771711&c=board&t=1234&so=10&bso=10&sd=0&st=space-342762920",
|
||||||
|
).id,
|
||||||
|
).toEqual("1234")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should match gitlab-mergerequest url", () => {
|
||||||
|
const service = matcher(
|
||||||
|
"https://gitlab.com/testorganisatzion/testproject/-/merge_requests/1",
|
||||||
|
)
|
||||||
|
expect(service.id).toEqual("1")
|
||||||
|
expect(service.match.id).toEqual("1")
|
||||||
|
expect(service.name).toEqual("gitlab")
|
||||||
|
expect(service.projectId).toEqual("testproject")
|
||||||
|
})
|
||||||
|
it("should match gitlab-mergerequest url with group", () => {
|
||||||
|
const service = matcher(
|
||||||
|
"https://gitlab.com/testorganisatzion/test-group/testproject/-/merge_requests/1",
|
||||||
|
)
|
||||||
|
expect(service.id).toEqual("1")
|
||||||
|
expect(service.match.id).toEqual("1")
|
||||||
|
expect(service.name).toEqual("gitlab")
|
||||||
|
expect(service.projectId).toEqual("testproject")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should match gitlab-issue url", () => {
|
||||||
|
const service = matcher("https://gitlab.com/testorganisatzion/testproject/-/issues/1")
|
||||||
|
expect(service.id).toEqual("1")
|
||||||
|
expect(service.match.id).toEqual("1")
|
||||||
|
expect(service.name).toEqual("gitlab")
|
||||||
|
expect(service.projectId).toEqual("testproject")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should match gitlab-issue url with group", () => {
|
||||||
|
const service = matcher(
|
||||||
|
"https://gitlab.com/testorganisatzion/test-group/testproject/-/issues/1",
|
||||||
|
)
|
||||||
|
expect(service.id).toEqual("1")
|
||||||
|
expect(service.match.id).toEqual("1")
|
||||||
|
expect(service.name).toEqual("gitlab")
|
||||||
|
expect(service.projectId).toEqual("testproject")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createEnhancer", () => {
|
describe("createEnhancer", () => {
|
||||||
it("enhances a services", () => {
|
it("enhances a services", () => {
|
||||||
const url = "https://github.com/hundertzehn/mocoapp/pull/123"
|
const url = "https://github.com/hundertzehn/mocoapp/pull/123"
|
||||||
const document = {
|
const document = {
|
||||||
querySelector: jest
|
querySelector: jest.fn().mockReturnValue({ textContent: "[4321] Foo" }),
|
||||||
.fn()
|
|
||||||
.mockReturnValue({ textContent: "[4321] Foo" })
|
|
||||||
}
|
}
|
||||||
const service = matcher(url)
|
const service = matcher(url)
|
||||||
const enhancedService = createEnhancer(document)(service)
|
const enhancedService = createEnhancer(document)(service)
|
||||||
@@ -111,4 +162,27 @@ describe("utils", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("urlMatcher with overrideHosts", () => {
|
||||||
|
let matcher
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
matcher = createMatcher(remoteServices, {
|
||||||
|
github: "https://my-custom-github-url.com",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createMatcher", () => {
|
||||||
|
it("matches overridden host and path", () => {
|
||||||
|
const service = matcher("https://my-custom-github-url.com/hundertzehn/mocoapp/pull/123")
|
||||||
|
expect(service.key).toEqual("github-pr")
|
||||||
|
expect(service.name).toEqual("github")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't match default host and path", () => {
|
||||||
|
const service = matcher("https://github.com/hundertzehn/mocoapp/pull/123")
|
||||||
|
expect(service).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
|
require("dotenv").config()
|
||||||
|
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const webpack = require("webpack")
|
const webpack = require("webpack")
|
||||||
const CleanWebpackPlugin = require("clean-webpack-plugin")
|
const { CleanWebpackPlugin } = require("clean-webpack-plugin")
|
||||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
|
||||||
const HtmlWebpackPlugin = require("html-webpack-plugin")
|
const HtmlWebpackPlugin = require("html-webpack-plugin")
|
||||||
const RemoveSourceMapPlugin = require("./webpack/RemoveSourceMapPlugin")
|
|
||||||
const ZipPlugin = require("zip-webpack-plugin")
|
const ZipPlugin = require("zip-webpack-plugin")
|
||||||
const {
|
|
||||||
BugsnagBuildReporterPlugin,
|
|
||||||
BugsnagSourceMapUploaderPlugin
|
|
||||||
} = require("webpack-bugsnag-plugins")
|
|
||||||
|
|
||||||
module.exports = env => {
|
module.exports = (env) => {
|
||||||
const config = {
|
const config = {
|
||||||
entry: {
|
entry: {
|
||||||
background: "./src/js/background.js",
|
background: "./src/js/background.js",
|
||||||
content: "./src/js/content.js",
|
content: "./src/js/content.js",
|
||||||
popup: "./src/js/popup.js",
|
popup: "./src/js/popup.js",
|
||||||
options: "./src/js/options.js"
|
options: "./src/js/options.js",
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
path: path.join(__dirname, `build/${env.browser}`),
|
path: path.join(__dirname, `build/${env.browser}`),
|
||||||
filename: `[name].${process.env.npm_package_version}.js`
|
filename: `[name].${process.env.npm_package_version}.js`,
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
@@ -28,96 +25,85 @@ module.exports = env => {
|
|||||||
test: /\.scss$/,
|
test: /\.scss$/,
|
||||||
use: [
|
use: [
|
||||||
{
|
{
|
||||||
loader: MiniCssExtractPlugin.loader
|
loader: MiniCssExtractPlugin.loader,
|
||||||
},
|
},
|
||||||
"css-loader",
|
"css-loader",
|
||||||
{
|
{
|
||||||
loader: "sass-loader",
|
loader: "sass-loader",
|
||||||
options: {
|
options: {
|
||||||
includePaths: [path.join(__dirname, "src/css")]
|
sassOptions: {
|
||||||
}
|
includePaths: [path.join(__dirname, "src/css")],
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exclude: /node_modules/
|
exclude: /node_modules/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
use: {
|
use: {
|
||||||
loader: "babel-loader"
|
loader: "babel-loader",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.(jpg|png)$/,
|
test: /\.(jpg|png)$/,
|
||||||
loader: "file-loader",
|
loader: "file-loader",
|
||||||
options: {
|
options: {
|
||||||
name: "[path][name].[ext]"
|
name: "[path][name].[ext]",
|
||||||
},
|
},
|
||||||
exclude: /node_modules/
|
exclude: /node_modules/,
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
|
test: /\.svg$/,
|
||||||
|
loader: "svg-inline-loader",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CleanWebpackPlugin([`build/${env.browser}`]),
|
new CleanWebpackPlugin({
|
||||||
|
cleanAfterEveryBuildPatterns: ["!manifest.json", "!*.html"],
|
||||||
|
}),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
"process.env.NODE_ENV": JSON.stringify(env.NODE_ENV)
|
"process.env.NODE_ENV": JSON.stringify(env.NODE_ENV),
|
||||||
}),
|
}),
|
||||||
new MiniCssExtractPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: "[name].css",
|
filename: "[name].css",
|
||||||
chunkFilename: "[id].css"
|
chunkFilename: "[id].css",
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: path.join(__dirname, "src", "background.html"),
|
template: path.join(__dirname, "src", "background.html"),
|
||||||
filename: "background.html",
|
filename: path.resolve(`build/${env.browser}`, "background.html"),
|
||||||
chunks: ["background"]
|
chunks: ["background"],
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: path.join(__dirname, "src", "popup.html"),
|
template: path.join(__dirname, "src", "popup.html"),
|
||||||
filename: "popup.html",
|
filename: path.resolve(`build/${env.browser}`, "popup.html"),
|
||||||
chunks: ["popup"]
|
chunks: ["popup"],
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: path.join(__dirname, "src", "options.html"),
|
template: path.join(__dirname, "src", "options.html"),
|
||||||
filename: "options.html",
|
filename: path.resolve(`build/${env.browser}`, "options.html"),
|
||||||
chunks: ["options"]
|
chunks: ["options"],
|
||||||
})
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
modules: [path.join(__dirname, "src/js"), "node_modules"],
|
modules: [path.join(__dirname, "src/js"), "node_modules"],
|
||||||
alias: {
|
alias: {
|
||||||
images: path.join(__dirname, "src/images")
|
images: path.join(__dirname, "src/images"),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mode: env.NODE_ENV || "development",
|
mode: env.NODE_ENV || "development",
|
||||||
devtool: "cheap-module-source-map"
|
devtool: "cheap-module-source-map",
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.NODE_ENV === "production") {
|
if (env.NODE_ENV === "production") {
|
||||||
config.devtool = "source-maps"
|
config.devtool = "none"
|
||||||
|
|
||||||
config.plugins.push(
|
config.plugins.push(
|
||||||
new BugsnagBuildReporterPlugin({
|
|
||||||
apiKey: "da6caac4af70af3e4683454b40fe5ef5",
|
|
||||||
appVersion: process.env.npm_package_version,
|
|
||||||
releaseStage: "production"
|
|
||||||
}),
|
|
||||||
// important: upload sourcemaps before removing source mapping url
|
|
||||||
new BugsnagSourceMapUploaderPlugin({
|
|
||||||
apiKey: "da6caac4af70af3e4683454b40fe5ef5",
|
|
||||||
appVersion: process.env.npm_package_version,
|
|
||||||
publicPath:
|
|
||||||
env.browser === "firefox"
|
|
||||||
? "moz-extension*://*/"
|
|
||||||
: "chrome-extension*://*/", // extra asterisk after protocol needed
|
|
||||||
overwrite: true
|
|
||||||
}),
|
|
||||||
new RemoveSourceMapPlugin(),
|
|
||||||
new ZipPlugin({
|
new ZipPlugin({
|
||||||
filename: `moco-bx-${env.browser}-v${
|
filename: `moco-bx-${env.browser}-v${process.env.npm_package_version}.zip`,
|
||||||
process.env.npm_package_version
|
exclude: [/\.map$/],
|
||||||
}.zip`,
|
}),
|
||||||
exclude: [/\.map$/]
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,39 +3,37 @@ const { compact } = require("lodash/fp")
|
|||||||
|
|
||||||
const baseConfig = require("./webpack.base.config")
|
const baseConfig = require("./webpack.base.config")
|
||||||
|
|
||||||
module.exports = env => {
|
module.exports = (env) => {
|
||||||
const config = baseConfig(env)
|
const config = baseConfig(env)
|
||||||
|
|
||||||
config.plugins.unshift(
|
config.plugins.unshift(
|
||||||
new CopyWebpackPlugin([
|
new CopyWebpackPlugin({
|
||||||
{
|
patterns: [
|
||||||
from: "src/manifest.json",
|
{
|
||||||
transform: function(content, _path) {
|
from: "src/manifest.json",
|
||||||
const manifest = JSON.parse(
|
transform: function (content, _path) {
|
||||||
content
|
const manifest = JSON.parse(
|
||||||
.toString()
|
content.toString().replace(/\[version\]/g, process.env.npm_package_version),
|
||||||
.replace(/\[version\]/g, process.env.npm_package_version)
|
)
|
||||||
)
|
return Buffer.from(
|
||||||
return Buffer.from(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
...manifest,
|
||||||
...manifest,
|
permissions: compact([
|
||||||
permissions: compact([
|
...manifest.permissions,
|
||||||
...manifest.permissions,
|
env.NODE_ENV === "development" ? "http://*.mocoapp.localhost/*" : null,
|
||||||
env.NODE_ENV === "development"
|
]),
|
||||||
? "http://*.mocoapp.localhost/*"
|
options_ui: {
|
||||||
: null
|
...manifest.options_ui,
|
||||||
]),
|
chrome_style: true,
|
||||||
options_ui: {
|
},
|
||||||
...manifest.options_ui,
|
description: process.env.npm_package_description,
|
||||||
chrome_style: true
|
version: process.env.npm_package_version,
|
||||||
},
|
}),
|
||||||
description: process.env.npm_package_description,
|
)
|
||||||
version: process.env.npm_package_version
|
},
|
||||||
})
|
},
|
||||||
)
|
],
|
||||||
}
|
}),
|
||||||
}
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,44 +1,45 @@
|
|||||||
|
const { v4: uuidv4 } = require("uuid")
|
||||||
const CopyWebpackPlugin = require("copy-webpack-plugin")
|
const CopyWebpackPlugin = require("copy-webpack-plugin")
|
||||||
const { compact } = require("lodash/fp")
|
const { compact } = require("lodash/fp")
|
||||||
|
|
||||||
const baseConfig = require("./webpack.base.config")
|
const baseConfig = require("./webpack.base.config")
|
||||||
|
|
||||||
module.exports = env => {
|
module.exports = (env) => {
|
||||||
const config = baseConfig(env)
|
const config = baseConfig(env)
|
||||||
|
|
||||||
config.plugins.unshift(
|
config.plugins.unshift(
|
||||||
new CopyWebpackPlugin([
|
new CopyWebpackPlugin({
|
||||||
{
|
patterns: [
|
||||||
from: "src/manifest.json",
|
{
|
||||||
transform: function(content, _path) {
|
from: "src/manifest.json",
|
||||||
const manifest = JSON.parse(
|
transform: function (content, _path) {
|
||||||
content
|
const manifest = JSON.parse(
|
||||||
.toString()
|
content.toString().replace(/\[version\]/g, process.env.npm_package_version),
|
||||||
.replace(/\[version\]/g, process.env.npm_package_version)
|
)
|
||||||
)
|
return Buffer.from(
|
||||||
return Buffer.from(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
...manifest,
|
||||||
...manifest,
|
permissions: compact([
|
||||||
permissions: compact([
|
...manifest.permissions,
|
||||||
...manifest.permissions,
|
env.NODE_ENV === "development" ? "http://*.mocoapp.localhost/*" : null,
|
||||||
env.NODE_ENV === "development"
|
]),
|
||||||
? "http://*.mocoapp.localhost/*"
|
options_ui: {
|
||||||
: null
|
...manifest.options_ui,
|
||||||
]),
|
browser_style: true,
|
||||||
options_ui: {
|
},
|
||||||
...manifest.options_ui,
|
browser_specific_settings: {
|
||||||
browser_style: true
|
gecko: {
|
||||||
},
|
id: process.env.APPLICATION_ID || `{${uuidv4()}}`,
|
||||||
browser_specific_settings: {
|
},
|
||||||
gecko: { id: "browser-extension@mocoapp.com" }
|
},
|
||||||
},
|
description: process.env.npm_package_description,
|
||||||
description: process.env.npm_package_description,
|
version: process.env.npm_package_version,
|
||||||
version: process.env.npm_package_version
|
}),
|
||||||
})
|
)
|
||||||
)
|
},
|
||||||
}
|
},
|
||||||
}
|
],
|
||||||
])
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user