UNPKG

@discoveryjs/discovery

Version:

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

650 lines (649 loc) 21.3 kB
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 }; } }