UNPKG

mockaton

Version:
917 lines (791 loc) 22.3 kB
import { createElement as r, createSvgElement as s, className, restoreFocus, Defer, Fragment } from './dom-utils.js' import { store } from './app-store.js' import { parseFilename } from './Filename.js' import { HEADER_502 } from './ApiConstants.js' const CSS = { BulkSelector: null, CookieSelector: null, DelayToggler: null, ErrorToast: null, FallbackBackend: null, Field: null, GlobalDelayField: null, GroupByMethod: null, InternalServerErrorToggler: null, MenuTrigger: null, Method: null, MockList: null, MockSelector: null, NotFoundToggler: null, PayloadViewer: null, PreviewLink: null, ProgressBar: null, ProxyToggler: null, ResetButton: null, Resizer: null, SaveProxiedCheckbox: null, SettingsMenu: null, Table: null, TableHeading: null, TableRow: null, animIn: null, canProxy: null, checkboxBody: null, chosen: null, dittoDir: null, leftSide: null, nonDefault: null, nonGroupedByMethod: null, rightSide: null, status4xx: null, syntaxAttr: null, syntaxAttrVal: null, syntaxKey: null, syntaxPunc: null, syntaxStr: null, syntaxTag: null, syntaxVal: null } for (const k of Object.keys(CSS)) CSS[k] = k const FocusGroup = { ProxyToggler: 0, DelayToggler: 1, StatusToggler: 2, PreviewLink: 3 } const t = translation => translation[0] store.onError = onError store.render = render store.renderRow = renderRow store.fetchState() initRealTimeUpdates() initKeyboardNavigation() function render() { render.count++ restoreFocus(() => document.body.replaceChildren(...App())) if (store.hasChosenLink) previewMock() } render.count = 0 const leftSideRef = {} function App() { return [ Header(), r('main', null, r('div', { ref: leftSideRef, style: { width: leftSideRef.width }, className: CSS.leftSide }, r('div', className(CSS.Table), MockList(), StaticFilesList())), r('div', { className: CSS.rightSide }, Resizer(leftSideRef), PayloadViewer())) ] } function Header() { return ( r('header', null, r('object', { data: 'logo.svg', type: 'image/svg+xml', width: 120, height: 22 }), r('div', null, GlobalDelayField(), BulkSelector(), CookieSelector(), ProxyFallbackField(), ResetButton(), SettingsMenuTrigger()))) } function GlobalDelayField() { function onChange() { store.setGlobalDelay(this.valueAsNumber) } function onWheel(event) { if (event.deltaY > 0) this.stepUp() else this.stepDown() clearTimeout(onWheel.timer) onWheel.timer = setTimeout(onChange.bind(this), 300) } return ( r('label', className(CSS.Field, CSS.GlobalDelayField), r('span', null, t`Delay (ms)`), r('input', { type: 'number', min: 0, step: 100, autocomplete: 'none', value: store.delay, onChange, onWheel: [onWheel, { passive: true }] }))) } function BulkSelector() { const { comments } = store const firstOption = t`Pick Comment…` function onChange() { const value = this.value this.value = firstOption // hack so it’s always selected store.bulkSelectByComment(value) } const disabled = !comments.length return ( r('label', className(CSS.Field), r('span', null, t`Bulk Select`), r('select', { className: CSS.BulkSelector, autocomplete: 'off', disabled, title: disabled ? t`No mock files have comments which are anything within parentheses on the filename.` : '', onChange }, r('option', { value: firstOption }, firstOption), r('hr'), comments.map(value => r('option', { value }, value))))) // TODO For a11y, use `menu` instead of `select` } function CookieSelector() { const { cookies } = store const disabled = cookies.length <= 1 const list = cookies.length ? cookies : [[t`None`, true]] return ( r('label', className(CSS.Field, CSS.CookieSelector), r('span', null, t`Cookie`), r('select', { autocomplete: 'off', disabled, title: disabled ? t`No cookies specified in config.cookies` : '', onChange() { store.selectCookie(this.value) } }, list.map(([value, selected]) => r('option', { value, selected }, value))))) } function ProxyFallbackField() { const checkboxRef = {} function onChange() { checkboxRef.elem.disabled = !this.validity.valid || !this.value.trim() if (!this.validity.valid) this.reportValidity() else store.setProxyFallback(this.value.trim()) } return ( r('div', className(CSS.Field, CSS.FallbackBackend), r('label', null, r('span', null, t`Fallback`), r('input', { type: 'url', autocomplete: 'none', placeholder: t`Type backend address`, value: store.proxyFallback, onChange })), SaveProxiedCheckbox(checkboxRef))) } function SaveProxiedCheckbox(ref) { return ( r('label', className(CSS.SaveProxiedCheckbox), r('input', { ref, type: 'checkbox', disabled: !store.canProxy, checked: store.collectProxied, onChange() { store.setCollectProxied(this.checked) } }), r('span', className(CSS.checkboxBody), t`Save Mocks`))) } function ResetButton() { return ( r('button', { className: CSS.ResetButton, onClick: store.reset }, t`Reset`)) } function SettingsMenuTrigger() { const id = '_settings_menu_' return ( r('button', { title: t`Settings`, popovertarget: id, className: CSS.MenuTrigger }, SettingsIcon(), Defer(() => SettingsMenu(id)))) } function SettingsMenu(id) { const firstInputRef = {} return ( r('menu', { id, popover: '', className: CSS.SettingsMenu, onToggle(event) { if (event.newState === 'open') firstInputRef.elem.focus() } }, r('div', null, r('label', className(CSS.GroupByMethod), r('input', { ref: firstInputRef, type: 'checkbox', checked: store.groupByMethod, onChange: store.toggleGroupByMethod }), r('span', className(CSS.checkboxBody), t`Group by Method`)), r('a', { href: 'https://github.com/ericfortis/mockaton', target: '_blank', rel: 'noopener noreferrer' }, t`Documentation`), r('p', null, `v${store.mockatonVersion}`) ))) } /** # MockList */ function MockList() { if (!Object.keys(store.brokersByMethod).length) return r('div', null, t`No mocks found`) if (store.groupByMethod) return Object.keys(store.brokersByMethod).map(method => Fragment( r('div', className(CSS.TableHeading, store.canProxy && CSS.canProxy), method), store.brokersAsRowsByMethod(method).map(Row))) return store.brokersAsRowsByMethod('*').map(Row) } /** * @param {BrokerRowModel} row * @param {number} i */ function Row(row, i) { const { method, urlMask } = row return ( r('div', { key: row.key, ...className(CSS.TableRow, render.count > 1 && row.isNew && CSS.animIn) }, store.canProxy && ProxyToggler(method, urlMask, row.proxied), DelayRouteToggler(method, urlMask, row.delayed), InternalServerErrorToggler(method, urlMask, !row.proxied && row.status === 500, // checked row.opts.length === 1 && row.status === 500), // disabled !store.groupByMethod && r('span', className(CSS.Method), method), PreviewLink(method, urlMask, row.urlMaskDittoed, i === 0), MockSelector(row))) } function renderRow(method, urlMask) { restoreFocus(() => { unChooseOld() const row = store.brokerAsRow(method, urlMask) trFor(row.key).replaceWith(Row(row)) previewMock() }) function trFor(key) { return leftSideRef.elem.querySelector(`.${CSS.TableRow}[key="${key}"]`) } function unChooseOld() { return leftSideRef.elem.querySelector(`a.${CSS.chosen}`) ?.classList.remove(CSS.chosen) } } function PreviewLink(method, urlMask, urlMaskDittoed, autofocus) { function onClick(event) { event.preventDefault() store.previewLink(method, urlMask) } const isChosen = store.chosenLink.method === method && store.chosenLink.urlMask === urlMask const [ditto, tail] = urlMaskDittoed return ( r('a', { ...className(CSS.PreviewLink, isChosen && CSS.chosen), href: urlMask, autofocus, 'data-focus-group': FocusGroup.PreviewLink, onClick }, ditto ? [r('span', className(CSS.dittoDir), ditto), tail] : tail)) } /** @param {BrokerRowModel} row */ function MockSelector(row) { return ( r('select', { onChange() { store.selectFile(this.value) }, autocomplete: 'off', 'aria-label': t`Mock Selector`, disabled: row.opts.length < 2, ...className( CSS.MockSelector, row.selectedIdx > 0 && CSS.nonDefault, row.selectedFileIs4xx && CSS.status4xx) }, row.opts.map(([value, label, selected]) => r('option', { value, selected }, label)))) } function DelayRouteToggler(method, urlMask, checked) { return ClickDragToggler({ checked, commit(checked) { store.setDelayed(method, urlMask, checked) }, focusGroup: FocusGroup.DelayToggler }) } function InternalServerErrorToggler(method, urlMask, checked, disabled) { return ( r('label', { className: CSS.InternalServerErrorToggler, title: t`Internal Server Error` }, r('input', { type: 'checkbox', disabled, checked, onChange() { store.toggle500(method, urlMask) }, 'data-focus-group': FocusGroup.StatusToggler }), r('span', className(CSS.checkboxBody), t`500`))) } function ProxyToggler(method, urlMask, checked) { return ( r('label', { className: CSS.ProxyToggler, title: t`Proxy Toggler` }, r('input', { type: 'checkbox', checked, onChange() { store.setProxied(method, urlMask, this.checked) }, 'data-focus-group': FocusGroup.ProxyToggler }), CloudIcon())) } /** # StaticFilesList */ function StaticFilesList() { const rows = store.staticBrokersAsRows() return !rows.length ? null : Fragment( r('div', className(CSS.TableHeading, store.canProxy && CSS.canProxy, !store.groupByMethod && CSS.nonGroupedByMethod), store.groupByMethod ? t`Static GET` : t`Static`), rows.map(StaticRow)) } /** @param {StaticBrokerRowModel} row */ function StaticRow(row) { const { groupByMethod } = store const [ditto, tail] = row.urlMaskDittoed return ( r('div', { key: row.key, ...className(CSS.TableRow, render.count > 1 && row.isNew && CSS.animIn) }, DelayStaticRouteToggler(row.urlMask, row.delayed), NotFoundToggler(row.urlMask, row.status === 404), !groupByMethod && r('span', className(CSS.Method), 'GET'), r('a', { href: row.urlMask, target: '_blank', className: CSS.PreviewLink, 'data-focus-group': FocusGroup.PreviewLink }, ditto ? [r('span', className(CSS.dittoDir), ditto), tail] : tail))) } function DelayStaticRouteToggler(route, checked) { return ClickDragToggler({ optClassName: store.canProxy && CSS.canProxy, checked, focusGroup: FocusGroup.DelayToggler, commit(checked) { store.setDelayedStatic(route, checked) } }) } function NotFoundToggler(route, checked) { return ( r('label', { className: CSS.NotFoundToggler, title: t`Not Found` }, r('input', { type: 'checkbox', checked, 'data-focus-group': FocusGroup.StatusToggler, onChange() { store.setStaticRouteStatus(route, this.checked ? 404 : 200) } }), r('span', className(CSS.checkboxBody), t`404`))) } function ClickDragToggler({ checked, commit, focusGroup, optClassName }) { function onPointerEnter(event) { if (event.buttons === 1) onPointerDown.call(this) } function onPointerDown() { this.checked = !this.checked this.focus() commit(this.checked) } function onClick(event) { if (event.pointerType === 'mouse') event.preventDefault() } function onChange() { commit(this.checked) } return ( r('label', { ...className(CSS.DelayToggler, optClassName), title: t`Delay` }, r('input', { type: 'checkbox', 'data-focus-group': focusGroup, checked, onPointerEnter, onPointerDown, onClick, onChange }), TimerIcon())) } function Resizer(ref) { let raf = 0 let initialX = 0 let initialWidth = 0 function onPointerDown(event) { initialX = event.clientX initialWidth = ref.elem.clientWidth addEventListener('pointerup', onUp, { once: true }) addEventListener('pointermove', onMove) Object.assign(document.body.style, { cursor: 'col-resize', userSelect: 'none', pointerEvents: 'none' }) } function onMove(event) { const MIN_LEFT_WIDTH = 340 raf = raf || requestAnimationFrame(() => { ref.width = Math.max(initialWidth - (initialX - event.clientX), MIN_LEFT_WIDTH) + 'px' ref.elem.style.width = ref.width raf = 0 }) } function onUp() { removeEventListener('pointermove', onMove) cancelAnimationFrame(raf) raf = 0 Object.assign(document.body.style, { cursor: 'auto', userSelect: 'auto', pointerEvents: 'auto' }) } return ( r('div', { className: CSS.Resizer, onPointerDown })) } /** # Payload Preview */ const payloadViewerTitleRef = {} const payloadViewerCodeRef = {} function PayloadViewer() { return ( r('div', className(CSS.PayloadViewer), r('h2', { ref: payloadViewerTitleRef }, !store.hasChosenLink && t`Preview`), r('pre', null, r('code', { ref: payloadViewerCodeRef }, !store.hasChosenLink && t`Click a link to preview it`)))) } function PayloadViewerTitle(file, statusText) { const { method, status, ext } = parseFilename(file) const fileNameWithComments = file.split('.').slice(0, -3).join('.') return ( r('span', null, fileNameWithComments + '.' + method + '.', r('abbr', { title: statusText }, status), '.' + ext)) } function PayloadViewerTitleWhenProxied(response) { const mime = response.headers.get('content-type') || '' const badGateway = response.headers.get(HEADER_502) return ( r('span', null, badGateway ? r('span', null, t`⛔ Fallback Backend Error` + ' ') : r('span', null, t`Got` + ' '), r('abbr', { title: response.statusText }, response.status), ' ' + mime)) } const SPINNER_DELAY = 80 function PayloadViewerProgressBar() { return ( r('div', className(CSS.ProgressBar), r('div', { style: { animationDuration: store.delay - SPINNER_DELAY + 'ms' } }))) } async function previewMock() { const { method, urlMask } = store.chosenLink previewMock.controller?.abort() previewMock.controller = new AbortController const spinnerTimer = setTimeout(() => { payloadViewerTitleRef.elem.replaceChildren(t`Fetching…`) payloadViewerCodeRef.elem.replaceChildren(PayloadViewerProgressBar()) }, SPINNER_DELAY) try { const response = await fetch(urlMask, { method, signal: previewMock.controller.signal }) clearTimeout(spinnerTimer) const { proxied, file } = store.brokerFor(method, urlMask) if (proxied || file) await updatePayloadViewer(proxied, file, response) } catch { payloadViewerCodeRef.elem.replaceChildren() } } async function updatePayloadViewer(proxied, file, response) { const mime = response.headers.get('content-type') || '' payloadViewerTitleRef.elem.replaceChildren(proxied ? PayloadViewerTitleWhenProxied(response) : PayloadViewerTitle(file, response.statusText)) if (mime.startsWith('image/')) // Naively assumes GET 200 payloadViewerCodeRef.elem.replaceChildren(r('img', { src: URL.createObjectURL(await response.blob()) })) else { const body = await response.text() || t`/* Empty Response Body */` if (mime === 'application/json') payloadViewerCodeRef.elem.replaceChildren(SyntaxJSON(body)) else if (isXML(mime)) payloadViewerCodeRef.elem.replaceChildren(SyntaxXML(body)) else payloadViewerCodeRef.elem.textContent = body } } function isXML(mime) { return ['text/html', 'text/xml', 'application/xml'].some(m => mime.includes(m)) || /application\/.*\+xml/.test(mime) } async function onError(error) { if (error?.name === 'AbortError') return let msg = '' let isOffline = false if (error instanceof Response) { if (error.status === 422) msg = await error.text() else if (error.statusText) msg = error.statusText } else if (error?.message === 'Failed to fetch') { msg = t`Looks like the Mockaton server is not running` isOffline = true } else msg = error?.message || t`Unexpected Error` ErrorToast(msg, isOffline) console.error(error) } function ErrorToast(msg, isOffline) { ErrorToast.isOffline = isOffline ErrorToast.ref.elem?.remove() document.body.appendChild( r('div', { role: 'alert', ref: ErrorToast.ref, className: CSS.ErrorToast, onClick: ErrorToast.close }, msg)) } ErrorToast.isOffline = false ErrorToast.ref = {} ErrorToast.close = () => { document.startViewTransition(() => ErrorToast.ref.elem?.remove()) } /** # Icons */ function TimerIcon() { return ( s('svg', { viewBox: '0 0 24 24' }, s('path', { d: 'm11 5.6 0.14 7.2 6 3.7' }))) } function CloudIcon() { return ( s('svg', { viewBox: '0 0 24 24' }, s('path', { d: 'm6.1 8.9c0.98-2.3 3.3-3.9 6-3.9 3.3-2e-7 6 2.5 6.4 5.7 0.018 0.15 0.024 0.18 0.026 0.23 0.0016 0.037 8.2e-4 0.084 0.098 0.14 0.097 0.054 0.29 0.05 0.48 0.05 2.2 0 4 1.8 4 4s-1.8 4-4 4c-4-0.038-9-0.038-13-0.018-2.8 0-5-2.2-5-5-2.2e-7 -2.8 2.2-5 5-5 2.8 2e-7 5 2.2 5 5' }), s('path', { d: 'm6.1 9.1c2.8 0 5 2.3 5 5' }))) } function SettingsIcon() { return ( s('svg', { viewBox: '0 0 24 24' }, s('path', { d: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6' }))) } /** * # Long polls UI sync version * The version increments when a mock file is added, removed, or renamed. */ function initRealTimeUpdates() { let oldVersion = undefined // undefined so it waits until next event or timeout let controller = new AbortController() longPoll() document.addEventListener('visibilitychange', () => { if (document.hidden) { controller.abort('_hidden_tab_') controller = new AbortController() } else longPoll() }) async function longPoll() { try { const response = await store.getSyncVersion(oldVersion, controller.signal) if (response.ok) { if (ErrorToast.isOffline) ErrorToast.close() const version = await response.json() const shouldSkip = oldVersion === undefined if (oldVersion !== version) { // because it could be < or > oldVersion = version if (!shouldSkip) store.fetchState() } longPoll() } else throw response.status } catch (error) { if (error !== '_hidden_tab_') setTimeout(longPoll, 3000) } } } function initKeyboardNavigation() { addEventListener('keydown', onKeyDown) function onKeyDown(event) { const pivot = document.activeElement switch (event.key) { case 'ArrowDown': case 'ArrowUp': { let fg = pivot.getAttribute('data-focus-group') if (fg !== null) { const offset = event.key === 'ArrowDown' ? +1 : -1 circularAdjacent(offset, allInFocusGroup(+fg), pivot).focus() } break } case 'ArrowRight': case 'ArrowLeft': { if (pivot.hasAttribute('data-focus-group') || pivot.classList.contains(CSS.MockSelector)) { const offset = event.key === 'ArrowRight' ? +1 : -1 rowFocusable(pivot, offset).focus() } break } } } function rowFocusable(el, step) { const row = el.closest(`.${CSS.TableRow}`) if (row) { const focusables = Array.from(row.querySelectorAll('a, input, select:not(:disabled)')) return circularAdjacent(step, focusables, el) } } function allInFocusGroup(focusGroup) { return Array.from(leftSideRef.elem.querySelectorAll( `.${CSS.TableRow} [data-focus-group="${focusGroup}"]:is(input, a)`)) } function circularAdjacent(step = 1, arr, pivot) { return arr[(arr.indexOf(pivot) + step + arr.length) % arr.length] } } function SyntaxJSON(json) { // Capture groups: [string, optional colon, punc] const regex = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")(\s*:)?|([{}\[\],:\s]+)|\S+/g const MAX_NODES = 50_000 let nNodes = 0 const frag = new DocumentFragment() function span(className, textContent) { nNodes++ const s = document.createElement('span') s.className = className s.textContent = textContent frag.appendChild(s) } function text(t) { nNodes++ frag.appendChild(document.createTextNode(t)) } let match let lastIndex = 0 while ((match = regex.exec(json)) !== null) { if (nNodes > MAX_NODES) break if (match.index > lastIndex) text(json.slice(lastIndex, match.index)) const [full, str, colon, punc] = match lastIndex = match.index + full.length if (str && colon) { span(CSS.syntaxKey, str) text(colon) } else if (punc) text(punc) else if (str) span(CSS.syntaxStr, str) else span(CSS.syntaxVal, full) } frag.normalize() text(json.slice(lastIndex)) return frag } function SyntaxXML(xml) { // Capture groups: [tagPunc, tagName, attrName, attrVal] const regex = /(<\/?|\/?>|\?>)|(?<=<\??\/?)([A-Za-z_:][\w:.-]*)|([A-Za-z_:][\w:.-]*)(?==)|("(?:[^"\\]|\\.)*")/g const MAX_NODES = 50_000 let nNodes = 0 const frag = new DocumentFragment() function span(className, textContent) { nNodes++ const s = document.createElement('span') s.className = className s.textContent = textContent frag.appendChild(s) } function text(t) { nNodes++ frag.appendChild(document.createTextNode(t)) } let match let lastIndex = 0 while ((match = regex.exec(xml)) !== null) { if (nNodes > MAX_NODES) break if (match.index > lastIndex) text(xml.slice(lastIndex, match.index)) lastIndex = match.index + match[0].length if (match[1]) span(CSS.syntaxPunc, match[1]) else if (match[2]) span(CSS.syntaxTag, match[2]) else if (match[3]) span(CSS.syntaxAttr, match[3]) else if (match[4]) span(CSS.syntaxAttrVal, match[4]) } text(xml.slice(lastIndex)) frag.normalize() return frag }