Render App in iframe
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#moco-bx-bubble, #moco-bx-container {
|
||||
#moco-bx-root {
|
||||
@keyframes moco-bx-spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "form";
|
||||
|
||||
#moco-bx-root {
|
||||
}
|
||||
|
||||
5
src/css/popup.scss
Normal file
5
src/css/popup.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "form";
|
||||
@import "spinner";
|
||||
|
||||
#moco-bx-root {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
125
src/js/components/App.js
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 "Integrationen".
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
41
src/js/components/Popup.js
Normal file
41
src/js/components/Popup.js
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
18
src/js/popup.js
Normal 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')
|
||||
)
|
||||
@@ -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}`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
9
src/popup.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="moco-bx-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
13
yarn.lock
13
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user