Merge branch 'feature/host-overrides' of https://github.com/yay-digital/mocoapp-browser-extension into yay-digital-feature/host-overrides

This commit is contained in:
Manuel Bouza
2020-06-12 13:42:03 +02:00
8 changed files with 200 additions and 36 deletions

View File

@@ -38,5 +38,16 @@
.text-danger { .text-danger {
color: $red; color: $red;
} }
.moco-bx-override-hosts {
&-container {
padding: 0 20px;
text-align: center;
}
&-btn {
cursor: pointer;
text-decoration: underline;
}
}
} }
} }

View File

@@ -3,32 +3,58 @@ import { observable } from "mobx"
import { observer } from "mobx-react" import { observer } from "mobx-react"
import { isChrome, getSettings, setStorage } from "utils/browser" import { isChrome, getSettings, setStorage } from "utils/browser"
import ApiClient from "api/Client" import ApiClient from "api/Client"
import remoteServices from "../remoteServices"
import { pipe, prop, map, sortedUniqBy, filter } from "lodash/fp"
@observer @observer
class Options extends Component { class Options extends Component {
@observable subdomain = "" @observable subdomain = ""
@observable apiKey = "" @observable apiKey = ""
@observable hostOverrides = {}
@observable errorMessage = null @observable errorMessage = null
@observable isSuccess = false @observable isSuccess = false
@observable servicesHostOverrideList = []
@observable showHostOverrideOptions = false
componentDidMount() { componentDidMount() {
getSettings(false).then(({ subdomain, apiKey }) => { this.servicesHostOverrideList = pipe(
this.subdomain = subdomain || "" filter(prop("allowHostOverride")),
this.apiKey = apiKey || "" map((remoteService) => ({
name: remoteService.name,
host: remoteService.host,
})),
sortedUniqBy("name"),
)(remoteServices)
getSettings(false).then((storeData) => {
this.subdomain = storeData.subdomain || ""
this.apiKey = storeData.apiKey || ""
this.hostOverrides = storeData.hostOverrides || {}
}) })
} }
onChange = event => { onChange = (event) => {
this[event.target.name] = event.target.value.trim() this[event.target.name] = event.target.value.trim()
} }
handleSubmit = _event => { onChangeHostOverrides = (event) => {
// ensure to remove path (and trailing slash) from URL, as this can cause problems otherwise
this.hostOverrides[event.target.name] = this.removePathFromUrl(event.target.value.trim())
}
toggleHostOverrideOptions = () => {
this.showHostOverrideOptions = !this.showHostOverrideOptions
}
handleSubmit = (_event) => {
this.isSuccess = false this.isSuccess = false
this.errorMessage = null this.errorMessage = null
setStorage({ setStorage({
subdomain: this.subdomain, subdomain: this.subdomain,
apiKey: this.apiKey, apiKey: this.apiKey,
settingTimeTrackingHHMM: false, settingTimeTrackingHHMM: false,
hostOverrides: this.hostOverrides,
}).then(() => { }).then(() => {
const { version } = chrome.runtime.getManifest() const { version } = chrome.runtime.getManifest()
const apiClient = new ApiClient({ const apiClient = new ApiClient({
@@ -45,13 +71,17 @@ class Options extends Component {
this.isSuccess = true this.isSuccess = true
this.closeWindow() this.closeWindow()
}) })
.catch(error => { .catch((error) => {
this.errorMessage = error.response?.data?.message || "Anmeldung fehlgeschlagen" this.errorMessage = error.response?.data?.message || "Anmeldung fehlgeschlagen"
}) })
}) })
} }
handleInputKeyDown = event => { removePathFromUrl = (url) => {
return url.replace(/(\.[a-z]+)\/.*$/, "$1")
}
handleInputKeyDown = (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
this.handleSubmit() this.handleSubmit()
} }
@@ -93,6 +123,27 @@ class Options extends Component {
Den API-Schlüssel findest du in deinem Profil unter "Integrationen". Den API-Schlüssel findest du in deinem Profil unter "Integrationen".
</p> </p>
</div> </div>
<hr />
{!this.showHostOverrideOptions && (
<div className="moco-bx-override-hosts-container">
<span className="moco-bx-override-hosts-btn" onClick={this.toggleHostOverrideOptions}>Zeige Optionen zum Überschreiben<br />der Service Hosts</span>
</div>
)}
{this.showHostOverrideOptions &&
this.servicesHostOverrideList.map((remoteService) => (
<div className="form-group" key={remoteService.name}>
<label>Host URL: {remoteService.name}</label>
<input
type="text"
name={remoteService.name}
value={this.hostOverrides[remoteService.name] || ""}
placeholder={remoteService.host}
onKeyDown={this.handleInputKeyDown}
onChange={this.onChangeHostOverrides}
/>
</div>
))}
<hr />
<button className="moco-bx-btn" onClick={this.handleSubmit}> <button className="moco-bx-btn" onClick={this.handleSubmit}>
OK OK
</button> </button>

View File

@@ -8,9 +8,14 @@ import { createServiceFinder } from "utils/urlMatcher"
import remoteServices from "./remoteServices" import remoteServices from "./remoteServices"
import { ContentMessenger } from "utils/messaging" import { ContentMessenger } from "utils/messaging"
import "../css/content.scss" import "../css/content.scss"
import { getSettings } from "./utils/browser"
const popupRef = createRef() const popupRef = createRef()
const findService = createServiceFinder(remoteServices)(document)
let findService
getSettings().then((settings) => {
findService = createServiceFinder(remoteServices, settings.hostOverrides)(document)
})
chrome.runtime.onConnect.addListener(function (port) { chrome.runtime.onConnect.addListener(function (port) {
const messenger = new ContentMessenger(port) const messenger = new ContentMessenger(port)

View File

@@ -9,9 +9,10 @@ const projectIdentifierBySelector = selector => document =>
export default { export default {
asana: { asana: {
name: "asana", name: "asana",
host: "https://app.asana.com",
urlPatterns: [ urlPatterns: [
[/^https:\/\/app.asana.com\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]], [/^__HOST__\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]],
[/^https:\/\/app.asana.com\/0\/search\/([^/]+)\/(\d+)/, ["domainUserId", "id"]], [/^__HOST__\/0\/search\/([^/]+)\/(\d+)/, ["domainUserId", "id"]],
], ],
description: document => description: document =>
document.querySelector(".ItemRow--highlighted textarea")?.textContent?.trim() || document.querySelector(".ItemRow--highlighted textarea")?.textContent?.trim() ||
@@ -19,32 +20,38 @@ export default {
document.querySelector(".SingleTaskPane textarea")?.textContent?.trim() || document.querySelector(".SingleTaskPane textarea")?.textContent?.trim() ||
document.querySelector(".SingleTaskTitleInput-taskName textarea")?.textContent?.trim(), document.querySelector(".SingleTaskTitleInput-taskName textarea")?.textContent?.trim(),
projectId: projectIdentifierBySelector(".TopbarPageHeaderStructure-titleRow h1"), projectId: projectIdentifierBySelector(".TopbarPageHeaderStructure-titleRow h1"),
allowHostOverride: false,
}, },
"github-pr": { "github-pr": {
name: "github", name: "github",
urlPatterns: ["https\\://github.com/:org/:repo/pull/:id(/:tab)"], host: "https://github.com",
urlPatterns: ["__HOST__/:org/:repo/pull/:id(/:tab)"],
id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."), id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."),
description: document => document.querySelector(".js-issue-title")?.textContent?.trim(), description: document => document.querySelector(".js-issue-title")?.textContent?.trim(),
projectId: projectIdentifierBySelector(".js-issue-title"), projectId: projectIdentifierBySelector(".js-issue-title"),
allowHostOverride: true,
}, },
"github-issue": { "github-issue": {
name: "github", name: "github",
urlPatterns: ["https\\://github.com/:org/:repo/issues/:id"], host: "https://github.com",
urlPatterns: ["__HOST__/:org/:repo/issues/:id"],
id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."), id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."),
description: (document, service, { org, repo, id }) => description: (document, service, { org, repo, id }) =>
document.querySelector(".js-issue-title")?.textContent?.trim(), document.querySelector(".js-issue-title")?.textContent?.trim(),
projectId: projectIdentifierBySelector(".js-issue-title"), projectId: projectIdentifierBySelector(".js-issue-title"),
allowHostOverride: true,
}, },
jira: { jira: {
name: "jira", name: "jira",
host: "https://:org.atlassian.net",
urlPatterns: [ urlPatterns: [
"https\\://:org.atlassian.net/secure/RapidBoard.jspa", "__HOST__/secure/RapidBoard.jspa",
"https\\://:org.atlassian.net/browse/:id", "__HOST__/browse/:id",
"https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board", "__HOST__/jira/software/projects/:projectId/boards/:board",
"https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board/backlog", "__HOST__/jira/software/projects/:projectId/boards/:board/backlog",
], ],
queryParams: { queryParams: {
id: "selectedIssue", id: "selectedIssue",
@@ -59,11 +66,13 @@ export default {
document.querySelector(".ghx-selected .ghx-summary")?.textContent?.trim() document.querySelector(".ghx-selected .ghx-summary")?.textContent?.trim()
return `#${id} ${title || ""}` return `#${id} ${title || ""}`
}, },
allowHostOverride: true,
}, },
meistertask: { meistertask: {
name: "meistertask", name: "meistertask",
urlPatterns: ["https\\://www.meistertask.com/app/task/:id/:slug"], host: "https://www.meistertask.com",
urlPatterns: ["/app/task/:id/:slug"],
description: document => { description: document => {
const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}" const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}"
const data = JSON.parse(json) const data = JSON.parse(json)
@@ -75,30 +84,36 @@ export default {
const match = data.taskName?.match(projectRegex) || data.projectName?.match(projectRegex) const match = data.taskName?.match(projectRegex) || data.projectName?.match(projectRegex)
return match && match[1] return match && match[1]
}, },
allowHostOverride: false,
}, },
trello: { trello: {
name: "trello", name: "trello",
urlPatterns: ["https\\://trello.com/c/:id/:title"], host: "https://trello.com",
urlPatterns: ["__HOST__/c/:id/:title"],
description: (document, service, { title }) => description: (document, service, { title }) =>
document.querySelector(".js-title-helper")?.textContent?.trim() || title, document.querySelector(".js-title-helper")?.textContent?.trim() || title,
projectId: document => projectId: document =>
projectIdentifierBySelector(".js-title-helper")(document) || projectIdentifierBySelector(".js-title-helper")(document) ||
projectIdentifierBySelector(".js-board-editing-target")(document), projectIdentifierBySelector(".js-board-editing-target")(document),
allowHostOverride: false,
}, },
youtrack: { youtrack: {
name: "youtrack", name: "youtrack",
urlPatterns: ["https\\://:org.myjetbrains.com/youtrack/issue/:id"], host: "https://:org.myjetbrains.com",
urlPatterns: ["__HOST__/youtrack/issue/:id"],
description: document => document.querySelector("yt-issue-body h1")?.textContent?.trim(), description: document => document.querySelector("yt-issue-body h1")?.textContent?.trim(),
projectId: projectIdentifierBySelector("yt-issue-body h1"), projectId: projectIdentifierBySelector("yt-issue-body h1"),
allowHostOverride: true,
}, },
wrike: { wrike: {
name: "wrike", name: "wrike",
host: "https://www.wrike.com",
urlPatterns: [ urlPatterns: [
"https\\://www.wrike.com/workspace.htm#path=mywork", "__HOST__/workspace.htm#path=mywork",
"https\\://www.wrike.com/workspace.htm#path=folder", "__HOST__/workspace.htm#path=folder",
"https\\://app-eu.wrike.com/workspace.htm#path=mywork", "https\\://app-eu.wrike.com/workspace.htm#path=mywork",
"https\\://app-eu.wrike.com/workspace.htm#path=folder", "https\\://app-eu.wrike.com/workspace.htm#path=folder",
], ],
@@ -107,39 +122,46 @@ export default {
}, },
description: document => document.querySelector(".title-field-ghost")?.textContent?.trim(), description: document => document.querySelector(".title-field-ghost")?.textContent?.trim(),
projectId: projectIdentifierBySelector(".header-title__main"), projectId: projectIdentifierBySelector(".header-title__main"),
allowHostOverride: false,
}, },
wunderlist: { wunderlist: {
name: "wunderlist", name: "wunderlist",
urlPatterns: ["https\\://www.wunderlist.com/(webapp)#/tasks/:id(/*)"], host: "https://www.wunderlist.com",
urlPatterns: ["__HOST__/(webapp)#/tasks/:id(/*)"],
description: document => description: document =>
document document
.querySelector(".taskItem.selected .taskItem-titleWrapper-title") .querySelector(".taskItem.selected .taskItem-titleWrapper-title")
?.textContent?.trim(), ?.textContent?.trim(),
projectId: projectIdentifierBySelector(".taskItem.selected .taskItem-titleWrapper-title"), projectId: projectIdentifierBySelector(".taskItem.selected .taskItem-titleWrapper-title"),
allowHostOverride: false,
}, },
"gitlab-mr": { "gitlab-mr": {
name: "gitlab", name: "gitlab",
host: "https://gitlab.com",
urlPatterns: [ urlPatterns: [
"https\\://gitlab.com/:org/:group/:projectId/-/merge_requests/:id", "__HOST__/:org/:group/:projectId/-/merge_requests/:id",
"https\\://gitlab.com/:org/:projectId/-/merge_requests/:id", "__HOST__/:org/:projectId/-/merge_requests/:id",
], ],
description: (document, service, { id }) => { description: (document, service, { id }) => {
const title = document.querySelector("h2.title")?.textContent?.trim() const title = document.querySelector("h2.title")?.textContent?.trim()
return `#${id} ${title || ""}`.trim() return `#${id} ${title || ""}`.trim()
}, },
allowHostOverride: true,
}, },
"gitlab-issues": { "gitlab-issues": {
name: "gitlab", name: "gitlab",
host: "https://gitlab.com",
urlPatterns: [ urlPatterns: [
"https\\://gitlab.com/:org/:group/:projectId/-/issues/:id", "__HOST__/:org/:group/:projectId/-/issues/:id",
"https\\://gitlab.com/:org/:projectId/-/issues/:id", "__HOST__/:org/:projectId/-/issues/:id",
], ],
description: (document, service, { id }) => { description: (document, service, { id }) => {
const title = document.querySelector("h2.title")?.textContent?.trim() const title = document.querySelector("h2.title")?.textContent?.trim()
return `#${id} ${title || ""}`.trim() return `#${id} ${title || ""}`.trim()
}, },
allowHostOverride: true,
}, },
} }

View File

@@ -1,11 +1,12 @@
import { head } from "lodash/fp" import { head } from "lodash/fp"
export const isChrome = () => typeof browser === "undefined" && chrome export const isChrome = () => typeof browser === "undefined" && chrome
export const isFirefox = () => typeof browser !== "undefined" && chrome export const isFirefox = () => typeof browser !== "undefined" && chrome
const DEFAULT_SUBDOMAIN = "unset" const DEFAULT_SUBDOMAIN = "unset"
export const getSettings = (withDefaultSubdomain = true) => { export const getSettings = (withDefaultSubdomain = true) => {
const keys = ["subdomain", "apiKey", "settingTimeTrackingHHMM"] const keys = ["subdomain", "apiKey", "settingTimeTrackingHHMM", "hostOverrides"]
const { version } = chrome.runtime.getManifest() const { version } = chrome.runtime.getManifest()
if (isChrome()) { if (isChrome()) {
return new Promise(resolve => { return new Promise(resolve => {

View File

@@ -12,7 +12,15 @@ import { createMatcher } from "utils/urlMatcher"
import remoteServices from "remoteServices" import remoteServices from "remoteServices"
import { queryTabs, isBrowserTab, getSettings, setStorage } from "utils/browser" import { queryTabs, isBrowserTab, getSettings, setStorage } from "utils/browser"
const matcher = createMatcher(remoteServices) let matcher
const initMatcher = (settings) => {
matcher = createMatcher(remoteServices, settings.hostOverrides)
}
getSettings().then((settings) => {
initMatcher(settings)
})
export function tabUpdated(tab, { messenger, settings }) { export function tabUpdated(tab, { messenger, settings }) {
messenger.connectTab(tab) messenger.connectTab(tab)
@@ -54,6 +62,8 @@ export function tabUpdated(tab, { messenger, settings }) {
} }
export function settingsChanged(settings, { messenger }) { export function settingsChanged(settings, { messenger }) {
initMatcher(settings)
queryTabs({ currentWindow: true }) queryTabs({ currentWindow: true })
.then(reject(isBrowserTab)) .then(reject(isBrowserTab))
.then( .then(

View File

@@ -1,5 +1,5 @@
import UrlPattern from "url-pattern" import UrlPattern from "url-pattern"
import { isFunction, isUndefined, compose, toPairs, map, pipe, isNil } from "lodash/fp" import { isFunction, isUndefined, compose, toPairs, map, pipe, isNil, convert } from "lodash/fp"
import { asArray } from "./index" import { asArray } from "./index"
import queryString from "query-string" import queryString from "query-string"
@@ -39,15 +39,36 @@ const createEvaluator = args => fnOrValue => {
return fnOrValue return fnOrValue
} }
const prepareHostForRegExp = (host) => {
if (isUndefined(host)) {
return
}
return host.replace(":", "\\:")
}
const replaceHostInPattern = (host, pattern) => {
if(typeof pattern === "string") {
return pattern.replace("__HOST__", prepareHostForRegExp(host))
} else if(pattern instanceof RegExp) {
return new RegExp(pattern.source.replace("__HOST__", prepareHostForRegExp(host)))
} else {
console.error("Invalid type for pattern %v, no host replacement performed", pattern)
return pattern
}
}
const parseServices = compose( const parseServices = compose(
map(([key, config]) => ({ map(([key, config]) => ({
...config, ...config,
key, key,
patterns: config.urlPatterns.map(pattern => { patterns: config.urlPatterns.map(pattern => {
if (Array.isArray(pattern)) { if (Array.isArray(pattern)) {
return new UrlPattern(...pattern) return new UrlPattern(
...pattern.map((p, index) => (index === 0 ? replaceHostInPattern(config.host, p) : p)),
)
} }
return new UrlPattern(pattern) return new UrlPattern(replaceHostInPattern(config.host, pattern))
}), }),
})), })),
toPairs, toPairs,
@@ -72,8 +93,28 @@ export const createEnhancer = document => service => {
} }
} }
export const createMatcher = remoteServices => { const applyHostOverrides = (remoteServices, hostOverrides) => {
const services = parseServices(remoteServices) let appliedRemoteServices = Object.assign(remoteServices)
if (isUndefined(hostOverrides)) {
console.error("No overrides found.")
return remoteServices
}
Object.keys(remoteServices).forEach((key) => {
const remoteService = remoteServices[key]
appliedRemoteServices[key] = {
...remoteService,
key,
host: (hostOverrides && hostOverrides[remoteService.name]) || remoteService.host,
}
})
return appliedRemoteServices
}
export const createMatcher = (remoteServices, hostOverrides) => {
const services = parseServices(applyHostOverrides(remoteServices, hostOverrides))
return tabUrl => { return tabUrl => {
const { origin, pathname, hash, query } = parseUrl(tabUrl) const { origin, pathname, hash, query } = parseUrl(tabUrl)
const url = `${origin}${pathname}${hash}` const url = `${origin}${pathname}${hash}`
@@ -99,8 +140,8 @@ export const createMatcher = remoteServices => {
} }
} }
export const createServiceFinder = remoteServices => document => { export const createServiceFinder = (remoteServices, hostOverrides) => document => {
const matcher = createMatcher(remoteServices) const matcher = createMatcher(remoteServices, hostOverrides)
const enhancer = createEnhancer(document) const enhancer = createEnhancer(document)
return pipe( return pipe(
matcher, matcher,

View File

@@ -6,7 +6,7 @@ describe("utils", () => {
let matcher let matcher
beforeEach(() => { beforeEach(() => {
matcher = createMatcher(remoteServices) matcher = createMatcher(remoteServices, {})
}) })
describe("createMatcher", () => { describe("createMatcher", () => {
@@ -162,4 +162,27 @@ describe("utils", () => {
}) })
}) })
}) })
describe("urlMatcher with overrideHosts", () => {
let matcher
beforeEach(() => {
matcher = createMatcher(remoteServices, {
github: "https://my-custom-github-url.com",
})
})
describe("createMatcher", () => {
it("matches overridden host and path", () => {
const service = matcher("https://my-custom-github-url.com/hundertzehn/mocoapp/pull/123")
expect(service.key).toEqual("github-pr")
expect(service.name).toEqual("github")
})
it("doesn't match default host and path", () => {
const service = matcher("https://github.com/hundertzehn/mocoapp/pull/123")
expect(service).toBe(undefined)
})
})
})
}) })