diff --git a/.babelrc b/.babelrc index d63fe0c..1a131b9 100644 --- a/.babelrc +++ b/.babelrc @@ -2,6 +2,7 @@ "presets": ["@babel/preset-env", "@babel/preset-react"], "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }], - ["@babel/plugin-proposal-class-properties", { "loose": true }] + ["@babel/plugin-proposal-class-properties", { "loose": true }], + ["@babel/plugin-proposal-optional-chaining"] ] } diff --git a/package.json b/package.json index e821749..3c009bb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@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", diff --git a/src/js/background.js b/src/js/background.js index 2b0f2f8..eddb7b2 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -1,7 +1,8 @@ -import DomainCheck from "./services/DomainCheck" -import config from "./config" +import { parseServices, createMatcher } from 'utils/urlMatcher' +import remoteServices from "./remoteServices" -const domainCheck = new DomainCheck(config) +const services = parseServices(remoteServices) +const matcher = createMatcher(services) const { version } = chrome.runtime.getManifest() chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { @@ -10,14 +11,14 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { return } - const service = domainCheck.match(tab.url) + const service = matcher(tab.url) if (service) { chrome.storage.sync.get( ["subdomain", "apiKey"], ({ subdomain, apiKey }) => { const settings = { subdomain, apiKey, version } - const payload = { service, settings } + const payload = { serviceKey: service.key, settings } chrome.tabs.sendMessage(tabId, { type: "mountBubble", payload }, () => { console.log("bubble mounted") }) diff --git a/src/js/components/Bubble.js b/src/js/components/Bubble.js index 5167599..fc523f1 100644 --- a/src/js/components/Bubble.js +++ b/src/js/components/Bubble.js @@ -12,6 +12,14 @@ import { findLastProject, findLastTask, groupedProjectOptions } from "utils" @observer class Bubble 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, settings: PropTypes.shape({ subdomain: PropTypes.string, apiKey: PropTypes.string, diff --git a/src/js/config.js b/src/js/config.js deleted file mode 100644 index 6f4838b..0000000 --- a/src/js/config.js +++ /dev/null @@ -1,12 +0,0 @@ -export default { - services: [ - { - name: "github", - urlPattern: "https://github.com/:org/:repo/pull/:id(/:tab)", - description: (document, { org, repo, id }) => - `${org}/${repo}/${id} - ${document - .querySelector(".gh-header-title") - .textContent.trim()}` - } - ] -} diff --git a/src/js/content.js b/src/js/content.js index aa33e61..d49f823 100644 --- a/src/js/content.js +++ b/src/js/content.js @@ -1,8 +1,12 @@ import { createElement } from "react" import ReactDOM from "react-dom" import Bubble from "./components/Bubble" +import services from "remoteServices" +import { createEnhancer } from "utils/urlMatcher" import "../css/main.scss" +const serviceEnhancer = createEnhancer(window.document)(services) + chrome.runtime.onMessage.addListener(({ type, payload }) => { switch (type) { case "mountBubble": { @@ -15,20 +19,24 @@ chrome.runtime.onMessage.addListener(({ type, payload }) => { } }) -const mountBubble = ({ service, settings }) => { - if (document.getElementById("moco-bx-bubble")) { - return +const mountBubble = ({ serviceKey, settings }) => { + if (!document.getElementById("moco-bx-container")) { + const domContainer = document.createElement("div") + domContainer.setAttribute("id", "moco-bx-container") + document.body.appendChild(domContainer) } - const domContainer = document.createElement("div") - domContainer.setAttribute("id", "moco-bx-container") - document.body.appendChild(domContainer) + if (!document.getElementById("moco-bx-bubble")) { + const domBubble = document.createElement("div") + domBubble.setAttribute("id", "moco-bx-bubble") + document.body.appendChild(domBubble) + } - const domBubble = document.createElement("div") - domBubble.setAttribute("id", "moco-bx-bubble") - document.body.appendChild(domBubble) - - ReactDOM.render(createElement(Bubble, { service, settings }), domBubble) + const service = serviceEnhancer(serviceKey, window.location.href) + ReactDOM.render( + createElement(Bubble, { service, settings }), + document.getElementById("moco-bx-bubble") + ) } const unmountBubble = () => { diff --git a/src/js/remoteServices.js b/src/js/remoteServices.js new file mode 100644 index 0000000..0174236 --- /dev/null +++ b/src/js/remoteServices.js @@ -0,0 +1,30 @@ +export default { + "github-pr": { + name: "github", + urlPattern: "https://github.com/:org/:repo/pull/:id(/:tab)", + id: (document, service, { org, repo, id }) => + [org, repo, service.key, id].join("."), + description: (document, service, { org, repo, id }) => + `${org}/${repo}/${id} - ${document + .querySelector(".js-issue-title") + ?.textContent?.trim()}`, + projectId: document => { + const match = document + .querySelector(".js-issue-title") + ?.textContent.trim() + ?.match(/^\[(\d+)\]/) + return match && match[1] + } + }, + + "github-issue": { + name: "github", + urlPattern: "https://github.com/:org/:repo/issues/:id", + id: (document, service, { org, repo, id }) => + [org, repo, "issue", id].join("."), + description: (document, service, { org, repo, id }) => + `${org}/${repo}/${id} - ${document + .querySelector(".gh-header-title") + .textContent.trim()}` + } +} diff --git a/src/js/services/DomainCheck.js b/src/js/services/DomainCheck.js deleted file mode 100644 index 8a93123..0000000 --- a/src/js/services/DomainCheck.js +++ /dev/null @@ -1,31 +0,0 @@ -import Route from "route-parser" - -class DomainCheck { - #services; - - constructor(config) { - this.#services = config.services.map(service => ({ - ...service, - route: new Route(service.urlPattern), - })) - } - - #findService = url => - this.#services.find(service => service.route.match(url)); - - match(url) { - const service = this.#findService(url) - - if (!service) { - return false - } - - return { - ...service, - match: service.route.match(url), - } - } - -} - -export default DomainCheck diff --git a/src/js/utils.js b/src/js/utils/index.js similarity index 92% rename from src/js/utils.js rename to src/js/utils/index.js index 8a87dcd..8a97d7e 100644 --- a/src/js/utils.js +++ b/src/js/utils/index.js @@ -14,13 +14,13 @@ const nilToArray = input => input || [] export const findLastProject = id => compose( - find(pathEq("value", id)), + find(pathEq("value", Number(id))), flatMap(get("options")) ) export const findLastTask = id => compose( - find(pathEq("value", id)), + find(pathEq("value", Number(id))), get("tasks") ) diff --git a/src/js/utils/urlMatcher.js b/src/js/utils/urlMatcher.js new file mode 100644 index 0000000..b3ceb26 --- /dev/null +++ b/src/js/utils/urlMatcher.js @@ -0,0 +1,45 @@ +import Route from "route-parser" +import { isFunction, isUndefined, compose, toPairs, map } from "lodash/fp" + +const createEvaluator = args => fnOrValue => { + if (isUndefined(fnOrValue)) { + return + } + + if (isFunction(fnOrValue)) { + return fnOrValue(...args) + } + + return fnOrValue +} + +export const parseServices = compose( + map(([key, config]) => ({ + ...config, + key, + route: new Route(config.urlPattern) + })), + toPairs +) + +export const createEnhancer = document => services => (key, url) => { + const service = services[key] + service.key = key + const route = new Route(service.urlPattern) + const match = route.match(url) + const args = [document, service, match] + const evaluate = createEvaluator(args) + + return { + ...service, + key, + url, + id: evaluate(service.id), + description: evaluate(service.description), + projectId: evaluate(service.projectId), + taskId: evaluate(service.taskId), + } +} + +export const createMatcher = services => url => + services.find(service => service.route.match(url)) diff --git a/test/data.js b/test/data.js index b783cf4..860633c 100644 --- a/test/data.js +++ b/test/data.js @@ -1,3 +1,19 @@ +export const remoteServices = { + 'github-pr': { + name: 'github', + urlPattern: 'https://github.com/:org/:repo/pull/:id', + id: (document, service, { org, repo, id }) => [org, repo, service.key, id].join('-'), + description: 'This is always the same text', + projectId: (document) => { + const match = document.querySelector(".gh-header-title").textContent.trim().match(/\[(\d+)\]/) + return match && match[1] + } + }, + 'jira-cloud': { + name: 'jira', + urlPattern: 'https://cloud.jira.com/browse?project=:project&issue=:id' + } +} export const projects = [ { id: 944868981, diff --git a/test/services/DomainCheck.test.js b/test/services/DomainCheck.test.js deleted file mode 100644 index cbf57e6..0000000 --- a/test/services/DomainCheck.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import DomainCheck from '../../src/js/services/DomainCheck' - -describe('services', () => { - describe('DomainCheck', () => { - const config = { - services: [ - { - name: 'github', - urlPattern: 'https://github.com/:org/:repo/pull/:id', - }, - { - name: 'jira', - urlPattern: 'https://support.jira.com/browse?project=:project&issue=:id' - } - ] - } - - let domainCheck - - beforeAll(() => { - domainCheck = new DomainCheck(config) - }) - - it('matches host and path', () => { - const service = domainCheck.match('https://github.com/hundertzehn/mocoapp/pull/123') - expect(service.name).toEqual('github') - expect(service.match).toEqual({ - org: 'hundertzehn', - repo: 'mocoapp', - id: '123', - }) - }) - - it('matches query string', () => { - const service = domainCheck.match('https://support.jira.com/browse?project=mocoapp&issue=1234') - expect(service.name).toEqual('jira') - expect(service.match).toEqual({ - project: 'mocoapp', - id: '1234', - }) - }) - - it('does not match different host', () => { - const service = domainCheck.match('https://trello.com/hundertzehn/mocoapp/pull/123') - expect(service).toBeFalsy() - }) - }) -}) diff --git a/test/utils.test.js b/test/utils/index.test.js similarity index 96% rename from test/utils.test.js rename to test/utils/index.test.js index 9e33f0d..ef4a5bb 100644 --- a/test/utils.test.js +++ b/test/utils/index.test.js @@ -1,9 +1,9 @@ -import { projects } from "./data" +import { projects } from "../data" import { findLastProject, findLastTask, groupedProjectOptions -} from "../src/js/utils" +} from "../../src/js/utils" import { map } from "lodash/fp" describe("utils", () => { diff --git a/test/utils/urlMatcher.test.js b/test/utils/urlMatcher.test.js new file mode 100644 index 0000000..a9688de --- /dev/null +++ b/test/utils/urlMatcher.test.js @@ -0,0 +1,64 @@ +import { remoteServices } from '../data' +import { parseServices, createMatcher, createEnhancer } from '../../src/js/utils/urlMatcher' +import Route from "route-parser" + +describe('utils', () => { + describe("urlMatcher", () => { + describe("parseServices", () => { + it("parses the services", () => { + const services = parseServices(remoteServices) + + let service = services[0] + expect(service.key).toEqual("github-pr") + expect(service.name).toEqual("github") + expect(service.route).toBeInstanceOf(Route) + + service = services[1] + expect(service.key).toEqual("jira-cloud") + expect(service.name).toEqual("jira") + expect(service.route).toBeInstanceOf(Route) + }) + }) + + describe("createMatcher", () => { + let services, matcher + + beforeEach(() => { + services = parseServices(remoteServices) + matcher = createMatcher(services) + }) + + it('matches host and path', () => { + const service = matcher('https://github.com/hundertzehn/mocoapp/pull/123') + expect(service.key).toEqual('github-pr') + expect(service.name).toEqual('github') + }) + + it('matches query string', () => { + const service = matcher('https://cloud.jira.com/browse?project=mocoapp&issue=1234') + expect(service.key).toEqual('jira-cloud') + expect(service.name).toEqual('jira') + }) + + it('does not match different host', () => { + const service = matcher('https://trello.com/hundertzehn/mocoapp/pull/123') + expect(service).toBeFalsy() + }) + }) + + describe("createEnhancer", () => { + it("enhances a services", () => { + const url = 'https://github.com/hundertzehn/mocoapp/pull/123' + const document = { + querySelector: jest.fn().mockReturnValue({ textContent: '[4321] Foo' }) + } + const enhancedService = createEnhancer(document)(remoteServices)('github-pr', url) + expect(enhancedService.id).toEqual( 'hundertzehn-mocoapp-github-pr-123') + expect(enhancedService.description).toEqual('This is always the same text') + expect(enhancedService.projectId).toEqual('4321') + expect(enhancedService.taskId).toBe(undefined) + }) + }) + }) +}) + diff --git a/yarn.lock b/yarn.lock index 79e717b..2ed8909 100644 --- a/yarn.lock +++ b/yarn.lock @@ -287,6 +287,14 @@ "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" +"@babel/plugin-proposal-optional-chaining@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz#ae454f4c21c6c2ce8cb2397dc332ae8b420c5441" + integrity sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-optional-chaining" "^7.2.0" + "@babel/plugin-proposal-unicode-property-regex@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.2.0.tgz#abe7281fe46c95ddc143a65e5358647792039520" @@ -338,6 +346,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-syntax-optional-chaining@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz#a59d6ae8c167e7608eaa443fda9fa8fa6bf21dff" + integrity sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-arrow-functions@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550"