UNPKG

@discoveryjs/discovery

Version:

Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards

668 lines (667 loc) 24.2 kB
import { createElement } from "../../core/utils/dom.js"; import { escapeHtml } from "../../core/utils/html.js"; import { getBoundingRect } from "../../core/utils/layout.js"; import { contextWithoutEditorParams, getParamsFromContext } from "./params.js"; function count(value, one, many) { return value.length ? `${value.length} ${value.length === 1 ? one : many}` : "empty"; } function valueDescriptor(value) { if (Array.isArray(value)) { return `Array (${count(value, "element", "elements")})`; } if (value && typeof value === "object") { return `Object (${count(Object.keys(value), "entry", "entries")})`; } return `Scalar (${value === null ? "null" : typeof value})`; } function svg(tagName, attributes) { const el = document.createElementNS("http://www.w3.org/2000/svg", tagName); if (attributes) { for (const [k, v] of Object.entries(attributes)) { el.setAttribute(k, String(v)); } } return el; } function buildQueryGraph(el, graph, host) { function createBox() { return el.appendChild(createElement("div", "query-graph-box")); } function walk(box, node, path, currentPath) { if (!Array.isArray(currentPath)) { currentPath = []; } if (!box) { box = createBox(); } const isTarget = currentPath.length === 1; const isCurrent = currentPath.length > 1; const nodeEl = box.appendChild(createElement("div", { "data-path": path.join(" "), tabindex: -1, class: `query-graph-node${isTarget ? " target" : isCurrent ? " current" : ""}` })); host.view.attachTooltip(nodeEl, { className: "query-graph-tooltip", content: isTarget ? 'badge:"Current query"' : { view: "source", syntax: '=query ? "jora" : false', source: '=query or "<empty query>"', actionCopySource: false, lineNumber: false } }, { query: node.query }); if (Array.isArray(node.children)) { for (let i = 0; i < node.children.length; i++) { const childEl = walk( box.nextSibling, node.children[i], path.concat(i), currentPath[1] === i ? currentPath.slice(1) : [] ); connections.push([nodeEl, childEl]); } } return nodeEl; } const connections = []; for (let i = 0; i < graph.children.length; i++) { walk(el.firstChild, graph.children[i], [i], graph.current[0] === i ? graph.current : []); } requestAnimationFrame(() => { const svgEl = el.appendChild(svg("svg")); for (const [fromEl, toEl] of connections) { const fromBox = getBoundingRect(fromEl, svgEl); const toBox = getBoundingRect(toEl, svgEl); const x1 = fromBox.right - 2; const y1 = fromBox.top + fromBox.height / 2; const x2 = toBox.left + 2; const y2 = toBox.top + toBox.height / 2; const dx = (x2 - x1) / 3; svgEl.append( y1 === y2 ? svg("line", { stroke: "#888", x1, y1, x2, y2 }) : svg("path", { stroke: "#888", fill: "none", d: `M ${x1} ${y1} C ${x1 + 2 * dx} ${y1} ${x2 - 2 * dx} ${y2} ${x2} ${y2}` }) ); } }); } function normalizeGraph(inputGraph) { const graph = { current: Array.isArray(inputGraph.current) ? inputGraph.current : [], children: Array.isArray(inputGraph.children) ? inputGraph.children : [] }; if (graph.current.length === 0) { graph.current.push(0); } if (graph.children.length === 0) { graph.children.push({}); } return graph; } function getPathInGraph(graph, path) { const result = [graph]; let cursor = graph; for (let i = 0; i < path.length; i++) { cursor = cursor.children?.[path[i]]; result.push(cursor); } return result; } export default function(host, updateHostParams) { const QueryEditorClass = host.view.QueryEditor; const defaultGraph = {}; let expandQueryInput; let expandQueryInputData = NaN; let expandQueryResults = false; let expandQueryResultData = NaN; let currentQuery; let currentView; let currentGraph = normalizeGraph({}); let currentContext; let errorMarker = null; let scheduledCompute = null; let computationCache = []; let queryEditorSuggestionsEl; let queryEditorLiveEditEl; const getQuerySuggestions = (query, offset, data, context) => queryEditorSuggestionsEl.checked ? host.querySuggestions(query, offset, data, context) : null; const queryEditor = new QueryEditorClass(getQuerySuggestions).on( "change", (value) => queryEditorLiveEditEl.checked && updateHostParams({ query: value }, true) ); const queryEngineInfo = host.getQueryEngineInfo(); const queryGraphButtonsEl = createElement("div", "query-graph-actions"); const queryEditorButtonsEl = createElement("div", "buttons"); const queryEditorInputEl = createElement("div", "query-input"); const queryEditorInputDetailsEl = createElement("div", "query-input-details"); const queryEditorResultEl = createElement("div", "data-query-result"); const queryEditorResultDetailsEl = createElement("div", "data-query-result-details"); const queryGraphEl = createElement("div", "query-graph"); const queryPathEl = createElement("div", "query-path"); const queryEditorFormEl = createElement("div", "form query-editor-form", [ queryGraphEl, queryPathEl, createElement("div", "query-editor", [ queryEditor.el ]) ]); queryEditor.inputPanelEl.append( queryGraphButtonsEl, queryEditorInputEl, queryEditorInputDetailsEl, createElement("a", { class: "view-link query-engine", href: queryEngineInfo.link, target: "_blank" }, [ `${queryEngineInfo.name} ${queryEngineInfo.version || ""}` ]) ); queryEditor.outputPanelEl.append( queryEditorResultEl, queryEditorButtonsEl, queryEditorResultDetailsEl ); const hintTooltip = (text) => ({ position: "trigger", className: "hint-tooltip", showDelay: true, content: { view: "context", data: typeof text === "function" ? text : () => text, content: "md" } }); function createSubquery(query = "") { mutateGraph(({ nextGraph, last }) => { if (!Array.isArray(last.children)) { last.children = []; } last.query = currentQuery; last.view = currentView; nextGraph.current.push(last.children.push({}) - 1); return { query, view: void 0, graph: nextGraph }; }); } host.view.render(queryGraphButtonsEl, [ { view: "button", className: "subquery", tooltip: hintTooltip("Create a new query for a result of current one"), onClick: createSubquery }, { view: "button", className: "stash", tooltip: hintTooltip( () => currentGraph.current.length < 2 ? "Stash current query and create a new empty query" : "Stash current query and create a new empty query for current parent" ), onClick() { mutateGraph(({ nextGraph, last, preLast }) => { const preLastChildren = preLast.children || []; last.query = currentQuery; last.view = currentView; nextGraph.current[nextGraph.current.length - 1] = preLastChildren.push({}) - 1; return { query: "", view: void 0, graph: nextGraph }; }); } }, { view: "button", className: "clone", tooltip: hintTooltip("Clone current query"), onClick() { mutateGraph(({ nextGraph, last, preLast }) => { const preLastChildren = preLast.children || []; last.query = currentQuery; last.view = currentView; nextGraph.current[nextGraph.current.length - 1] = preLastChildren.push({}) - 1; return { graph: nextGraph }; }); } }, { view: "button", className: "delete", tooltip: hintTooltip("Delete current query and all the descendants"), onClick() { mutateGraph(({ nextGraph, last, preLast }) => { const index = preLast.children?.indexOf(last) ?? -1; let nextQuery = preLast.query; preLast.children?.splice(index, 1); if (!preLast.children?.length) { preLast.children = void 0; } nextGraph.current.pop(); if (nextGraph.current.length === 0) { const targetIndex = Math.max(0, Math.min(index - 1, (nextGraph.children?.length || 0) - 1)); nextGraph.current.push(targetIndex); nextQuery = nextGraph.children?.[targetIndex]?.query; } return { query: nextQuery, graph: nextGraph }; }); } } ]); queryEditorButtonsEl.append( createElement("label", { class: "view-checkbox suggestions", tabindex: 0 }, [ queryEditorSuggestionsEl = createElement("input", { class: "live-update", type: "checkbox", checked: true, onchange() { queryEditor.focus(); queryEditor.cm.showHint(); } }), createElement("span", "view-checkbox__label", "suggestions") ]), createElement("label", { class: "view-checkbox live-update", tabindex: 0 }, [ queryEditorLiveEditEl = createElement("input", { class: "live-update", type: "checkbox", checked: true, onchange({ target }) { queryEditor.focus(); if (target.checked) { updateHostParams({ query: queryEditor.getValue() }, true); } } }), createElement("span", "view-checkbox__label", "process on input") ]) ); host.view.attachTooltip(queryEditorSuggestionsEl.parentNode, hintTooltip( () => queryEditorSuggestionsEl.checked ? "Query suggestions enabled<br>(click to disable)" : "Query suggestions disabled<br>(click to enable)" )); host.view.attachTooltip(queryEditorLiveEditEl.parentNode, hintTooltip( () => queryEditorLiveEditEl.checked ? "Auto-perform query enabled<br>(click to disable)" : "Auto-perform query disabled<br>(click to enable)" )); host.view.render(queryEditorButtonsEl, { view: "button-primary", content: 'text:"Process"', onClick: () => { computationCache = computationCache.slice(0, currentGraph.current.length - 1); updateHostParams({ query: queryEditor.getValue() }, true); host.scheduleRender("page"); } }); queryPathEl.addEventListener("click", ({ target }) => { const closestQueryPathEl = target.closest(".query-path > *"); const idx = [...queryPathEl.children].indexOf(closestQueryPathEl); if (idx !== -1) { mutateGraph(({ nextGraph, currentPath, last }) => { last.query = currentQuery; nextGraph.current = nextGraph.current.slice(0, idx + 1); return { query: currentPath[idx + 1].query, graph: nextGraph }; }); } }); queryGraphEl.addEventListener("click", ({ target }) => { const path = target.dataset.path; if (typeof path === "string" && path !== currentGraph.current.join(" ")) { mutateGraph(({ nextGraph, last }) => { const nextPath = path.split(" ").map(Number); const nextGraphPath = getPathInGraph(nextGraph, nextPath); const nextTarget = nextGraphPath[nextGraphPath.length - 1]; const nextQuery = nextTarget.query; const nextView = nextTarget.view; nextTarget.query = void 0; nextTarget.view = void 0; last.query = currentQuery; last.view = currentView; nextGraph.current = nextPath; return { query: nextQuery, view: nextView, graph: nextGraph }; }); } }); function updateParams(patch, autofocus = true, replace = false) { updateHostParams(patch, replace); if (autofocus) { setTimeout(() => { queryEditor.focus(); queryEditor.cm.setCursor(queryEditor.cm.lineCount(), 0); }, 0); } } function mutateGraph(fn) { const nextGraph = JSON.parse(JSON.stringify(currentGraph)); const currentPath = getPathInGraph(nextGraph, nextGraph.current); const last = currentPath[currentPath.length - 1]; const preLast = currentPath[currentPath.length - 2]; const params = fn({ nextGraph, currentPath, last, preLast }); updateParams(params, true); } function scheduleCompute(fn) { const id = setTimeout(fn, 16); return () => clearTimeout(id); } function syncInputData(computation) { queryEditorInputEl.innerHTML = ""; queryEditorInputEl.append( createElement("div", { class: "query-input-data", tabindex: -1, onclick() { expandQueryInput = expandQueryInput === "data" ? void 0 : "data"; syncExpandInputData(computation); } }, [ createElement("span", { class: "query-input-variable", "data-name": "input" }, ["@"]), computation.state === "awaiting" ? "Computing..." : computation.state === "canceled" ? "Not available (undefined)" : valueDescriptor(computation.data) ]), createElement("div", { class: "query-input-context", tabindex: -1, onclick() { expandQueryInput = expandQueryInput === "context" ? void 0 : "context"; syncExpandInputData(computation); } }, [ createElement("span", { class: "query-input-variable", "data-name": "context" }, ["#"]), computation.state === "awaiting" ? "Computing..." : computation.state === "canceled" ? "Not available (undefined)" : valueDescriptor(computation.context) ]) ); syncExpandInputData(computation); } function syncExpandInputData(computation) { queryEditor.inputPanelEl.classList.toggle("details-expanded", expandQueryInput !== void 0); queryEditorInputEl.dataset.details = expandQueryInput; if (expandQueryInput) { const newData = computation.state !== "awaiting" && computation.state !== "canceled" ? computation[expandQueryInput] : NaN; if (newData !== expandQueryInputData) { expandQueryInputData = newData; if (computation.state === "awaiting") { queryEditorInputDetailsEl.textContent = "Computing..."; } else if (computation.state === "canceled") { queryEditorInputDetailsEl.textContent = "Not available because one of ancestor queries failed"; } else { queryEditorInputDetailsEl.innerHTML = ""; host.view.render( queryEditorInputDetailsEl, [ { view: "struct", expanded: 1 } ], expandQueryInputData ); } } } else { expandQueryInputData = NaN; queryEditorInputDetailsEl.innerHTML = ""; } } function renderOutputExpander(computation, prelude, message) { const content = [ createElement("span", "query-output-message", Array.isArray(message) ? message : [message]) ]; if (prelude) { content.unshift(createElement("span", "query-output-prelude", [prelude])); } queryEditorResultEl.replaceChildren( createElement("div", { class: "query-result-data" + (computation.state === "failed" ? " error" : ""), tabindex: -1, onclick() { expandQueryResults = !expandQueryResults; syncExpandOutputData(computation); } }, content) ); } function syncOutputData(computation) { if (errorMarker) { errorMarker.clear(); errorMarker = null; } switch (computation.state) { case "canceled": { queryEditor.setValue(computation.query); renderOutputExpander(computation, "Result", "Not available"); break; } case "awaiting": { queryEditor.setValue(computation.query); renderOutputExpander(computation, null, "Avaiting..."); break; } case "computing": { queryEditor.setValue(computation.query, computation.data, computation.context); renderOutputExpander(computation, null, "Computing..."); break; } case "successful": { queryEditor.setValue(computation.query, computation.data, computation.context); renderOutputExpander(computation, "Result", [ valueDescriptor(computation.computed), ` in ${parseInt(computation.duration, 10)}ms` ]); break; } case "failed": { const { error } = computation; const range = error?.details?.loc?.range; const doc = queryEditor.cm.doc; if (Array.isArray(range) && range.length === 2) { const [start, end] = range; errorMarker = error.details?.token === "EOF" || start === end || computation.query[start] === "\n" ? doc.setBookmark( doc.posFromIndex(start), { widget: createElement("span", "discovery-editor-error", " ") } ) : doc.markText( doc.posFromIndex(start), doc.posFromIndex(end), { className: "discovery-editor-error" } ); } queryEditor.setValue(computation.query, computation.data, computation.context); renderOutputExpander(computation, "Error", [ error.message.split(/\n/)[0].replace(/^(Parse error|Bad input).+/s, "Parse error") ]); break; } } syncExpandOutputData(computation); } function syncExpandOutputData(computation) { queryEditor.outputPanelEl.classList.toggle("details-expanded", expandQueryResults); if (expandQueryResults) { const newData = computation.state !== "awaiting" && computation.state !== "canceled" && computation.state !== "computing" ? computation.error || computation.computed : NaN; if (newData !== expandQueryResultData) { expandQueryResultData = newData; switch (computation.state) { case "awaiting": queryEditorResultDetailsEl.innerHTML = '<div class="state-message">Awaiting for all of ancestor queries done<div>'; break; case "canceled": queryEditorResultDetailsEl.innerHTML = '<div class="state-message">Not available because one of ancestor queries failed<div>'; break; case "failed": queryEditorResultDetailsEl.innerHTML = '<div class="discovery-error query-error">' + escapeHtml(computation.error?.message || "") + "</div>"; break; case "successful": queryEditorResultDetailsEl.innerHTML = ""; host.view.render(queryEditorResultDetailsEl, { view: "struct", expanded: 1 }, expandQueryResultData); break; } } } else { expandQueryResultData = NaN; queryEditorResultDetailsEl.innerHTML = ""; } } function syncComputeState(computation) { const path = computation.path.join(" "); const graphNodeEl = queryGraphEl.querySelector(`[data-path="${path}"]`); if (graphNodeEl) { graphNodeEl.dataset.state = computation.state; } if (computationCache[currentGraph.current.length - 1] === computation) { syncInputData(computation); syncOutputData(computation); } } function compute(computeIndex, computeData, computeContext, first = false) { if (scheduledCompute) { scheduledCompute.cancel(); scheduledCompute.computation.state = "canceled"; scheduledCompute = null; } let computeError = false; if (first) { for (let i = computeIndex; i < currentGraph.current.length; i++) { const computation = computationCache[i]; if (computation.state !== "computing") { syncComputeState(computation); } } } for (let i = computeIndex; i < currentGraph.current.length; i++) { const computation = computationCache[i]; const isTarget = i === currentGraph.current.length - 1; if (computation.state === "awaiting") { computation.state = "computing"; } if (computation.state === "failed") { computeError = true; } else if (computeError) { computation.state = "canceled"; } if (computation.state !== "computing") { computeData = computation.computed; computeContext = computation.context; syncComputeState(computation); if (isTarget) { return Promise.resolve(computation); } continue; } return new Promise((resolve, reject) => { computation.data = computeData; computation.context = computeContext; syncComputeState(computation); scheduledCompute = { computation, cancel: scheduleCompute(() => { const startTime = Date.now(); scheduledCompute = null; try { computation.computed = host.query(computation.query, computation.data, computation.context); computation.state = "successful"; } catch (error) { computation.error = error; computation.state = "failed"; } computation.duration = Date.now() - startTime; compute( i, computeData, computeContext ).then(resolve, reject); }) }; }); } return Promise.reject("No computation found"); } function makeComputationPlan(computeData, computeContext) { const graphPath = getPathInGraph(currentGraph, currentGraph.current).slice(1); let firstComputation = -1; let computeError = null; for (let i = 0; i < currentGraph.current.length; i++) { const graphNode = graphPath[i]; const cache = computationCache[i] || {}; const isTarget = i === currentGraph.current.length - 1; const computeQuery = isTarget ? currentQuery : graphNode.query; const computePath = currentGraph.current.slice(0, i + 1); if (firstComputation === -1 && cache.query === computeQuery && cache.data === computeData && cache.context === computeContext && String(cache.path) === String(computePath)) { computeData = cache.computed; computeError = cache.error; continue; } const computation = computationCache[i] = { state: "awaiting", path: currentGraph.current.slice(0, i + 1), query: computeQuery || "", data: void 0, context: void 0, computed: void 0, error: null, duration: 0 }; if (computeError) { computation.state = "canceled"; continue; } if (firstComputation === -1) { firstComputation = i; computation.state = "computing"; computation.data = computeData; computation.context = computeContext; continue; } } return firstComputation !== -1 ? computationCache.slice(firstComputation, currentGraph.current.length) : []; } return { el: queryEditorFormEl, createSubquery, appendToQuery(query) { const newQuery = typeof currentQuery === "string" && currentQuery.trimRight() !== "" ? currentQuery.replace(/(\n[ \t]*)*$/, () => "\n| " + query) : query; updateParams({ query: newQuery }, true, true); }, perform(data, context) { const queryContext = contextWithoutEditorParams(context, currentContext); const pageParams = getParamsFromContext(context); const pageQuery = pageParams.query; const pageView = pageParams.view; const pageGraph = normalizeGraph({ ...pageParams.graph || defaultGraph }); queryGraphButtonsEl.classList.toggle("root", pageGraph.current.length < 2); queryGraphEl.innerHTML = ""; buildQueryGraph(queryGraphEl, pageGraph, host); queryPathEl.innerHTML = ""; for (const node of getPathInGraph(pageGraph, pageGraph.current).slice(1, -1)) { queryPathEl.append(createElement("div", "query", node.query || "")); } currentGraph = pageGraph; currentContext = queryContext; currentQuery = pageQuery; currentView = pageView; makeComputationPlan(data, queryContext); return compute(0, data, queryContext, true); } }; }