@discoveryjs/discovery
Version:
Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards
650 lines (649 loc) • 21.3 kB
JavaScript
import { isDocumentFragment } from "./utils/dom.js";
import { hasOwn } from "./utils/object-utils.js";
import { Dictionary } from "./dict.js";
import { queryToConfig } from "./utils/query-to-config.js";
;
const STUB_VIEW_OPTIONS = Object.freeze({ tag: void 0 });
const STUB_CONFIG = Object.freeze({ view: "" });
const tooltipEls = /* @__PURE__ */ new WeakMap();
const rootViewEls = /* @__PURE__ */ new WeakMap();
const configTransitions = /* @__PURE__ */ new WeakMap();
const propsTransitions = /* @__PURE__ */ new WeakMap();
const configOnlyProps = /* @__PURE__ */ new Set([
"view",
"when",
"context",
"data",
"whenData",
"postRender",
"className",
"tooltip"
]);
export function isRawViewConfig(value) {
return typeof value === "string" || Array.isArray(value) || value !== null && typeof value === "object" && typeof value.view === "string";
}
function regConfigTransition(res, from) {
configTransitions.set(res, from);
return res;
}
function collectViewTree(viewRenderer, node, parent, ignoreNodes) {
if (node === null || ignoreNodes.has(node)) {
return;
}
const fragmentNodes = viewRenderer.fragmentEls.get(node);
if (fragmentNodes !== void 0) {
for (const info of fragmentNodes) {
const child = parent.children.find((child2) => child2.view === info);
if (child) {
parent = child;
} else {
parent.children.push(parent = {
node: null,
parent,
view: info,
children: []
});
}
}
}
const rootViewInfo = rootViewEls.get(node);
if (rootViewInfo !== void 0) {
parent.children.push(parent = {
node,
parent,
viewRoot: rootViewInfo,
children: []
});
} else {
const viewInfo = viewRenderer.viewEls.get(node);
if (viewInfo !== void 0) {
parent.children.push(parent = {
node,
parent,
view: viewInfo,
children: []
});
}
}
if (node.nodeType === 1) {
for (let child = node.firstChild; child; child = child.nextSibling) {
collectViewTree(viewRenderer, child, parent, ignoreNodes);
}
}
}
function createDefaultRenderErrorView(view) {
return {
name: "config-error",
options: STUB_VIEW_OPTIONS,
render(el, config) {
el.className = "discovery-buildin-view-render-error";
el.dataset.type = config.type;
el.textContent = config.reason;
if ("config" in config) {
const configEl = el.appendChild(document.createElement("span"));
configEl.className = "toggle-config";
configEl.textContent = "show config...";
configEl.addEventListener("click", () => {
if (el.classList.toggle("expanded")) {
configEl.textContent = "hide config...";
view.render(el, { view: "struct", expanded: 1 }, config.config);
} else {
configEl.textContent = "show config...";
el.lastChild?.remove();
}
});
}
}
};
}
function condition(type, viewRenderer, config, queryData, context, inputData, inputDataIndex, placeholder) {
if (!hasOwn(config, type) || config[type] === void 0) {
return true;
}
if (viewRenderer.host.queryBool(config[type] === true ? "" : config[type], queryData, context)) {
return true;
}
viewRenderer.viewEls.set(placeholder, {
skipped: type,
config,
inputData,
inputDataIndex,
data: queryData,
context
});
return false;
}
function computeClassName(host, className, data, context) {
let classNames = className;
if (typeof classNames === "string" && classNames.startsWith("=")) {
classNames = host.queryFn(classNames.slice(1));
}
if (typeof classNames === "function") {
classNames = classNames(data, context);
}
if (typeof classNames === "string") {
classNames = classNames.trim().split(/\s+/);
}
if (Array.isArray(classNames)) {
classNames = classNames.map((item) => typeof item === "function" ? item(data, context) : item).filter(Boolean);
if (classNames.length) {
return classNames;
}
}
return null;
}
function applyComputedClassName(host, el, className, data, context) {
const classNames = className ? computeClassName(host, className, data, context) : null;
if (classNames !== null) {
el.classList.add(...classNames);
}
}
async function renderDom(viewRenderer, renderer, placeholder, config, props, data, context, inputData, inputDataIndex) {
const { tag } = renderer.options;
const el = tag === null ? document.createDocumentFragment() : document.createElement(tag || "div");
await renderer.render(el, props, data, context);
if (typeof config.postRender === "function") {
await config.postRender(el, config, data, context);
}
const info = {
config,
props,
inputData,
inputDataIndex,
data,
context
};
if (!isDocumentFragment(el)) {
viewRenderer.viewEls.set(el, info);
if (renderer.name) {
el.classList.add(`view-${renderer.name}`);
}
applyComputedClassName(viewRenderer.host, el, config.className, data, context);
if (config.tooltip) {
attachTooltip(viewRenderer.host, el, config.tooltip, data, context);
}
} else {
for (const child of el.childNodes) {
const viewInfos = viewRenderer.fragmentEls.get(child);
if (viewInfos !== void 0) {
viewInfos.unshift(info);
} else {
viewRenderer.fragmentEls.set(child, [info]);
}
}
}
placeholder.replaceWith(el);
}
function renderError(viewRenderer, reason, placeholder, config) {
return renderDom(viewRenderer, viewRenderer.defaultRenderErrorRenderer, placeholder, STUB_CONFIG, {
type: "render",
reason,
config
});
}
function createRenderContext(viewRenderer, name) {
return {
name,
// block() {
// return `view-${name}`;
// },
// blockMod(modifierName, value = true) {
// return `${this.block()}_${modifierName}${value === true ? '' : '_' + value}`;
// },
// element(elementName) {
// return `${this.block()}__${elementName}`;
// },
// elementMod(elementName, modifierName, value = true) {
// return `${this.element(elementName)}_${modifierName}${value === true ? '' : '_' + value}`;
// },
normalizeConfig: viewRenderer.normalizeConfig.bind(viewRenderer),
ensureValidConfig: viewRenderer.ensureValidConfig.bind(viewRenderer),
composeConfig: viewRenderer.composeConfig.bind(viewRenderer),
propsFromConfig: viewRenderer.propsFromConfig.bind(viewRenderer),
computeClassName: computeClassName.bind(null, viewRenderer),
applyComputedClassName: applyComputedClassName.bind(null, viewRenderer),
render: viewRenderer.render.bind(viewRenderer),
listLimit: viewRenderer.listLimit.bind(viewRenderer),
renderList: viewRenderer.renderList.bind(viewRenderer),
maybeMoreButtons: viewRenderer.maybeMoreButtons.bind(viewRenderer),
renderMoreButton: viewRenderer.renderMoreButton.bind(viewRenderer),
tooltip(el, config, data, context) {
if (el && el.nodeType === 1) {
attachTooltip(viewRenderer.host, el, config, data, context);
} else {
viewRenderer.host.logger.warn("A tooltip can be attached to a HTML element only");
}
}
};
}
function attachTooltip(host, el, config, data, context) {
el.classList.add("discovery-view-has-tooltip");
tooltipEls.set(el, { config, data, context });
if (host.view.tooltip === null) {
host.view.tooltip = createTooltip(host);
}
}
function isPopupConfig(value) {
return Boolean(value) && !Array.isArray(value) && typeof value !== "string" && typeof value !== "function" && !value.view;
}
function ensureNumber(value, fallback) {
return Number.isFinite(value) ? Number(value) : fallback;
}
function createTooltip(host) {
let classNames = null;
const popup = new host.view.Popup({
className: "discovery-buildin-view-tooltip",
hoverTriggers: ".discovery-view-has-tooltip",
position: "pointer",
showDelay(triggerEl) {
const { config } = tooltipEls.get(triggerEl) || {};
return isPopupConfig(config) ? config.showDelay ?? true : true;
},
render(el, triggerEl) {
const { config, data, context } = tooltipEls.get(triggerEl) || {};
let position = "pointer";
let positionMode = "natural";
let pointerOffsetX = 3;
let pointerOffsetY = 3;
let content = config;
if (classNames !== null) {
el.classList.remove(...classNames);
classNames = null;
}
if (isPopupConfig(config)) {
classNames = computeClassName(host, config.className, data, context);
if (classNames !== null) {
el.classList.add(...classNames);
}
position = config.position || position;
positionMode = config.positionMode || positionMode;
pointerOffsetX = ensureNumber(config.pointerOffsetX, pointerOffsetX);
pointerOffsetY = ensureNumber(config.pointerOffsetY, pointerOffsetY);
content = config.content;
}
popup.position = position;
popup.positionMode = positionMode;
popup.pointerOffsetX = pointerOffsetX;
popup.pointerOffsetY = pointerOffsetY;
if (content) {
return host.view.render(el, content, data, context);
}
return host.view.render(el, {
view: host.view.defaultRenderErrorRenderer.render,
reason: "Element marked as having a tooltip but related data is not found"
});
}
});
return popup;
}
async function render(viewRenderer, container, config, inputData, inputDataIndex, context) {
if (Array.isArray(config)) {
await Promise.all(config.map(
(config2) => render(viewRenderer, container, config2, inputData, inputDataIndex, context)
));
return;
}
const queryData = inputData && typeof inputDataIndex === "number" ? inputData[inputDataIndex] : inputData;
let renderer = null;
switch (typeof config.view) {
case "function":
renderer = {
name: false,
options: STUB_VIEW_OPTIONS,
render: config.view
};
break;
case "string":
if (config.view === "render") {
const {
config: configQuery = "",
context: contextQuery = ""
} = viewRenderer.propsFromConfig(config, inputData, context);
renderer = {
name: false,
options: { tag: null },
render(el, _, _data) {
const _config = configQuery !== "" ? viewRenderer.host.query(configQuery, queryData, context) : _data;
const _context = viewRenderer.host.query(contextQuery, context, queryData);
return viewRenderer.render(
el,
_config,
_data !== _config ? _data : queryData,
_context
);
}
};
} else if (config.view.startsWith("preset/")) {
const presetName = config.view.substr(7);
renderer = {
name: false,
options: { tag: null },
render: viewRenderer.host.preset.isDefined(presetName) ? viewRenderer.host.preset.get(presetName).render : () => {
}
};
} else {
renderer = viewRenderer.get(config.view) || null;
}
break;
}
if (!container) {
container = document.createDocumentFragment();
}
const placeholder = container.appendChild(document.createComment(""));
if (!renderer) {
const errorMsg = typeof config.view === "string" ? "View `" + config.view + "` is not found" : "Render is not a function";
viewRenderer.host.logger.error(errorMsg, config);
return renderError(viewRenderer, errorMsg, placeholder, config);
}
try {
if (condition("when", viewRenderer, config, queryData, context, inputData, inputDataIndex, placeholder)) {
const renderContext = "context" in config ? await viewRenderer.host.query(config.context, queryData, context) : context;
const renderData = "data" in config ? await viewRenderer.host.query(config.data, queryData, renderContext) : queryData;
if (condition("whenData", viewRenderer, config, renderData, renderContext, inputData, inputDataIndex, placeholder)) {
return await renderDom(
viewRenderer,
renderer,
placeholder,
config,
viewRenderer.propsFromConfig(config, renderData, renderContext),
renderData,
renderContext,
inputData,
inputDataIndex
);
}
}
} catch (e) {
viewRenderer.host.logger.error("View render error:", e.message);
return renderError(viewRenderer, String(e), placeholder, STUB_CONFIG);
}
}
export class ViewPopup {
// FIXME: that a stub for a Popup, use view/Popup instead
el;
position;
positionMode;
pointerOffsetX;
pointerOffsetY;
constructor() {
}
toggle() {
}
async show() {
}
hide() {
}
}
export class ViewRenderer extends Dictionary {
host;
defaultRenderErrorRenderer;
viewEls;
fragmentEls;
tooltip;
Popup = ViewPopup;
constructor(host) {
super();
this.host = host;
this.defaultRenderErrorRenderer = createDefaultRenderErrorView(this);
this.viewEls = /* @__PURE__ */ new WeakMap();
this.fragmentEls = /* @__PURE__ */ new WeakMap();
this.tooltip = null;
}
define(name, _render, _options) {
const options = isRawViewConfig(_render) || typeof _render === "function" ? { ..._options, render: _render } : _render;
const { render: render2 = [], ...optionsWithoutRender } = options;
const { tag, props } = optionsWithoutRender;
return ViewRenderer.define(this, name, Object.freeze({
name,
options: Object.freeze({
...options,
tag: typeof tag === "string" || tag === void 0 ? tag : null,
props: typeof props === "string" ? this.host.queryFn(props) : props
}),
render: typeof render2 === "function" ? render2.bind(createRenderContext(this, name)) : (el, _, data, context) => this.render(el, render2, data, context)
}));
}
normalizeConfig(config) {
if (!config) {
return null;
}
if (Array.isArray(config)) {
const arrayOfConfigs = [];
for (const configElement of config) {
const normalizedConfig = this.normalizeConfig(configElement);
if (normalizedConfig !== null) {
if (Array.isArray(normalizedConfig)) {
arrayOfConfigs.push(...normalizedConfig);
} else {
arrayOfConfigs.push(normalizedConfig);
}
}
}
return arrayOfConfigs;
}
if (typeof config === "string") {
const [, prefix, op, query] = config.match(/^(\S+?)([:{])((?:.|\s)+)$/) || [];
if (prefix) {
if (op === "{") {
try {
return regConfigTransition(
queryToConfig(prefix, op + query),
config
);
} catch (error) {
return regConfigTransition(
this.badConfig(config, error),
config
);
}
}
return regConfigTransition({
view: prefix,
data: query
}, config);
}
return regConfigTransition({
view: config
}, config);
} else if (typeof config === "function") {
return regConfigTransition({
view: config
}, config);
}
return config;
}
badConfig(config, error) {
const errorMsg = error?.message || "Unknown error";
this.host.logger.error(errorMsg, { config, error });
return {
view: this.defaultRenderErrorRenderer.render,
type: "config",
reason: errorMsg,
config
};
}
ensureValidConfig(config) {
if (Array.isArray(config)) {
return config.map((item) => this.ensureValidConfig(item)).flat();
}
if (!config || !config.view) {
return this.badConfig(config, new Error(!config ? "Config is not a valid value" : "Option `view` is missed"));
}
return config;
}
composeConfig(config, extension) {
config = this.normalizeConfig(config);
extension = this.normalizeConfig(extension);
if (config && extension) {
return Array.isArray(config) ? config.map((item) => regConfigTransition({ ...item, ...extension }, [item, extension])) : regConfigTransition({ ...config, ...extension }, [config, extension]);
}
return config || extension;
}
propsFromConfig(config, data, context, fn = this.get(config?.view)?.options.props) {
let props = {};
for (const [key, value] of Object.entries(config)) {
if (!configOnlyProps.has(key)) {
props[key] = typeof value === "string" && value.startsWith("=") ? this.host.query(value.slice(1), data, context) : value;
}
}
if (typeof fn === "function") {
const normProps = fn(data, { props, context });
if (normProps !== null && typeof normProps === "object" && normProps !== props) {
propsTransitions.set(normProps, { props, fn });
props = normProps;
}
}
return props;
}
render(container, config, data, context, dataIndex) {
return render(
this,
container,
this.ensureValidConfig(this.normalizeConfig(config)),
data,
dataIndex,
context
);
}
listLimit(value, defaultValue) {
if (value === false) {
return false;
}
if (!value || isNaN(value)) {
return defaultValue;
}
return Math.max(parseInt(value, 10), 0) || defaultValue;
}
renderList(container, itemConfig, data, context, offset = 0, limitOrOptions, moreContainer) {
const options = typeof limitOrOptions === "object" && limitOrOptions !== null ? limitOrOptions : { limit: limitOrOptions, moreContainer };
const { moreContainer: moreContainerEl, onSliceRender } = options;
let { limit = false } = options;
if (limit === false) {
limit = data.length;
}
const result = Promise.all(
data.slice(offset, offset + limit).map(
(_, sliceIndex, slice) => this.render(container, itemConfig, data, {
...context,
index: offset + sliceIndex,
array: data,
sliceIndex,
slice
}, offset + sliceIndex)
)
);
if (typeof onSliceRender === "function") {
result.then(() => onSliceRender(
Math.max(data.length - offset - limit, 0),
offset,
limit,
data.length
));
}
this.maybeMoreButtons(
moreContainerEl || container,
null,
data.length,
offset + limit,
limit,
(offset2) => this.renderList(container, itemConfig, data, context, offset2, options)
);
return result;
}
maybeMoreButtons(container, beforeEl, count, offset, limit, renderMore) {
if (count <= offset) {
return null;
}
const restCount = count - offset;
const buttons = document.createElement("span");
if (restCount > limit) {
this.renderMoreButton(
buttons,
"Show " + limit + " more...",
() => renderMore(offset, limit)
);
}
if (restCount > 0) {
this.renderMoreButton(
buttons,
"Show all the rest " + restCount + " items...",
() => renderMore(offset, Infinity)
);
}
if (buttons !== null) {
buttons.className = "more-buttons";
container.insertBefore(buttons, beforeEl);
}
return buttons;
}
renderMoreButton(container, caption, fn) {
const moreButton = document.createElement("button");
moreButton.className = "more-button";
moreButton.innerHTML = caption;
moreButton.addEventListener("click", () => {
container.remove();
fn();
});
container.appendChild(moreButton);
}
attachTooltip(el, config, data, context) {
attachTooltip(this.host, el, config, data, context);
}
adoptFragment(fragment, probe) {
const info = this.fragmentEls.get(probe);
if (info) {
for (const node of fragment.childNodes) {
this.fragmentEls.set(node, info);
}
}
}
setViewRoot(node, name, props) {
rootViewEls.set(node, {
name,
...props
});
}
getViewTree(ignore) {
const ignoreNodes = new Set(ignore || []);
const result = [];
collectViewTree(this, this.host.dom?.container || null, { parent: null, children: result }, ignoreNodes);
return result;
}
getViewStackTrace(el) {
const { container: root } = this.host.dom;
if (!root || el instanceof Node === false || !root.contains(el)) {
return null;
}
const stack = [];
let cursor = el;
while (cursor !== null && cursor !== root) {
const viewInfo = this.viewEls.get(cursor);
if (viewInfo !== void 0) {
stack.push(viewInfo);
}
cursor = cursor.parentNode;
}
if (stack.length === 0) {
return null;
}
return stack.reverse();
}
getViewConfigTransitionTree(value) {
let deps = configTransitions.get(value) || [];
if (!Array.isArray(deps)) {
deps = [deps];
}
return {
value,
deps: deps.map(this.getViewConfigTransitionTree, this)
};
}
getViewPropsTransition(value) {
const transition = propsTransitions.get(value) || null;
return transition && {
props: transition.props,
fn: transition.fn,
query: transition.fn.query || null
};
}
}