diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..1a131b9 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["@babel/preset-env", "@babel/preset-react"], + "plugins": [ + ["@babel/plugin-proposal-decorators", { "legacy": true }], + ["@babel/plugin-proposal-class-properties", { "loose": true }], + ["@babel/plugin-proposal-optional-chaining"] + ] +} diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..c17f54b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,73 @@ +{ + "extends": ["eslint:recommended", "plugin:react/recommended"], + "env": { + "browser": true, + "commonjs": true, + "es6": true, + "node": true, + "jest/globals": true + }, + "globals": { + "chrome": false, + "browser": false + }, + "plugins": ["jest"], + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 7, + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "experimentalDecorators": true, + "jsx": true + }, + "sourceType": "module" + }, + "rules": { + "strict": 0, + "semi": ["error", "never"], + "array-callback-return": "warn", + "getter-return": "warn", + "no-const-assign": "warn", + "no-this-before-super": "warn", + "no-undef": "warn", + "no-unreachable": "warn", + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "constructor-super": "warn", + "valid-typeof": "warn", + "jest/no-disabled-tests": "warn", + "jest/no-focused-tests": "error", + "jest/no-identical-title": "error", + "jest/prefer-to-have-length": "warn", + "jest/valid-expect": "error", + "react/prop-types": [ + "error", + { + "ignore": [ + "activityStore", + "commentStore", + "contactStore", + "dealStore", + "holidayStore", + "scheduleStore", + "presenceStore", + "employmentStore", + "match", + "permission", + "presenceStore", + "projectStore", + "purchaseBudgetStore", + "purchaseCategoryStore", + "purchasePaymentStore", + "userStore", + "vacationDailyHoursOverrideStore", + "purchaseStore", + "routing", + "scheduleStore", + "tagStore", + "taskTemplateStore", + "userStore" + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b38db2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..69045fb --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +mocoapp-browser-extension +========================= + +Documentation +------------- + +* https://checklyhq.com/blog/2018/08/creating-a-chrome-extension-in-2018-the-good-the-bad-and-the-meh/ +* https://developer.chrome.com/extensions +* https://developer.chrome.com/extensions/api_index + +Development +----------- + +* run `yarn` +* run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`) +* load extension into browser: + * Chrome: visit `chrome://extensions` and load unpacked extension from `build/chrome` + * Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox` +* reload browser extension after change + +Release +------- + +* bump version in `package.json` +* run `yarn build` +* upload Chrome and Firefox extensions in `build/chrome` and `build/firefox` respectively diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..4ba52ba --- /dev/null +++ b/jest.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/manifest.json b/manifest.json deleted file mode 100644 index 9539978..0000000 --- a/manifest.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "MOCO Zeiterfassung", - "short_name": "MOCO", - "description": "MOCO Zeiterfassung Plugin", - "version": "0.9.20", - "manifest_version": 2, - "description" : "MOCO Time Tracking Plugin", - "icons": { "16": "src/images/logo.png", - "48": "src/images/logo.png", - "128": "src/images/logo.png" }, - "options_ui": { - "page": "options.html", - "chrome_style": true - }, - "permissions": [ - "https://*.mocoapp.com/*", - "background", - "storage", - "tabs" - ], - "optional_permissions": [ - "*://*/" - ], - "background": { - "scripts": [ - "node_modules/jquery/dist/jquery.min.js", - "node_modules/select2/select2.js", - "background.min.js" - ] - }, - "browser_action": { - "default_icon": "src/images/logo.png", - "default_title": "MOCO Time Tracking", - "default_popup": "popup.html" - }, - "web_accessible_resources": [ - "src/images/*" - ], - "commands": { - "_execute_browser_action": { - "suggested_key": { - "default": "Ctrl+M", - "mac": "Command+M" - } - } - } -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e562130 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "moco-browser-extensions", + "description": "Browser plugin for MOCO", + "version": "1.0.18", + "scripts": { + "start": "yarn start:chrome", + "start:chrome": "node_modules/.bin/webpack --config webpack.chrome.config.js --watch --env.browser chrome --env.NODE_ENV development", + "start:firefox": "node_modules/.bin/webpack --config webpack.firefox.config.js --watch --env.browser firefox --env.NODE_ENV development", + "build:chrome": "node_modules/.bin/webpack -p --config webpack.chrome.config.js --env.browser chrome --env.NODE_ENV production", + "build:firefox": "node_modules/.bin/webpack -p --config webpack.firefox.config.js --env.browser firefox --env.NODE_ENV production", + "build": "yarn run build:firefox && yarn run build:chrome", + "test": "node_modules/.bin/jest", + "test:watch": "node_modules/.bin/jest --watch", + "release": "copyfiles main.css main.min.js background.min.js manifest.json popup.html options.html node_modules/jquery/dist/jquery.min.js node_modules/select2/select2.js src/images/* release" + }, + "dependencies": { + "@bugsnag/js": "^5.2.0", + "@bugsnag/plugin-react": "^5.2.0", + "axios": "^0.18.0", + "classnames": "^2.2.6", + "date-fns": "^1.30.1", + "lodash": "^4.17.11", + "mobx": "^5.5.0", + "mobx-react": "^5.2.8", + "prop-types": "^15.6.2", + "query-string": "^6.2.0", + "react": "^16.8.0", + "react-dom": "^16.8.0", + "react-select": "^2.3.0", + "react-spring": "^8.0.7", + "url-pattern": "^1.0.3" + }, + "devDependencies": { + "@babel/core": "^7.2.2", + "@babel/plugin-proposal-class-properties": "^7.2.2", + "@babel/plugin-proposal-decorators": "^7.2.2", + "@babel/plugin-proposal-optional-chaining": "^7.2.0", + "@babel/preset-env": "^7.2.2", + "@babel/preset-react": "^7.0.0", + "babel-eslint": "^10.0.1", + "babel-loader": "^8.0.4", + "babel-plugin-module-resolver": "^3.1.1", + "clean-webpack-plugin": "^1.0.1", + "copy-webpack-plugin": "^4.6.0", + "copyfiles": "^2.1.0", + "css-loader": "^2.1.0", + "eslint": "^5.7.0", + "eslint-plugin-jest": "^22.2.2", + "eslint-plugin-react": "^7.11.1", + "file-loader": "^3.0.1", + "html-webpack-plugin": "^3.2.0", + "jest": "^24.1.0", + "mini-css-extract-plugin": "^0.5.0", + "node-sass": "^4.11.0", + "prettier": "^1.16.4", + "sass-loader": "^7.1.0", + "style-loader": "^0.23.1", + "webpack": "^4.15.0", + "webpack-bugsnag-plugins": "^1.3.0", + "webpack-cli": "^3.0.8", + "zip-webpack-plugin": "^3.0.0" + } +} diff --git a/src/background.html b/src/background.html new file mode 100644 index 0000000..0915a77 --- /dev/null +++ b/src/background.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/css/_button.scss b/src/css/_button.scss new file mode 100644 index 0000000..33ec648 --- /dev/null +++ b/src/css/_button.scss @@ -0,0 +1,42 @@ +button.moco-bx-btn { + display: block; + padding: 8px 12px; + margin: 0 auto; + font-weight: normal; + text-align: center; + white-space: nowrap; + color: white; + background-image: none; + background-color: #7dc332; + border-color: #7dc332; + border-radius: 0; + border-style: solid; + box-shadow: none; + font-size: 100%; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: #639a28; + border-color: #639a28; + } + + &:disabled { + opacity: 0.65; + cursor: default; + } + + &.secondary { + color: black; + background-color: #fff; + border-color: #ccc; + + &:hover { + background-color: #f4f4f4; + border-color: #ccc; + } + } + + & + button { + margin-left: 0.5rem; + } +} diff --git a/src/css/_form.scss b/src/css/_form.scss new file mode 100644 index 0000000..233df13 --- /dev/null +++ b/src/css/_form.scss @@ -0,0 +1,91 @@ +@import "button"; + +input { + border-radius: 0; + height: 20px; +} + +.form-group { + width: 100%; + margin: 1rem 0; + + label { + display: block; + font-weight: bold; + margin-bottom: 0.25rem; + } + + input, textarea { + padding: 6px 12px; + background-color: white; + border-color: #cccccc; + width: 100%; + font-size: 100%; + border-style: solid; + border-width: 1px; + min-height: 20px; + } + + .text-muted { + color: #999; + } + + &.has-error { + input, textarea { + border-color: #FB3A2F; + } + } + + .form-error { + color: #FB3A2F; + } + + .input-group { + display: flex; + flex-flow: row nowrap; + + input { + flex: 1 1; + } + + .input-group-addon { + flex: 0 0 auto; + padding: 6px 12px; + font-weight: normal; + color: #555555; + text-align: center; + background-color: #eeeeee; + border: 1px solid #cccccc; + border-left: none; + line-height: 18px; + } + } +} + +.moco-bx-select + .moco-bx-select { + margin-top: 3px; +} + +input[name="hours"] { + width: 48px; + outline: 0 !important; + + &:focus { + border: 1px solid #38b5eb; + box-shadow: 0 0 0 1px #38b5eb; + } +} + +textarea[name="description"] { + font-family: inherit; + box-sizing: border-box; + width: 100%; + resize: none; + outline: 0 !important; + + &:focus { + border: 1px solid #38b5eb; + box-shadow: 0 0 0 1px #38b5eb; + } +} + diff --git a/src/css/_reset.scss b/src/css/_reset.scss new file mode 100644 index 0000000..7e126c4 --- /dev/null +++ b/src/css/_reset.scss @@ -0,0 +1,43 @@ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/src/css/_spinner.scss b/src/css/_spinner.scss new file mode 100644 index 0000000..9270bb0 --- /dev/null +++ b/src/css/_spinner.scss @@ -0,0 +1,24 @@ +#moco-bx-root { + @keyframes moco-bx-spinner { + to { + transform: rotate(360deg); + } + } + + .moco-bx-spinner__container { + width: 100%; + margin-top: 120px; + + .moco-bx-spinner { + display: block; + width: 2rem; + height: 2rem; + vertical-align: text-bottom; + border: 2px solid #999; + border-right-color: transparent; + border-radius: 50%; + margin: 0 auto; + animation: moco-bx-spinner .75s linear infinite; + } + } +} diff --git a/src/css/_variables.scss b/src/css/_variables.scss new file mode 100644 index 0000000..d5b7a37 --- /dev/null +++ b/src/css/_variables.scss @@ -0,0 +1,5 @@ +$font-family: Arial, sans-serif; +$font-color: #191919; +$popup-width: 420px; +$popup-height: 463px; + diff --git a/src/css/content.scss b/src/css/content.scss new file mode 100644 index 0000000..de3b2cd --- /dev/null +++ b/src/css/content.scss @@ -0,0 +1,77 @@ +@import "variables"; +@import "button"; + +#moco-bx-root { + font-family: $font-family; + color: $font-color; + + .moco-bx-bubble { + box-sizing: content-box; + position: fixed; + bottom: 2rem; + height: 60px; + width: 60px; + background-color: white; + border-radius: 50%; + box-shadow: -1px -1px 15px 4px rgba(0, 0, 0, 0.05), + 2px 2px 15px 4px rgba(0, 0, 0, 0.05); + padding: 5px; + z-index: 9999; + + cursor: pointer; + + .moco-bx-bubble-inner { + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + height: 100%; + + img.moco-bx-logo { + width: 30px; + height: 30px; + } + + .moco-bx-booked-hours { + display: inline-block; + font-size: 13px; + font-weight: 700; + line-height: 1; + color: black; + text-align: center; + padding: 5px 0 7px; + } + } + } +} + +#moco-bx-popup-root { + font-family: $font-family; + color: $font-color; + + iframe { + border: 0; + } + + h2 { + margin-bottom: 1rem; + } + + .moco-bx-popup { + position: fixed; /* Stay in place */ + z-index: 2000; /* Sit on top */ + padding-top: 100px; /* Location of the box */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0, 0, 0); /* Fallback color */ + background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */ + + .moco-bx-popup-content { + background-color: white; + margin: 0 auto; + } + } +} diff --git a/src/css/options.scss b/src/css/options.scss new file mode 100644 index 0000000..465fde3 --- /dev/null +++ b/src/css/options.scss @@ -0,0 +1,42 @@ +@import "variables"; +@import "reset"; +@import "form"; + +#moco-bx-root { + font-family: $font-family; + color: $font-color; + font-size: 14px; + line-height: 1.5; + + .moco-bx-options { + padding: 0rem 2rem 2rem; + + p { + margin: 0.5rem 0; + } + + h2 { + font-size: 1.5rem; + margin: 1rem 0 2rem; + } + + label { + font-weight: normal; + margin-bottom: 5px; + } + + input { + box-sizing: border-box; + padding: 6px 12px; + height: 32px; + } + + .text-success { + color: #7DC332; + } + + .text-danger { + color: #FB3A2F; + } + } +} diff --git a/src/css/popup.scss b/src/css/popup.scss new file mode 100644 index 0000000..312127b --- /dev/null +++ b/src/css/popup.scss @@ -0,0 +1,128 @@ +@import "form"; +@import "spinner"; +@import "variables"; + +html { + overflow: hidden; + + body { + font-size: 14px; + margin: 0px; + font-family: $font-family; + color: $font-color; + + #moco-bx-root { + min-width: 516px; + + .moco-bx-app-container { + width: 324px; + padding: 3rem 6rem; + + .moco-bx-logo__container { + display: flex; + justify-content: center; + margin-bottom: 3rem; + text-align: center; + + img.moco-bx-logo { + flex: 0 0 48px; + width: 48px; + height: 48px; + + } + + h1 { + line-height: 48px; + margin: 0; + } + } + + .moco-bx-calendar { + display: flex; + justify-content: space-between; + margin-bottom: 3rem; + + .moco-bx-calendar__day { + display: flex; + flex-flow: column nowrap; + align-items: center; + + flex: 0 0 42px; + width: 42px; + cursor: pointer; + + .moco-bx-calendar__day-of-week { + margin-bottom: 5px; + font-size: 12px; + color: hsl(0, 0%, 60%); + } + + .moco-bx-calendar__hours { + display: flex; + justify-content: center; + align-items: center; + width: 42px; + height: 42px; + flex: 0 0 42px; + color: white; + background-color: #eee; + + } + + &.moco-bx-calendar__day--filled { + .moco-bx-calendar__hours { + background-color: #7dc332; + } + } + + &.moco-bx-calendar__day--week-day-6, + &.moco-bx-calendar__day--week-day-0 { + .moco-bx-calendar__hours { + background-color: #bbb; + } + } + + &.moco-bx-calendar__day--active { + .moco-bx-calendar__hours { + background-color: #38b5eb; + } + } + } + } + } + + .moco-bx-error-container { + font-size: 18px; + line-height: 1.5; + width: 420px; + padding: 3rem; + text-align: center; + + h1 { + font-size: 35px; + font-weight: normal; + margin-top: 0; + line-height: 1.3; + } + + img { + width: auto; + max-width: 100%; + + &.moco-bx-logo { + width: 48px; + margin-bottom: 2rem; + } + } + + ol { + text-align: left; + } + + button { + margin-top: 1.5rem; + } + } + } + } +} diff --git a/src/images/error_general.png b/src/images/error_general.png new file mode 100644 index 0000000..f243f13 Binary files /dev/null and b/src/images/error_general.png differ diff --git a/src/images/firefox_addons.png b/src/images/firefox_addons.png new file mode 100644 index 0000000..4cca733 Binary files /dev/null and b/src/images/firefox_addons.png differ diff --git a/src/images/settings.png b/src/images/settings.png new file mode 100644 index 0000000..50bf38a Binary files /dev/null and b/src/images/settings.png differ diff --git a/src/js/api/Client.js b/src/js/api/Client.js new file mode 100644 index 0000000..7545a5b --- /dev/null +++ b/src/js/api/Client.js @@ -0,0 +1,63 @@ +import axios from "axios" +import { formatDate } from "utils" + +const baseURL = subdomain => { + if (process.env.NODE_ENV === "production") { + return `https://${encodeURIComponent( + subdomain + )}.mocoapp.com/api/browser_extensions` + } else { + return `http://${encodeURIComponent( + subdomain + )}.mocoapp.localhost:3001/api/browser_extensions` + } +} + +export default class Client { + #client; + #apiKey; + + constructor({ subdomain, apiKey, version }) { + this.#apiKey = apiKey + this.#client = axios.create({ + responseType: "json", + baseURL: baseURL(subdomain), + headers: { + common: { + "x-api-key": apiKey, + "x-extension-version": version + } + } + }) + } + + login = service => + this.#client.post("session", { + api_key: this.#apiKey, + remote_service: service?.name, + remote_id: service?.id + }); + + projects = () => this.#client.get("projects"); + + schedules = (fromDate, toDate) => + this.#client.get("schedules", { + params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` } + }); + + activities = (fromDate, toDate) => + this.#client.get("activities", { + params: { date: `${formatDate(fromDate)}:${formatDate(toDate)}` } + }); + + bookedHours = service => { + if (!service) { + return Promise.resolve({ data: { hours: 0 } }) + } + return this.#client.get("activities/tags", { + params: { selection: [service.id], remote_service: service.name } + }) + }; + + createActivity = activity => this.#client.post("activities", { activity }); +} diff --git a/src/js/background.js b/src/js/background.js new file mode 100644 index 0000000..84f2df0 --- /dev/null +++ b/src/js/background.js @@ -0,0 +1,128 @@ +import ApiClient from "api/Client" +import { + isChrome, + getCurrentTab, + getSettings, + isBrowserTab +} from "utils/browser" +import { BackgroundMessenger } from "utils/messaging" +import { + tabUpdated, + settingsChanged, + togglePopup +} from "utils/messageHandlers" + +const messenger = new BackgroundMessenger() + +messenger.on("togglePopup", () => { + getCurrentTab().then(tab => { + if (tab && !isBrowserTab(tab)) { + messenger.postMessage(tab, { type: "requestService" }) + messenger.once("newService", ({ payload }) => { + togglePopup(tab, { messenger })(payload) + }) + } + }) +}) + +chrome.runtime.onMessage.addListener(action => { + if (action.type === "closePopup") { + getCurrentTab().then(tab => { + messenger.postMessage(tab, action) + }) + } + + if (action.type === "createActivity") { + const { activity, service } = action.payload + getCurrentTab().then(tab => { + getSettings().then(settings => { + const apiClient = new ApiClient(settings) + apiClient + .createActivity(activity) + .then(() => { + messenger.postMessage(tab, { type: "closePopup" }) + apiClient.bookedHours(service).then(({ data }) => { + messenger.postMessage(tab, { + type: "showBubble", + payload: { + bookedHours: parseFloat(data[0]?.hours) || 0, + service + } + }) + }) + }) + .catch(error => { + if (error.response?.status === 422) { + chrome.runtime.sendMessage({ + type: "setFormErrors", + payload: error.response.data + }) + } + }) + }) + }) + } + + if (action.type === "openOptions") { + let url + if (isChrome()) { + url = `chrome://extensions/?options=${chrome.runtime.id}` + } else { + url = browser.runtime.getURL("options.html") + } + return chrome.tabs.create({ url }) + } + + if (action.type === "openExtensions") { + if (isChrome()) { + chrome.tabs.create({ url: "chrome://extensions" }) + } + } +}) + +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => { + if (areaName === "sync" && (apiKey || subdomain)) { + getSettings().then(settings => settingsChanged(settings, { messenger })) + } + }) +}) + +chrome.runtime.onStartup.addListener(() => { + chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => { + if (areaName === "sync" && (apiKey || subdomain)) { + getSettings().then(settings => settingsChanged(settings, { messenger })) + } + }) +}) + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (!isBrowserTab(tab) && changeInfo.status === "complete") { + getSettings().then(settings => { + tabUpdated(tab, { settings, messenger }) + }) + } +}) + +chrome.tabs.onCreated.addListener(tab => { + if (!isBrowserTab(tab)) { + messenger.connectTab(tab) + } +}) + +chrome.tabs.onRemoved.addListener(messenger.disconnectTab) + +chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => { + if (areaName === "sync" && (apiKey || subdomain)) { + getSettings().then(settings => settingsChanged(settings, { messenger })) + } +}) + +chrome.browserAction.onClicked.addListener(tab => { + if (!isBrowserTab(tab)) { + messenger.postMessage(tab, { type: "requestService" }) + messenger.once("newService", ({ payload }) => { + togglePopup(tab, { messenger })(payload) + }) + } +}) diff --git a/src/js/components/App.js b/src/js/components/App.js new file mode 100644 index 0000000..defae02 --- /dev/null +++ b/src/js/components/App.js @@ -0,0 +1,212 @@ +import React, { Component } from "react" +import PropTypes from "prop-types" +import Spinner from "components/Spinner" +import Form from "components/Form" +import Calendar from "components/Calendar" +import { observable, computed } from "mobx" +import { Observer, observer } from "mobx-react" +import { Spring, animated, config } from "react-spring/renderprops" +import { + ERROR_UNKNOWN, + ERROR_UNAUTHORIZED, + ERROR_UPGRADE_REQUIRED, + findProjectByValue, + findProjectByIdentifier, + findTask, + formatDate +} from "utils" +import InvalidConfigurationError from "components/Errors/InvalidConfigurationError" +import UpgradeRequiredError from "components/Errors/UpgradeRequiredError" +import UnknownError from "components/Errors/UnknownError" +import { parse } from "date-fns" +import Header from "./shared/Header" +import { head } from "lodash" +import TimeInputParser from "utils/TimeInputParser" + +@observer +class App extends Component { + static propTypes = { + loading: PropTypes.bool, + service: PropTypes.shape({ + id: PropTypes.string, + url: PropTypes.string, + name: PropTypes.string, + description: PropTypes.string, + projectId: PropTypes.string, + taskId: PropTypes.string + }), + activities: PropTypes.array, + schedules: PropTypes.array, + projects: PropTypes.array, + lastProjectId: PropTypes.number, + lastTaskId: PropTypes.number, + roundTimeEntries: PropTypes.bool, + fromDate: PropTypes.string, + toDate: PropTypes.string, + errorType: PropTypes.string, + errorMessage: PropTypes.string + }; + + static defaultProps = { + activities: [], + schedules: [], + projects: [], + roundTimeEntries: false + }; + + @observable changeset = {}; + @observable formErrors = {}; + + @computed get changesetWithDefaults() { + const { service, projects, lastProjectId, lastTaskId } = this.props + + const project = + findProjectByIdentifier(service?.projectId)(projects) || + findProjectByValue(Number(lastProjectId))(projects) || + head(projects) + + const task = + findTask(service?.taskId || lastTaskId)(project) || head(project?.tasks) + + const defaults = { + remote_service: service?.name, + remote_id: service?.id, + remote_url: service?.url, + date: formatDate(new Date()), + assignment_id: project?.value, + task_id: task?.value, + billable: task?.billable, + hours: "", + seconds: + this.changeset.hours && + new TimeInputParser(this.changeset.hours).parseSeconds(), + description: service?.description + } + + return { + ...defaults, + ...this.changeset + } + } + + componentDidMount() { + window.addEventListener("keydown", this.handleKeyDown) + chrome.runtime.onMessage.addListener(this.handleSetFormErrors) + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.handleKeyDown) + chrome.runtime.onMessage.removeListener(this.handleSetFormErrors) + } + + handleChange = event => { + const { projects } = this.props + const { + target: { name, value } + } = event + + this.changeset[name] = value + + if (name === "assignment_id") { + const project = findProjectByValue(value)(projects) + this.changeset.task_id = head(project?.tasks).value || null + } + }; + + handleSelectDate = date => { + this.changeset.date = formatDate(date) + }; + + handleSubmit = event => { + event.preventDefault() + const { service } = this.props + + chrome.runtime.sendMessage({ + type: "createActivity", + payload: { + activity: this.changesetWithDefaults, + service + } + }) + }; + + handleKeyDown = event => { + if (event.keyCode === 27) { + event.stopPropagation() + chrome.runtime.sendMessage({ type: "closePopup" }) + } + }; + + handleSetFormErrors = ({ type, payload }) => { + if (type === "setFormErrors") { + this.formErrors = payload + } + }; + + render() { + const { + loading, + projects, + activities, + schedules, + fromDate, + toDate, + errorType, + errorMessage + } = this.props + + if (loading) { + return + } + + if (errorType === ERROR_UNAUTHORIZED) { + return + } + + if (errorType === ERROR_UPGRADE_REQUIRED) { + return + } + + if (errorType === ERROR_UNKNOWN) { + return + } + + return ( + + {props => ( + +
+ + {() => ( + <> + +
+ + )} + + + )} + + ) + } +} + +export default App diff --git a/src/js/components/Bubble.js b/src/js/components/Bubble.js new file mode 100644 index 0000000..22b7f29 --- /dev/null +++ b/src/js/components/Bubble.js @@ -0,0 +1,23 @@ +import React from "react" +import PropTypes from "prop-types" +import logoUrl from "images/logo.png" + +const Bubble = ({ bookedHours, onClick }) => ( +
+ + {bookedHours > 0 ? ( + {bookedHours.toFixed(2)} + ) : null} +
+) + +Bubble.propTypes = { + bookedHours: PropTypes.number, + onClick: PropTypes.func.isRequired +} + +Bubble.defaultProps = { + bookedHours: 0 +} + +export default Bubble diff --git a/src/js/components/Calendar/Day.js b/src/js/components/Calendar/Day.js new file mode 100644 index 0000000..1e9afa7 --- /dev/null +++ b/src/js/components/Calendar/Day.js @@ -0,0 +1,40 @@ +import React, { useCallback } from "react" +import PropTypes from "prop-types" +import Hours from "./Hours" +import { format, getDay } from "date-fns" +import deLocale from "date-fns/locale/de" +import cn from "classnames" + +const Day = ({ date, hours, absence, active, onClick }) => { + const handleClick = useCallback(() => onClick(date), [date]) + + return ( +
0, + "moco-bx-calendar__day--absence": absence + } + )} + onClick={handleClick} + > + + {format(date, "dd", { locale: deLocale })} + + +
+ ) +} + +Day.propTypes = { + date: PropTypes.instanceOf(Date).isRequired, + hours: PropTypes.number.isRequired, + absence: PropTypes.object, + active: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired +} + +export default Day diff --git a/src/js/components/Calendar/Hours.js b/src/js/components/Calendar/Hours.js new file mode 100644 index 0000000..04ca61e --- /dev/null +++ b/src/js/components/Calendar/Hours.js @@ -0,0 +1,43 @@ +import React from "react" +import PropTypes from "prop-types" + +const Hours = ({ hours, absence, active }) => { + let style + let content = null + + if (hours > 0) { + content = hours.toFixed(1) + } else if (absence) { + if (!active) { + style = { backgroundColor: absence.assignment_color } + } + + content = + absence.assignment_code === "1" + ? "/" + : absence.assignment_code === "2" + ? "★" + : absence.assignment_code === "3" + ? "K" + : absence.assignment_code === "4" + ? "✈" + : null + } + + return ( + + {content} + + ) +} + +Hours.propTypes = { + hours: PropTypes.number.isRequired, + absence: PropTypes.shape({ + assignment_code: PropTypes.string, + assignment_color: PropTypes.string + }), + active: PropTypes.bool.isRequired +} + +export default Hours diff --git a/src/js/components/Calendar/index.js b/src/js/components/Calendar/index.js new file mode 100644 index 0000000..a29613f --- /dev/null +++ b/src/js/components/Calendar/index.js @@ -0,0 +1,60 @@ +import React from "react" +import PropTypes from "prop-types" +import Day from "./Day" +import { formatDate } from "utils" +import { eachDay } from "date-fns" +import { pathEq } from "lodash/fp" + +const findAbsence = (date, schedules) => + schedules.find(pathEq("date", formatDate(date))) + +const hoursAtDate = (date, activities) => + activities + .filter(pathEq("date", formatDate(date))) + .reduce((acc, activity) => acc + activity.hours, 0) + +const Calendar = ({ + fromDate, + toDate, + selectedDate, + activities, + schedules, + onChange +}) => ( +
+ {eachDay(fromDate, toDate).map(date => ( + + ))} +
+) + +Calendar.propTypes = { + fromDate: PropTypes.instanceOf(Date).isRequired, + toDate: PropTypes.instanceOf(Date).isRequired, + selectedDate: PropTypes.instanceOf(Date).isRequired, + activities: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number.isRequired, + date: PropTypes.string.isRequired, + hours: PropTypes.number.isRequired, + timer_started_at: PropTypes.string + }).isRequired + ), + schedules: PropTypes.arrayOf( + PropTypes.shape({ + date: PropTypes.string, + assignment_code: PropTypes.string, + assignment_color: PropTypes.string + }) + ).isRequired, + onChange: PropTypes.func.isRequired +} + +export default Calendar diff --git a/src/js/components/Errors/InvalidConfigurationError.js b/src/js/components/Errors/InvalidConfigurationError.js new file mode 100644 index 0000000..985a858 --- /dev/null +++ b/src/js/components/Errors/InvalidConfigurationError.js @@ -0,0 +1,28 @@ +import React from "react" +import settingsUrl from "images/settings.png" + +const InvalidConfigurationError = () => ( +
+

Bitte Einstellungen aktualisieren

+
    +
  1. Internetadresse eintragen
  2. +
  3. Persönlichen API-Schlüssel eintragen
  4. +
+ +
+
+ Browser extension configuration settings chrome.runtime.sendMessage({ type: "openOptions" })} + /> +
+) + +export default InvalidConfigurationError diff --git a/src/js/components/Errors/UnknownError.js b/src/js/components/Errors/UnknownError.js new file mode 100644 index 0000000..c0eba26 --- /dev/null +++ b/src/js/components/Errors/UnknownError.js @@ -0,0 +1,21 @@ +import React from "react" +import PropTypes from "prop-types" +import logo from "images/logo.png" + +const UnknownError = ({ message = "Unbekannter Fehler" }) => ( +
+ MOCO logo +

Ups, es ist ein Fehler passiert!

+

Bitte überprüfe deine Internetverbindung.

+

Wir wurden per Email benachrichtigt und untersuchen den Vorfall.

+
+

Fehlermeldung:

+
{message}
+
+) + +UnknownError.propTypes = { + message: PropTypes.string +} + +export default UnknownError diff --git a/src/js/components/Errors/UpgradeRequiredError.js b/src/js/components/Errors/UpgradeRequiredError.js new file mode 100644 index 0000000..a5e416f --- /dev/null +++ b/src/js/components/Errors/UpgradeRequiredError.js @@ -0,0 +1,35 @@ +import React from "react" +import { isChrome } from "utils/browser" +import logo from "images/logo.png" +import firefoxAddons from "images/firefox_addons.png" + +const UpgradeRequiredError = () => ( +
+ MOCO logo +

Upgrade erforderlich

+

+ Die installierte MOCO Browser-Erweiterung ist veraltet — bitte + aktualisieren. +

+ {isChrome() ? ( + + ) : ( + <> +
+

Unter folgender URL:

+ about:addons + + )} +
+) + +export default UpgradeRequiredError diff --git a/src/js/components/Form.js b/src/js/components/Form.js new file mode 100644 index 0000000..f429c51 --- /dev/null +++ b/src/js/components/Form.js @@ -0,0 +1,113 @@ +import React, { Component } from "react" +import PropTypes from "prop-types" +import Select from "components/Select" +import cn from "classnames" + +class Form extends Component { + static propTypes = { + changeset: PropTypes.shape({ + project: PropTypes.object, + task: PropTypes.object, + hours: PropTypes.string + }).isRequired, + errors: PropTypes.object, + projects: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired + }; + + static defaultProps = { + inline: true + }; + + isValid = () => { + const { changeset } = this.props + return ["assignment_id", "task_id", "hours", "description"] + .map(prop => changeset[prop]) + .every(Boolean) + }; + + handleTextareaKeyDown = event => { + const { onSubmit } = this.props + + if (event.key === "Enter") { + event.preventDefault() + this.isValid() && onSubmit(event) + } + }; + + render() { + const { projects, changeset, errors, onChange, onSubmit } = this.props + const project = Select.findOptionByValue(projects, changeset.assignment_id) + + return ( + +
+ "Zuerst Projekt wählen"} + /> + {errors.assignment_id ? ( +
{errors.assignment_id.join("; ")}
+ ) : null} + {errors.task_id ? ( +
{errors.task_id.join("; ")}
+ ) : null} +
+
+ + {errors.hours ? ( +
{errors.hours.join("; ")}
+ ) : null} +
+
+