MOCO Browser Extension (#2)
* spike * initial draft * updated styling * skeleton * added bubble script to webpack * added linter settings * installs * first implementation * Update webpack config - write bundle to `/build` - add support for SASS - improve options view as a proof o concept for styling * Update es-lint rules to mach mocoapp * Upgrade npm packages * Mount Bubble only for configured services * Update react and babel * Move module resolution config to webpack * Syncrhonize apiClient with chrome storage * Load projects and initialize form with last project and task * Enhance service * Improve handling of changeset with defaults * Create activity * Show error page on missing configuration * Refactor so that changeset can be used as activity params * Show form errors * Fetch and show booked hours for service * Allow to book hours with colon, error handling, spinner * WIP: Shadow DOM * Remove shadow dom * Render App in iframe * Refactor App component to load projects and create activity * Bugsnag integration * Add title to form and timer hint to hours input field * Configure positioning of bubble * Get rid of shared browser instance * Show Calendar and animate buble * Update webpack config * Prevent double animation of bubble * Fix eslint * Add margin to iframe body * Submit form when pressing enter on textarea * Open select on Enter * Use local environment for development * Show upgrade error if version invalid * Add asana service * Add jira and wunderlist services, add better support for query strings * Match urls with hash * Show popup in browser action * Pump version, add version to zip file * Add youtrack service * WIP: always show browserAction * Refactor * Update design * Finalize release 1.0.3 * Fix styles * Add support for Firefox browser * Extract common webpack config * Fix eslint * Close modal with ESC key * Use TimeInputParser to parse hours input * Improve webpack config * Show modal instead of popup when clicking on browser action * Pre-select last booked activities on service * Remove badge from booked hours * Show error and success feedback on options page * Remove updateBrowserActionForTab * Animate Bubble on unmount * Fix select date * Refactor * Fix key shortcut * Show schedule in calendar * Upload source maps to bugsnag * Upload sourcemaps to bugsnag * Define command shortcuts * Fix race condition where both Bubble and content wanted to mount Popup The content script is now the only place, where the Popup is mounted * Replace hash in filename by version * No new line in textarea and updated shortcuts for chrome * Change shortcut to Ctrl+Shift+K * Fix cors issue in new chrome 73 * Style improvements * Only report errors from own sources * Prevent sending messages to browser tabs * Fix scrollbars in iframe * Add error page for unknown error * Add stop propagation to Bubble click event * Update error pages * Remove timeout in tabHandler. The messaging error occurs only when the browser extension is reloaded/updated without refreshing the browser tab. * Refactor messaging * Show spinner in popup * Extract message handler to own module * Update styles and texts of error pages * Ensure focus is on document when opening popup * Find projects by identifier and value, do not highlight selected option in select component * Update docs * Spread match properties on service; improve remote service configuration for jira and wunderlist * Add webpack plugin to remove source mapping url * Bugsnag do not collect user ip * Upload source maps before removing source mapping url in bundles * Add support for regex url patterns, update asana config. * Fix animation Set default transform property via css * Improve config for asana * Change to fad-in/out animation
This commit is contained in:
91
src/js/utils/TimeInputParser.js
Normal file
91
src/js/utils/TimeInputParser.js
Normal file
@@ -0,0 +1,91 @@
|
||||
export default class TimeInputParser {
|
||||
#input;
|
||||
|
||||
constructor(input) {
|
||||
this.#input = input.toLowerCase().replace(/[\s()]/g, "")
|
||||
}
|
||||
|
||||
parseSeconds() {
|
||||
if (this.#isDecimal()) {
|
||||
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
|
||||
} else if (this.#isTime()) {
|
||||
return this.#parseTimeAsSeconds()
|
||||
} else if (this.#isMinutes()) {
|
||||
return this.#parseMinutesAsSeconds()
|
||||
} else if (this.#isRange()) {
|
||||
return this.#parseRange()
|
||||
} else if (this.#isHoursAndMinutes()) {
|
||||
return this.#parseHoursAndMinutes()
|
||||
} else {
|
||||
return Math.round(parseFloat(this.#parseDecimal()) * 3600)
|
||||
}
|
||||
}
|
||||
|
||||
#calculateFromHoursAndMinutes = (hours, minutes, isNegative) => {
|
||||
const calculated = hours * 3600 + minutes * 60
|
||||
|
||||
return isNegative ? -calculated : calculated
|
||||
};
|
||||
|
||||
#parseDecimal = () => {
|
||||
return this.#input.replace(/[.,]/g, ".")
|
||||
};
|
||||
|
||||
#parseTimeAsSeconds = () => {
|
||||
const match = this.#isTime()
|
||||
|
||||
const isNegative = "-" == match[1]
|
||||
const hours = parseInt(match[2])
|
||||
const minutes = parseInt(match[3])
|
||||
|
||||
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
|
||||
};
|
||||
|
||||
#parseMinutesAsSeconds = () => {
|
||||
const minutes = parseInt(this.#isMinutes()[1])
|
||||
return minutes * 60
|
||||
};
|
||||
|
||||
#parseRange = () => {
|
||||
const match = this.#isRange()
|
||||
|
||||
const from_hours = parseInt(match[1])
|
||||
const from_minutes = parseInt(match[2])
|
||||
const to_hours = parseInt(match[3])
|
||||
const to_minutes = parseInt(match[4])
|
||||
return (to_hours - from_hours) * 3600 + (to_minutes - from_minutes) * 60
|
||||
};
|
||||
|
||||
#parseHoursAndMinutes = () => {
|
||||
const match = this.#isHoursAndMinutes()
|
||||
|
||||
const isNegative = "-" == match[1]
|
||||
const hours = parseInt(match[2])
|
||||
const minutes = parseInt(match[3])
|
||||
|
||||
return this.#calculateFromHoursAndMinutes(hours, minutes, isNegative)
|
||||
};
|
||||
|
||||
#isDecimal = () => {
|
||||
return this.#input.match(/^([-]?[0-9]{0,2})[.,]{1}([0-9]{1,2})$/)
|
||||
};
|
||||
|
||||
#isTime = () => {
|
||||
return this.#input.match(/^([-]?)([0-9]{1,2}):([0-9]{2})$/)
|
||||
};
|
||||
|
||||
#isMinutes = () => {
|
||||
return this.#input.match(/^([-]?[0-9]{1,3})(m|mins?)$/)
|
||||
};
|
||||
|
||||
#isRange = () => {
|
||||
return this.#input.match(
|
||||
/^([0-9]{1,2})[:.]{0,1}([0-9]{2})-([0-9]{1,2})[:.]{0,1}([0-9]{2})$/
|
||||
)
|
||||
};
|
||||
|
||||
#isHoursAndMinutes = () => {
|
||||
// 1h 14m(in)
|
||||
return this.#input.match(/^([-]?)([0-9]{1,2})h([0-9]{1,2})(m|mins?)$/)
|
||||
};
|
||||
}
|
||||
41
src/js/utils/browser.js
Normal file
41
src/js/utils/browser.js
Normal file
@@ -0,0 +1,41 @@
|
||||
export const isChrome = () => typeof browser === "undefined" && chrome
|
||||
export const isFirefox = () => typeof browser !== "undefined" && chrome
|
||||
import { head } from "lodash/fp"
|
||||
|
||||
export const getSettings = () => {
|
||||
const keys = ["subdomain", "apiKey"]
|
||||
const { version } = chrome.runtime.getManifest()
|
||||
if (isChrome()) {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.sync.get(keys, data => {
|
||||
resolve({ ...data, version })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
return browser.storage.sync.get(keys).then(data => ({ ...data, version }))
|
||||
}
|
||||
}
|
||||
|
||||
export const setStorage = items => {
|
||||
if (isChrome()) {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.sync.set(items, resolve)
|
||||
})
|
||||
} else {
|
||||
return browser.storage.sync.set(items)
|
||||
}
|
||||
}
|
||||
|
||||
export const queryTabs = queryInfo => {
|
||||
if (isChrome()) {
|
||||
return new Promise(resolve => chrome.tabs.query(queryInfo, resolve))
|
||||
} else {
|
||||
return browser.tabs.query(queryInfo)
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentTab = () => {
|
||||
return queryTabs({ currentWindow: true, active: true }).then(head)
|
||||
}
|
||||
|
||||
export const isBrowserTab = tab => /^(?:chrome|about):/.test(tab.url)
|
||||
88
src/js/utils/index.js
Normal file
88
src/js/utils/index.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
groupBy,
|
||||
compose,
|
||||
map,
|
||||
mapValues,
|
||||
toPairs,
|
||||
flatMap,
|
||||
pathEq,
|
||||
get,
|
||||
find,
|
||||
curry,
|
||||
pick
|
||||
} from "lodash/fp"
|
||||
import { format } from "date-fns"
|
||||
|
||||
const nilToArray = input => input || []
|
||||
|
||||
export const ERROR_UNAUTHORIZED = "unauthorized"
|
||||
export const ERROR_UPGRADE_REQUIRED = "upgrade-required"
|
||||
export const ERROR_UNKNOWN = "unknown"
|
||||
|
||||
export const noop = () => null
|
||||
|
||||
export const findProjectBy = prop => val =>
|
||||
compose(
|
||||
find(pathEq(prop, val)),
|
||||
flatMap(get("options"))
|
||||
)
|
||||
export const findProjectByIdentifier = findProjectBy("identifier")
|
||||
export const findProjectByValue = findProjectBy("value")
|
||||
|
||||
export const findTask = id =>
|
||||
compose(
|
||||
find(pathEq("value", Number(id))),
|
||||
get("tasks")
|
||||
)
|
||||
|
||||
function taskOptions(tasks) {
|
||||
return tasks.map(({ id, name, billable }) => ({
|
||||
label: billable ? name : `(${name})`,
|
||||
value: id,
|
||||
billable
|
||||
}))
|
||||
}
|
||||
|
||||
export function projectOptions(projects) {
|
||||
return projects.map(project => ({
|
||||
value: project.id,
|
||||
label: project.intern ? `(${project.name})` : project.name,
|
||||
identifier: project.identifier,
|
||||
customerName: project.customer_name,
|
||||
tasks: taskOptions(project.tasks)
|
||||
}))
|
||||
}
|
||||
|
||||
export const groupedProjectOptions = compose(
|
||||
map(([customerName, projects]) => ({
|
||||
label: customerName,
|
||||
options: projectOptions(projects)
|
||||
})),
|
||||
toPairs,
|
||||
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 weekStartsOn = 1
|
||||
export const formatDate = date => format(date, "YYYY-MM-DD")
|
||||
|
||||
export const extensionSettingsUrl = () =>
|
||||
`chrome://extensions/?id=${chrome.runtime.id}`
|
||||
127
src/js/utils/messageHandlers.js
Normal file
127
src/js/utils/messageHandlers.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import ApiClient from "api/Client"
|
||||
import {
|
||||
ERROR_UNAUTHORIZED,
|
||||
ERROR_UPGRADE_REQUIRED,
|
||||
ERROR_UNKNOWN,
|
||||
groupedProjectOptions,
|
||||
weekStartsOn
|
||||
} from "utils"
|
||||
import { get, forEach, reject, isNil } from "lodash/fp"
|
||||
import { startOfWeek, endOfWeek } from "date-fns"
|
||||
import { createMatcher } from "utils/urlMatcher"
|
||||
import remoteServices from "remoteServices"
|
||||
import { queryTabs, isBrowserTab, getSettings } from "utils/browser"
|
||||
|
||||
const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn })
|
||||
const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn })
|
||||
const matcher = createMatcher(remoteServices)
|
||||
|
||||
export function tabUpdated(tab, { messenger, settings }) {
|
||||
messenger.connectTab(tab)
|
||||
|
||||
const service = matcher(tab.url)
|
||||
if (service?.match?.id) {
|
||||
messenger.postMessage(tab, { type: "requestService" })
|
||||
|
||||
messenger.once("newService", ({ payload: { service } }) => {
|
||||
const apiClient = new ApiClient(settings)
|
||||
apiClient
|
||||
.bookedHours(service)
|
||||
.then(({ data }) => {
|
||||
messenger.postMessage(tab, {
|
||||
type: "showBubble",
|
||||
payload: {
|
||||
bookedHours: parseFloat(data[0]?.hours) || 0,
|
||||
service
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
messenger.postMessage(tab, {
|
||||
type: "showBubble",
|
||||
payload: {
|
||||
bookedHours: 0,
|
||||
service
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
messenger.postMessage(tab, { type: "hideBubble" })
|
||||
}
|
||||
}
|
||||
|
||||
export function settingsChanged(settings, { messenger }) {
|
||||
queryTabs({ currentWindow: true })
|
||||
.then(reject(isBrowserTab))
|
||||
.then(
|
||||
forEach(tab => {
|
||||
messenger.postMessage(tab, { type: "closePopup" })
|
||||
tabUpdated(tab, { settings, messenger })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function togglePopup(tab, { messenger }) {
|
||||
return function({ isOpen, service } = {}) {
|
||||
if (isNil(isOpen)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
messenger.postMessage(tab, { type: "closePopup" })
|
||||
} else {
|
||||
openPopup(tab, { service, messenger })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openPopup(tab, { service, messenger }) {
|
||||
messenger.postMessage(tab, { type: "openPopup", payload: { loading: true } })
|
||||
|
||||
const fromDate = getStartOfWeek()
|
||||
const toDate = getEndOfWeek()
|
||||
getSettings()
|
||||
.then(settings => new ApiClient(settings))
|
||||
.then(apiClient =>
|
||||
Promise.all([
|
||||
apiClient.login(service),
|
||||
apiClient.projects(),
|
||||
apiClient.activities(fromDate, toDate),
|
||||
apiClient.schedules(fromDate, toDate)
|
||||
])
|
||||
)
|
||||
.then(responses => {
|
||||
const action = {
|
||||
type: "openPopup",
|
||||
payload: {
|
||||
service,
|
||||
lastProjectId: get("[0].data.last_project_id", responses),
|
||||
lastTaskId: get("[0].data.last_task_id", responses),
|
||||
roundTimeEntries: get("[0].data.round_time_entries", responses),
|
||||
projects: groupedProjectOptions(get("[1].data.projects", responses)),
|
||||
activities: get("[2].data", responses),
|
||||
schedules: get("[3].data", responses),
|
||||
fromDate,
|
||||
toDate,
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
messenger.postMessage(tab, action)
|
||||
})
|
||||
.catch(error => {
|
||||
let errorType, errorMessage
|
||||
if (error.response?.status === 401) {
|
||||
errorType = ERROR_UNAUTHORIZED
|
||||
} else if (error.response?.status === 426) {
|
||||
errorType = ERROR_UPGRADE_REQUIRED
|
||||
} else {
|
||||
errorType = ERROR_UNKNOWN
|
||||
errorMessage = error.message
|
||||
}
|
||||
messenger.postMessage(tab, {
|
||||
type: "openPopup",
|
||||
payload: { errorType, errorMessage }
|
||||
})
|
||||
})
|
||||
}
|
||||
99
src/js/utils/messaging.js
Normal file
99
src/js/utils/messaging.js
Normal file
@@ -0,0 +1,99 @@
|
||||
export class BackgroundMessenger {
|
||||
#ports = new Map();
|
||||
#handlers = new Map();
|
||||
#onceHandlers = new Map();
|
||||
|
||||
#handler = action => {
|
||||
const handler = this.#handlers.get(action.type)
|
||||
if (handler) {
|
||||
handler(action)
|
||||
}
|
||||
};
|
||||
|
||||
#onceHandler = action => {
|
||||
const handler = this.#onceHandlers.get(action.type)
|
||||
this.#onceHandlers.delete(action.type)
|
||||
if (handler) {
|
||||
handler(action)
|
||||
}
|
||||
};
|
||||
|
||||
#registerPort = (tabId, port) => {
|
||||
this.#ports.set(tabId, port)
|
||||
port.onMessage.addListener(this.#handler)
|
||||
port.onMessage.addListener(this.#onceHandler)
|
||||
port.onDisconnect.addListener(() => {
|
||||
this.#unregisterPort(tabId, port)
|
||||
})
|
||||
};
|
||||
|
||||
#unregisterPort = (tabId, port) => {
|
||||
port.onMessage.removeListener(this.#handler)
|
||||
port.onMessage.removeListener(this.#onceHandler)
|
||||
port.disconnect()
|
||||
this.#ports.delete(tabId)
|
||||
};
|
||||
|
||||
connectTab = tab => {
|
||||
const currentPort = this.#ports.get(tab.id)
|
||||
if (!currentPort) {
|
||||
const port = chrome.tabs.connect(tab.id)
|
||||
this.#registerPort(tab.id, port)
|
||||
}
|
||||
};
|
||||
|
||||
disconnectTab = tabId => {
|
||||
const port = this.#ports.get(tabId)
|
||||
if (port) {
|
||||
this.#unregisterPort(tabId, port)
|
||||
}
|
||||
};
|
||||
|
||||
postMessage = (tab, action) => {
|
||||
const port = this.#ports.get(tab.id)
|
||||
if (port) {
|
||||
port.postMessage(action)
|
||||
}
|
||||
};
|
||||
|
||||
once = (type, handler) => {
|
||||
this.#onceHandlers.set(type, handler)
|
||||
};
|
||||
|
||||
on = (type, handler) => {
|
||||
this.#handlers.set(type, handler)
|
||||
};
|
||||
}
|
||||
|
||||
export class ContentMessenger {
|
||||
#port;
|
||||
#handlers = new Map();
|
||||
|
||||
#handler = action => {
|
||||
const handler = this.#handlers.get(action.type)
|
||||
if (handler) {
|
||||
handler(action)
|
||||
}
|
||||
};
|
||||
|
||||
constructor(port) {
|
||||
this.#port = port
|
||||
this.#port.onMessage.addListener(this.#handler)
|
||||
}
|
||||
|
||||
postMessage = action => {
|
||||
if (this.#port) {
|
||||
this.#port.postMessage(action)
|
||||
}
|
||||
};
|
||||
|
||||
on = (type, handler) => {
|
||||
this.#handlers.set(type, handler)
|
||||
};
|
||||
|
||||
stop = () => {
|
||||
this.#port.onMessage.removeListener(this.#handler)
|
||||
this.#port = null
|
||||
this.#handlers.clear()
|
||||
};
|
||||
}
|
||||
39
src/js/utils/notifier.js
Normal file
39
src/js/utils/notifier.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import bugsnag from "@bugsnag/js"
|
||||
import bugsnagReact from "@bugsnag/plugin-react"
|
||||
import { includes } from "lodash/fp"
|
||||
|
||||
function getAppVersion() {
|
||||
try {
|
||||
return chrome.runtime.getManifest().version
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const filterReport = report => {
|
||||
const appVersion = getAppVersion()
|
||||
if (!appVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scripts = ["background", "content", "options", "popup"].map(
|
||||
file => `${chrome.extension.getURL(file)}.${appVersion}.js`
|
||||
)
|
||||
|
||||
return scripts.some(script => report.stacktrace.some(includes(script)))
|
||||
}
|
||||
|
||||
const bugsnagClient = bugsnag({
|
||||
apiKey: "da6caac4af70af3e4683454b40fe5ef5",
|
||||
appVersion: getAppVersion(),
|
||||
collectUserIp: false,
|
||||
beforeSend: filterReport,
|
||||
releaseStage: process.env.NODE_ENV,
|
||||
notifyReleaseStages: ["production"]
|
||||
})
|
||||
|
||||
bugsnagClient.use(bugsnagReact, React)
|
||||
|
||||
export default bugsnagClient
|
||||
export const ErrorBoundary = bugsnagClient.getPlugin("react")
|
||||
104
src/js/utils/urlMatcher.js
Normal file
104
src/js/utils/urlMatcher.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import UrlPattern from "url-pattern"
|
||||
import {
|
||||
isFunction,
|
||||
isUndefined,
|
||||
compose,
|
||||
toPairs,
|
||||
map,
|
||||
pipe
|
||||
} from "lodash/fp"
|
||||
import queryString from "query-string"
|
||||
|
||||
const extractQueryParams = (queryParams, query) => {
|
||||
return toPairs(queryParams).reduce((acc, [key, param]) => {
|
||||
acc[key] = query[param]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const createEvaluator = args => fnOrValue => {
|
||||
if (isUndefined(fnOrValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isFunction(fnOrValue)) {
|
||||
return fnOrValue(...args)
|
||||
}
|
||||
|
||||
return fnOrValue
|
||||
}
|
||||
|
||||
const parseServices = compose(
|
||||
map(([key, config]) => ({
|
||||
...config,
|
||||
key,
|
||||
patterns: config.urlPatterns.map(pattern => {
|
||||
if (Array.isArray(pattern)) {
|
||||
return new UrlPattern(...pattern)
|
||||
}
|
||||
return new UrlPattern(pattern)
|
||||
})
|
||||
})),
|
||||
toPairs
|
||||
)
|
||||
|
||||
export const createEnhancer = document => service => {
|
||||
if (!service) {
|
||||
return
|
||||
}
|
||||
|
||||
const match = service.match
|
||||
const args = [document, service, match]
|
||||
const evaluate = createEvaluator(args)
|
||||
|
||||
return {
|
||||
...service,
|
||||
id: evaluate(service.id),
|
||||
description: evaluate(service.description),
|
||||
projectId: evaluate(service.projectId),
|
||||
taskId: evaluate(service.taskId),
|
||||
position: service.position || { left: "50%", transform: "translateX(-50%)" }
|
||||
}
|
||||
}
|
||||
|
||||
export const createMatcher = remoteServices => {
|
||||
const services = parseServices(remoteServices)
|
||||
return tabUrl => {
|
||||
const { origin, pathname, hash, search } = new URL(tabUrl)
|
||||
const url = `${origin}${pathname}${hash}`
|
||||
const query = queryString.parse(search)
|
||||
const service = services.find(service =>
|
||||
service.patterns.some(pattern => pattern.match(url))
|
||||
)
|
||||
|
||||
if (!service) {
|
||||
return
|
||||
}
|
||||
|
||||
const pattern = service.patterns.find(pattern => pattern.match(url))
|
||||
let match = pattern.match(url)
|
||||
if (service.queryParams) {
|
||||
const extractedQueryParams = extractQueryParams(
|
||||
service.queryParams,
|
||||
query
|
||||
)
|
||||
match = { ...extractedQueryParams, ...match }
|
||||
}
|
||||
|
||||
return {
|
||||
...match,
|
||||
...service,
|
||||
url,
|
||||
match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createServiceFinder = remoteServices => document => {
|
||||
const matcher = createMatcher(remoteServices)
|
||||
const enhancer = createEnhancer(document)
|
||||
return pipe(
|
||||
matcher,
|
||||
enhancer
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user