Render App in iframe

This commit is contained in:
Manuel Bouza
2019-02-20 15:40:13 +01:00
parent a318f6a48e
commit 8811f6d382
26 changed files with 484 additions and 380 deletions

View File

@@ -18,6 +18,7 @@
"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",

View File

@@ -13,10 +13,13 @@ input {
}
input, textarea {
padding: 0.25rem 0.5rem;
padding: 0.5rem;
background-color: white;
border-color: #cccccc;
width: 100%;
font-size: 100%;
border-style: solid;
border-width: 1px;
}
text-muted {
@@ -63,7 +66,7 @@ input[name="hours"] {
textarea[name="description"] {
resize: none;
width: 100%;
width: calc(100% - 1rem);
}
button {
@@ -77,6 +80,7 @@ button {
background-image: none;
background-color: #7dc332;
border-color: #7dc332;
font-size: 100%;
cursor: pointer;
&:hover:not(:disabled) {
@@ -88,4 +92,19 @@ button {
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;
}
}

View File

@@ -1,63 +0,0 @@
$spacer: 1rem !default;
$spacers: (
0: 0,
1: (
$spacer * 0.25
),
2: (
$spacer * 0.5
),
3: $spacer,
4: (
$spacer * 1.5
),
5: (
$spacer * 3
)
);
@each $prop, $abbrev in (margin: m, padding: p) {
@each $size, $length in $spacers {
.#{$abbrev}-#{$size} {
#{$prop}: $length !important;
}
.#{$abbrev}t-#{$size},
.#{$abbrev}y-#{$size} {
#{$prop}-top: $length !important;
}
.#{$abbrev}r-#{$size},
.#{$abbrev}x-#{$size} {
#{$prop}-right: $length !important;
}
.#{$abbrev}b-#{$size},
.#{$abbrev}y-#{$size} {
#{$prop}-bottom: $length !important;
}
.#{$abbrev}l-#{$size},
.#{$abbrev}x-#{$size} {
#{$prop}-left: $length !important;
}
}
}
@each $size, $length in $spacers {
.m-auto {
margin: auto !important;
}
.mt-auto,
.my-auto {
margin-top: auto !important;
}
.mr-auto,
.mx-auto {
margin-right: auto !important;
}
.mb-auto,
.my-auto {
margin-bottom: auto !important;
}
.ml-auto,
.mx-auto {
margin-left: auto !important;
}
}

View File

@@ -1,45 +0,0 @@
#moco-bx-bubble, #moco-bx-container {
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;
}
}

View File

@@ -1,4 +1,4 @@
#moco-bx-bubble, #moco-bx-container {
#moco-bx-root {
@keyframes moco-bx-spinner {
to {
transform: rotate(360deg);

View File

@@ -1,7 +1,3 @@
@import "mixins";
@import "form";
@import "spinner";
#moco-bx-root {
position: fixed;
bottom: 40px;
@@ -17,21 +13,42 @@
2px 2px 15px 4px rgba(0, 0, 0, 0.05);
padding: 5px;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
cursor: pointer;
iframe {
border: 0;
}
.moco-bx-bubble {
img {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width: 100%;
height: 100%;
img.moco-logo {
width: 30px;
height: 30px;
}
.moco-bx-badge {
display: inline-block;
min-width: 10px;
padding: 2px 4px;
font-size: 12px;
font-weight: 700;
line-height: 1;
color: #fff;
text-align: center;
white-space: nowrap;
vertical-align: middle;
background-color: #7dc332;
border-radius: 10px;
}
}
.moco-bx-modal {
.moco-bx-popup {
position: fixed; /* Stay in place */
z-index: 2000; /* Sit on top */
padding-top: 100px; /* Location of the box */
@@ -43,11 +60,43 @@
background-color: rgb(0, 0, 0); /* Fallback color */
background-color: rgba(0, 0, 0, 0.4); /* Black w/ opacity */
.moco-bx-modal-content {
.moco-bx-popup-content {
background-color: white;
width: 600px;
padding: 40px;
height: 400px;
padding: 2rem;
margin: 0 auto;
}
}
#moco-bx-invalid-configuration-error {
img {
width: 536px;
}
}
button {
display: inline-block;
padding: 6px 12px;
margin-bottom: 0;
font-weight: normal;
text-align: center;
white-space: nowrap;
color: white;
background-image: none;
background-color: #7dc332;
border-color: #7dc332;
border-radius: 0;
cursor: pointer;
&:hover:not(:disabled) {
background-color: #639a28;
border-color: #639a28;
}
&:disabled {
opacity: 0.65;
cursor: default;
}
}
}

View File

@@ -0,0 +1,4 @@
@import "form";
#moco-bx-root {
}

5
src/css/popup.scss Normal file
View File

@@ -0,0 +1,5 @@
@import "form";
@import "spinner";
#moco-bx-root {
}

View File

@@ -1,22 +0,0 @@
#moco-bx-root {
position: fixed;
bottom: 40px;
left: 50%;
margin-left: -30px;
z-index: 1000;
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;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
cursor: pointer;
}

View File

@@ -1,5 +1,5 @@
import { createMatcher } from "utils/urlMatcher"
import remoteServices from "./remoteServices"
import { createMatcher } from 'utils/urlMatcher'
import remoteServices from './remoteServices'
const matcher = createMatcher(remoteServices)
const { version } = chrome.runtime.getManifest()
@@ -7,7 +7,7 @@ const registeredTabIds = new Set()
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
// run only after the page is fully loaded
if (changeInfo.status != "complete") {
if (changeInfo.status != 'complete') {
return
}
@@ -16,28 +16,28 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (service) {
registeredTabIds.add(tabId)
chrome.storage.sync.get(
["subdomain", "apiKey"],
['subdomain', 'apiKey'],
({ subdomain, apiKey }) => {
const payload = { subdomain, apiKey, version }
chrome.tabs.sendMessage(tabId, { type: "mountBubble", payload })
chrome.tabs.sendMessage(tabId, { type: 'mountBubble', payload })
}
)
} else {
registeredTabIds.delete(tabId)
chrome.tabs.sendMessage(tabId, { type: "unmountBubble" })
chrome.tabs.sendMessage(tabId, { type: 'unmountBubble' })
}
})
chrome.tabs.onRemoved.addListener(tabId => registeredTabIds.delete(tabId))
chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => {
if (areaName === "sync" && (apiKey || subdomain)) {
if (areaName === 'sync' && (apiKey || subdomain)) {
chrome.storage.sync.get(
["subdomain", "apiKey"],
['subdomain', 'apiKey'],
({ subdomain, apiKey }) => {
const payload = { subdomain, apiKey, version }
for (let tabId of registeredTabIds.values()) {
chrome.tabs.sendMessage(tabId, { type: "mountBubble", payload })
chrome.tabs.sendMessage(tabId, { type: 'mountBubble', payload })
}
}
)
@@ -46,7 +46,7 @@ chrome.storage.onChanged.addListener(({ apiKey, subdomain }, areaName) => {
chrome.runtime.onMessage.addListener(({ type }) => {
switch (type) {
case "openOptions": {
case 'openOptions': {
chrome.tabs.create({
url: `chrome://extensions/?options=${chrome.runtime.id}`
})

125
src/js/components/App.js Normal file
View File

@@ -0,0 +1,125 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import ApiClient from "api/Client"
import Form from "components/Form"
import Spinner from "components/Spinner"
import { observable, computed, reaction } from "mobx"
import { observer, disposeOnUnmount } from "mobx-react"
import {
findLastProject,
findLastTask,
groupedProjectOptions,
currentDate,
secondsFromHours
} from "utils"
import { head } from "lodash"
@observer
class App extends Component {
static propTypes = {
service: PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
projectId: PropTypes.string,
taskId: PropTypes.string
}).isRequired,
projects: PropTypes.arrayOf(PropTypes.object).isRequired,
lastProjectId: PropTypes.number,
lastTaskId: PropTypes.number,
browser: PropTypes.object.isRequired
};
@observable changeset = {};
@observable formErrors = {};
@computed get changesetWithDefaults() {
const { service, projects, lastProjectId, lastTaskId } = this.props
const project =
findLastProject(service.projectId || lastProjectId)(projects) ||
head(projects)
const task =
findLastTask(service.taskId || lastTaskId)(project) ||
head(project?.tasks)
const defaults = {
remote_service: service.name,
remote_id: service.id,
remote_url: service.url,
date: currentDate(),
assignment_id: project?.value,
task_id: task?.value,
billable: task?.billable,
hours: "",
seconds: secondsFromHours(this.changeset.hours),
description: service.description
}
return {
...defaults,
...this.changeset
}
}
componentDidMount() {
window.addEventListener("keydown", this.handleKeyDown)
}
componentWillUnmount() {
window.removeEventListener("keydown", this.handleKeyDown)
}
handleKeyDown = event => {
event.stopPropagation()
if (event.keyCode === 27) {
this.sendMessage({ type: 'closeForm' })
}
};
handleChange = event => {
const {
target: { name, value }
} = event
this.changeset[name] = value
if (name === "assignment_id") {
this.changeset.task_id = null
}
};
handleSubmit = event => {
event.preventDefault()
this.sendMessage({ type: 'submitForm', payload: this.changesetWithDefaults })
}
handleCancel = () => {
this.sendMessage({ type: 'closeForm' })
}
sendMessage = action =>
this.props.browser.tabs.query(
{ active: true, currentWindow: true },
tabs => chrome.tabs.sendMessage(tabs[0].id, action)
)
render() {
const { service, projects } = this.props;
return (
<Form
changeset={this.changesetWithDefaults}
projects={projects}
errors={this.formErrors}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
/>
)
}
}
export default App

View File

@@ -1,7 +1,7 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import ApiClient from "api/Client"
import Modal from "components/Modal"
import Popup from "components/Popup"
import InvalidConfigurationError from "components/InvalidConfigurationError"
import Form from "components/Form"
import Spinner from "components/Spinner"
@@ -32,14 +32,15 @@ class Bubble extends Component {
subdomain: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string
})
}),
browser: PropTypes.object.isRequired,
};
#apiClient;
@observable isLoading = false;
@observable isOpen = false;
@observable projects;
@observable projects = [];
@observable lastProjectId;
@observable lastTaskId;
@observable bookedHours = 0;
@@ -47,91 +48,77 @@ class Bubble extends Component {
@observable formErrors = {};
@observable unauthorizedError = false;
@computed get changesetWithDefaults() {
const { service } = this.props
const project =
findLastProject(service.projectId || this.lastProjectId)(this.projects) ||
head(this.projects)
const task = findLastTask(service.taskId || this.lastTaskId)(project)
const defaults = {
remote_service: service.name,
remote_id: service.id,
remote_url: window.location.href,
date: currentDate(),
assignment_id: project?.value,
task_id: task?.value,
billable: task?.billable,
hours: "",
seconds: secondsFromHours(this.changeset.hours),
description: service.description
}
return {
...defaults,
...this.changeset
}
}
constructor(props) {
super(props)
this.#apiClient = new ApiClient(props.settings)
this.initializeApiClient(props.settings)
}
componentDidMount() {
disposeOnUnmount(
this,
reaction(
() => (this.hasInvalidConfiguration() ? null : this.props.settings),
this.fetchProjects,
{
fireImmediately: true
}
)
reaction(() => this.props.settings, settings => {
this.initializeApiClient(settings)
this.fetchBookedHours()
this.close()
})
)
disposeOnUnmount(
this,
reaction(() => this.props.service, this.fetchBookedHours, {
fireImmediately: true
})
)
window.addEventListener("keydown", this.handleKeyDown)
this.props.browser.runtime.onMessage.addListener(this.receiveMessage)
window.addEventListener("keydown", this.handleKeyDown, true)
}
componentWillUnmount() {
this.props.browser.runtime.onMessage.removeListener(this.receiveMessage)
window.removeEventListener("keydown", this.handleKeyDown)
}
open = _event => {
open = event => {
if (event && event.target && event.target.classList.contains('moco-bx-popup')) {
return this.close()
}
this.isOpen = true
this.fetchProjects().then(() => (this.isOpen = true))
};
close = _event => {
this.isOpen = false
};
hasInvalidConfiguration = () => {
const { settings } = this.props
return ["subdomain", "apiKey"].some(key => !settings[key])
};
receiveMessage = ({ type, payload }) => {
switch(type) {
case 'submitForm': {
return this.createActivity(payload).then(() => this.close())
}
case 'closeForm': {
return this.close()
}
}
}
fetchProjects = settings => {
if (!settings) {
return
initializeApiClient = settings => {
this.#apiClient = new ApiClient(settings)
}
fetchProjects = () => {
if (this.projects.length > 0) {
return Promise.resolve();
}
this.isLoading = true
this.#apiClient = new ApiClient(settings)
this.#apiClient
return this.#apiClient
.projects()
.then(({ data }) => {
this.unauthorizedError = false
this.projects = groupedProjectOptions(data.projects)
this.lastProjectId = data.last_project_id
this.lastTaskId = data.lastTaskId
this.unauthorizedError = false
})
.catch(error => {
if (error.response?.status === 401) {
@@ -143,12 +130,16 @@ class Bubble extends Component {
})
};
fetchBookedHours = service => {
fetchBookedHours = () => {
const { service } = this.props
this.isLoading = true
this.#apiClient
.bookedHours(service)
.then(({ data }) => (this.bookedHours = parseFloat(data[0]?.hours) || 0))
.then(({ data }) => {
this.bookedHours = parseFloat(data[0]?.hours) || 0
this.unauthorizedError = false
})
.catch(error => {
if (error.response?.status === 401) {
this.unauthorizedError = true
@@ -157,83 +148,63 @@ class Bubble extends Component {
.finally(() => (this.isLoading = false))
};
// EVENT HANDLERS -----------------------------------------------------------
handleKeyDown = event => {
event.stopPropagation()
if (event.keyCode === 27) {
this.close()
}
};
handleChange = event => {
const {
target: { name, value }
} = event
this.changeset[name] = value
if (name === "assignment_id") {
this.changeset.task_id = null
}
};
handleSubmit = event => {
event.preventDefault()
createActivity = payload =>
this.#apiClient
.createActivity(this.changesetWithDefaults)
.createActivity(payload)
.then(({ data }) => {
this.close()
this.bookedHours += data.hours
this.changeset = {}
this.formErrors = {}
this.unauthorizedError = false
})
.catch(this.handleSubmitError)
};
handleSubmitError = error => {
if (error.response?.status === 422) {
this.formErrors = error.response.data
}
if (error.response?.status === 401) {
this.unauthorizedError = true
}
};
handleKeyDown = event => {
if (event.key === 'm' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
this.open()
}
};
hasInvalidConfiguration = () => {
const { settings } = this.props
return ["subdomain", "apiKey"].some(key => !settings[key])
};
// RENDER -------------------------------------------------------------------
renderContent = () => {
if (this.unauthorizedError || this.hasInvalidConfiguration()) {
return <InvalidConfigurationError />
} else if (this.isOpen) {
return (
<Form
projects={this.projects}
changeset={this.changesetWithDefaults}
errors={this.formErrors}
isLoading={this.isLoading}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
)
} else {
return null
}
}
render() {
if (this.isLoading) {
return <Spinner />
}
const { service, browser } = this.props;
return (
<div className="moco-bx-bubble">
<img
onClick={this.open}
src={chrome.extension.getURL(logoUrl)}
/>
{this.bookedHours > 0 && <span className="booked-hours"><small>{this.bookedHours}h</small></span>}
<div className="moco-bx-bubble" onClick={this.open}>
<img className="moco-logo" src={this.props.browser.extension.getURL(logoUrl)} />
{this.bookedHours > 0
? <span className="moco-bx-badge">{this.bookedHours}h</span>
: null
}
{this.isOpen && (
<Modal>
{this.renderContent()}
</Modal>
<Popup
service={service}
projects={this.projects}
lastProjectId={this.lastProjectId}
lastTaskId={this.lastTaskId}
browser={browser}
unauthorizedError={this.unauthorizedError}
/>
)}
</div>
)

View File

@@ -5,7 +5,6 @@ import cn from "classnames"
class Form extends Component {
static propTypes = {
isLoading: PropTypes.bool.isRequired,
changeset: PropTypes.shape({
project: PropTypes.object,
task: PropTypes.object,
@@ -14,7 +13,8 @@ class Form extends Component {
errors: PropTypes.object,
projects: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired
};
static defaultProps = {
@@ -31,10 +31,6 @@ class Form extends Component {
// RENDER -------------------------------------------------------------------
render() {
if (this.isLoading) {
return null
}
const { projects, changeset, errors, onChange, onSubmit } = this.props
const project = Select.findOptionByValue(projects, changeset.assignment_id)
@@ -95,6 +91,7 @@ class Form extends Component {
</div>
<button disabled={!this.isValid()}>Speichern</button>
<button type="button" className="secondary" onClick={this.props.onCancel}>Abbrechen</button>
</form>
)
}

View File

@@ -1,20 +1,22 @@
import React from "react"
import configurationSettingsUrl from "images/configurationSettings.png"
import React from 'react'
import configurationSettingsUrl from 'images/configurationSettings.png'
const InvalidConfigurationError = () => (
<div>
<div id='moco-bx-invalid-configuration-error'>
<h2>Konfiguration ungültig</h2>
<p>
Bitte trage deine Internetadresse und deinen API-Schlüssel in den
Einstellungen der MOCO Browser-Erweiterung ein. Deinen API-Key findest du
in der MOCO App in deinem Profil im Register &quot;Integrationen&quot;.
</p>
<button onClick={() => chrome.runtime.sendMessage({ type: "openOptions" })}>
<button onClick={() => chrome.runtime.sendMessage({ type: 'openOptions' })}>
Einstellungen öffnen
</button>
<br />
<br />
<img
src={chrome.extension.getURL(configurationSettingsUrl)}
alt="Browser extension configuration settings"
alt='Browser extension configuration settings'
/>
</div>
)

View File

@@ -1,22 +0,0 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
class Modal extends Component {
static propTypes = {
children: PropTypes.node.isRequired
}
// RENDER -------------------------------------------------------------------
render() {
return (
<div className="moco-bx-modal">
<div className="moco-bx-modal-content">
{this.props.children}
</div>
</div>
)
}
}
export default Modal

View File

@@ -3,7 +3,7 @@ import { observable } from "mobx"
import { observer } from "mobx-react"
@observer
class Setup extends Component {
class Options extends Component {
@observable loading = true
@observable subdomain = ""
@observable apiKey = ""
@@ -62,7 +62,7 @@ class Setup extends Component {
value={this.apiKey}
onChange={this.onChange}
/>
<div className="text-muted mt-1">
<div className="text-muted" style={{ marginTop: '0.5rem' }}>
Deinen API-Schlüssel findest du in der MOCO-App unter
Profil/Integrationen.
</div>
@@ -73,4 +73,4 @@ class Setup extends Component {
}
}
export default Setup
export default Options

View File

@@ -0,0 +1,41 @@
import React, { Component, useMemo } from 'react'
import PropTypes from 'prop-types'
import InvalidConfigurationError from 'components/InvalidConfigurationError'
import queryString from 'query-string'
import { serializeProps } from 'utils'
const Popup = props => {
const serializedProps = serializeProps(
['service', 'projects', 'lastProjectId', 'lastTaskId']
)(props)
const styles = useMemo(() => ({
width: '536px',
height: props.unauthorizedError ? '890px' : '400px'
}), [props.unauthorizedError])
return (
<div className='moco-bx-popup'>
<div className='moco-bx-popup-content' style={styles}>
{props.unauthorizedError
? <InvalidConfigurationError />
: <iframe
src={props.browser.extension.getURL(`popup.html?${queryString.stringify(serializedProps)}`)}
width={styles.width}
height={styles.height} />
}
</div>
</div>
)
}
Popup.propTypes = {
service: PropTypes.object.isRequired,
projects: PropTypes.arrayOf(PropTypes.object).isRequired,
lastProjectId: PropTypes.number,
lastTaskId: PropTypes.number,
browser: PropTypes.object.isRequired,
unauthorizedError: PropTypes.bool.isRequired
}
export default Popup

View File

@@ -1,21 +1,21 @@
import React from "react"
import ReactDOM from "react-dom"
import Bubble from "./components/Bubble"
import { createMatcher, createEnhancer } from "utils/urlMatcher"
import remoteServices from "./remoteServices"
import React from 'react'
import ReactDOM from 'react-dom'
import Bubble from './components/Bubble'
import { createMatcher, createEnhancer } from 'utils/urlMatcher'
import remoteServices from './remoteServices'
import { pipe } from 'lodash/fp'
import "../css/content.scss"
import '../css/content.scss'
const matcher = createMatcher(remoteServices)
const serviceEnhancer = createEnhancer(window.document)
const enhancer = createEnhancer(window.document)
chrome.runtime.onMessage.addListener(({ type, payload }) => {
switch (type) {
case "mountBubble": {
case 'mountBubble': {
return mountBubble(payload)
}
case "unmountBubble": {
case 'unmountBubble': {
return unmountBubble()
}
}
@@ -24,27 +24,27 @@ chrome.runtime.onMessage.addListener(({ type, payload }) => {
const mountBubble = (settings) => {
const service = pipe(
matcher,
serviceEnhancer(window.location.href)
enhancer(window.location.href)
)(window.location.href)
if (!service) {
return
}
if (!document.getElementById("moco-bx-root")) {
const domRoot = document.createElement("div")
domRoot.setAttribute("id", "moco-bx-root")
if (!document.getElementById('moco-bx-root')) {
const domRoot = document.createElement('div')
domRoot.setAttribute('id', 'moco-bx-root')
document.body.appendChild(domRoot)
}
ReactDOM.render(
<Bubble service={service} settings={settings} />,
document.getElementById("moco-bx-root")
<Bubble service={service} settings={settings} browser={chrome} />,
document.getElementById('moco-bx-root')
)
}
const unmountBubble = () => {
const domRoot = document.getElementById("moco-bx-root")
const domRoot = document.getElementById('moco-bx-root')
if (domRoot) {
ReactDOM.unmountComponentAtNode(domRoot)

View File

@@ -1,7 +1,7 @@
import React from "react"
import ReactDOM from "react-dom"
import Setup from "./components/Setup"
import "../css/options.scss"
import React from 'react'
import ReactDOM from 'react-dom'
import Options from './components/Options'
import '../css/options.scss'
const domContainer = document.querySelector("#moco-bx-root")
ReactDOM.render(<Setup />, domContainer)
const domContainer = document.querySelector('#moco-bx-root')
ReactDOM.render(<Options />, domContainer)

18
src/js/popup.js Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/App'
import queryString from 'query-string'
import { parseProps } from 'utils'
import '../css/popup.scss'
const parsedProps = parseProps(
['service', 'projects', 'lastProjectId', 'lastTaskId']
)(queryString.parse(location.search))
ReactDOM.render(
<App
{...parsedProps}
browser={chrome}
/>,
document.querySelector('#moco-bx-root')
)

View File

@@ -2,14 +2,16 @@ import {
groupBy,
compose,
map,
mapValues,
toPairs,
flatMap,
pathEq,
get,
find,
curry
} from "lodash/fp"
import { format } from "date-fns"
curry,
pick
} from 'lodash/fp'
import { format } from 'date-fns'
const SECONDS_PER_HOUR = 3600
const SECONDS_PER_MINUTE = 60
@@ -18,17 +20,17 @@ const nilToArray = input => input || []
export const findLastProject = id =>
compose(
find(pathEq("value", Number(id))),
flatMap(get("options"))
find(pathEq('value', Number(id))),
flatMap(get('options'))
)
export const findLastTask = id =>
compose(
find(pathEq("value", Number(id))),
get("tasks")
find(pathEq('value', Number(id))),
get('tasks')
)
function taskOptions(tasks) {
function taskOptions (tasks) {
return tasks.map(({ id, name, billable }) => ({
label: name,
value: id,
@@ -36,7 +38,7 @@ function taskOptions(tasks) {
}))
}
export function projectOptions(projects) {
export function projectOptions (projects) {
return projects.map(project => ({
value: project.id,
label: project.name,
@@ -51,18 +53,28 @@ export const groupedProjectOptions = compose(
options: projectOptions(projects)
})),
toPairs,
groupBy("customer_name"),
groupBy('customer_name'),
nilToArray
)
export const serializeProps = attrs => compose(
mapValues(JSON.stringify),
pick(attrs)
)
export const parseProps = attrs => compose(
mapValues(JSON.parse),
pick(attrs)
)
export const trace = curry((tag, value) => {
// eslint-disable-next-line no-console
console.log(tag, value)
return value
})
export const currentDate = (locale = "de") =>
format(new Date(), "YYYY-MM-DD", { locale })
export const currentDate = (locale = 'de') =>
format(new Date(), 'YYYY-MM-DD', { locale })
export const extensionSettingsUrl = () =>
`chrome://extensions/?id=${chrome.runtime.id}`

View File

@@ -1,5 +1,5 @@
import Route from "route-parser"
import { isFunction, isUndefined, compose, toPairs, map } from "lodash/fp"
import Route from 'route-parser'
import { isFunction, isUndefined, compose, toPairs, map, omit } from 'lodash/fp'
const createEvaluator = args => fnOrValue => {
if (isUndefined(fnOrValue)) {
@@ -33,7 +33,7 @@ export const createEnhancer = document => url => service => {
const evaluate = createEvaluator(args)
return {
...service,
...omit(['route'], service),
url,
id: evaluate(service.id) || match.id,
description: evaluate(service.description),

View File

@@ -32,18 +32,5 @@
"css": ["content.css"]
}
],
"browser_action": {
"default_icon": "src/images/logo.png",
"default_title": "MOCO Time Tracking",
"default_popup": "popup.html"
},
"web_accessible_resources": ["src/images/*", "content.css"],
"commands": {
"_execute_browser_action": {
"suggested_key": {
"default": "Ctrl+M",
"mac": "Command+M"
}
}
}
"web_accessible_resources": ["src/images/*", "popup.html"]
}

9
src/popup.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="moco-bx-root"></div>
</body>
</html>

View File

@@ -1,18 +1,19 @@
const path = require("path")
const MiniCssExtractPlugin = require("mini-css-extract-plugin")
const CleanWebpackPlugin = require("clean-webpack-plugin")
const HtmlWebpackPlugin = require("html-webpack-plugin")
const CopyWebpackPlugin = require("copy-webpack-plugin")
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
entry: {
background: "./src/js/background.js",
content: "./src/js/content.js",
options: "./src/js/options.js"
background: './src/js/background.js',
content: './src/js/content.js',
popup: './src/js/popup.js',
options: './src/js/options.js'
},
output: {
path: path.join(__dirname, "build"),
filename: "[name].js"
path: path.join(__dirname, 'build'),
filename: '[name].js'
},
module: {
rules: [
@@ -22,11 +23,11 @@ module.exports = {
{
loader: MiniCssExtractPlugin.loader
},
"css-loader",
'css-loader',
{
loader: "sass-loader",
loader: 'sass-loader',
options: {
includePaths: [path.join(__dirname, "src/css")]
includePaths: [path.join(__dirname, 'src/css')]
}
}
],
@@ -36,14 +37,14 @@ module.exports = {
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
loader: 'babel-loader'
}
},
{
test: /\.(jpg|png)$/,
loader: "file-loader",
loader: 'file-loader',
options: {
name: "[path][name].[ext]"
name: '[path][name].[ext]'
},
exclude: /node_modules/
}
@@ -51,14 +52,14 @@ module.exports = {
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].css",
filename: '[name].css',
chunkFilename: '[id].css'
}),
new CleanWebpackPlugin(["build"]),
new CleanWebpackPlugin(['build']),
new CopyWebpackPlugin([
{
from: "src/manifest.json",
transform: function(content, _path) {
from: 'src/manifest.json',
transform: function (content, _path) {
// generates the manifest file using the package.json informations
return Buffer.from(
JSON.stringify({
@@ -68,26 +69,28 @@ module.exports = {
})
)
}
},
{
from: "src/css/styles.css",
}
]),
new HtmlWebpackPlugin({
template: path.join(__dirname, "src", "options.html"),
filename: "options.html",
chunks: ["options"]
template: path.join(__dirname, 'src', 'popup.html'),
filename: 'popup.html',
chunks: ['popup']
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src', 'options.html'),
filename: 'options.html',
chunks: ['options']
})
],
resolve: {
modules: [path.join(__dirname, "src/js"), "node_modules"],
modules: [path.join(__dirname, 'src/js'), 'node_modules'],
alias: {
images: path.join(__dirname, "src/images")
images: path.join(__dirname, 'src/images')
}
},
// webpack creates sourcemaps by default and evals js code
// this is not allowed by chrome extensions
// https://stackoverflow.com/a/49100966
devtool: "none",
mode: process.env.NODE_ENV || "development"
devtool: 'none',
mode: process.env.NODE_ENV || 'development'
}

View File

@@ -5569,6 +5569,14 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
query-string@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.2.0.tgz#468edeb542b7e0538f9f9b1aeb26f034f19c86e1"
integrity sha512-5wupExkIt8RYL4h/FE+WTg3JHk62e6fFPWtAZA9J5IWK1PfTfKkMS93HBUHcFpeYi9KsY5pFbh+ldvEyaz5MyA==
dependencies:
decode-uri-component "^0.2.0"
strict-uri-encode "^2.0.0"
querystring-es3@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"
@@ -6455,6 +6463,11 @@ stream-shift@^1.0.0:
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
string-length@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"