@discoveryjs/discovery
Version:
Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards
668 lines (667 loc) • 24.2 kB
JavaScript
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);
}
};
}