UNPKG

@discoveryjs/discovery

Version:

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

482 lines (481 loc) 15.9 kB
import { createElement } from "../core/utils/dom.js"; import { injectStyles } from "../core/utils/inject-styles.js"; import { deepEqual } from "../core/utils/compare.js"; import { hasOwn } from "../core/utils/object-utils.js"; import { Model } from "./model.js"; import { Observer } from "../core/observer.js"; import { ColorScheme } from "../core/color-scheme.js"; import { ViewModelNavigation } from "../nav/index.js"; import { PageRenderer } from "../core/page.js"; import { ViewRenderer } from "../core/view.js"; import { PresetRenderer } from "../core/preset.js"; import inspector from "../extensions/inspector.js"; import * as views from "../views/index.js"; import * as pages from "../pages/index.js"; const renderSubjects = ["nav", "sidebar", "page"]; const noop = () => { }; const defaultEncodeParams = (params) => params; const defaultDecodeParams = (pairs) => Object.fromEntries(pairs); function setDatasetValue(el, key, value) { if (value) { el.dataset[key] = value; } else { delete el.dataset[key]; } } function getPageOption(host, pageId, name, fallback) { const options = host.page.get(pageId)?.options; return options !== void 0 && hasOwn(options, name) ? options[name] : fallback; } function getPageMethod(host, pageId, name, fallback) { const method = getPageOption(host, pageId, name, fallback); return typeof method === "function" ? method : fallback; } export class ViewModel extends Model { compact; colorScheme; inspectMode; view; nav; preset; page; #renderScheduler; #renderSchedulerTimeout; defaultPageId; discoveryPageId; reportToDiscoveryRedirect; // TODO: to make bookmarks work, remove sometime in the future pageId; pageRef; pageParams; pageAnchor; pageHash; dom; queryExtensions; constructor(options) { const { container, styles, extensions, logLevel, compact, darkmode = "light-only", // for backward compatibility darkmodePersistent = false, // for backward compatibility colorScheme = darkmode, colorSchemePersistent = darkmodePersistent, defaultPage, defaultPageId, discoveryPageId, reportToDiscoveryRedirect = true, inspector: useInspector = false } = options || {}; super({ ...options, logLevel: logLevel || "perf", extensions: void 0 }); if ("darkmode" in options || "darkmodePersistent" in options) { this.logger.warn('ViewModel "darkmode" option is deprecated, use "colorScheme" instead'); } this.compact = Boolean(compact); this.colorScheme = new ColorScheme(colorScheme, colorSchemePersistent); this.inspectMode = new Observer(false); this.initDom(styles); this.view = new ViewRenderer(this); this.nav = new ViewModelNavigation(this); this.preset = new PresetRenderer(this.view); this.page = new PageRenderer(this, this.view); this.#renderScheduler = /* @__PURE__ */ new Set(); this.#renderSchedulerTimeout = 16; this.defaultPageId = defaultPageId || "default"; this.discoveryPageId = discoveryPageId || "discovery"; this.reportToDiscoveryRedirect = Boolean(reportToDiscoveryRedirect); this.pageId = this.defaultPageId; this.pageRef = null; this.pageParams = {}; this.pageAnchor = null; this.pageHash = this.encodePageHash(this.pageId, this.pageRef, this.pageParams, this.pageAnchor); this.apply(views); this.apply(pages); if (defaultPage) { this.page.define(this.defaultPageId, defaultPage); } if (useInspector) { this.apply(inspector); } this.apply(extensions); this.setPageHash(this.pageHash); this.setContainer(container); this.scheduleRender(); for (const { name, page } of this.objectMarkers.values) { if (page && !this.page.isDefined(page)) { this.logger.error(`Page reference "${page}" in object marker "${name}" doesn't exist`); } } } initRenderTriggers() { this.on("context", () => this.scheduleRender()); this.on("unloadData", () => this.scheduleRender()); this.on("pageStateChange", () => this.scheduleRender("nav", "page")); this.on("pageAnchorChange", () => this.applyPageAnchor()); this.action.on("define", () => this.scheduleRender("nav", "page")).on("revoke", () => this.scheduleRender("nav", "page")); this.page.on("define", (pageId) => { if (this.pageId === pageId) { this.setPageHash(this.pageHash); this.scheduleRender("page"); } }); } // // Data // async setData(data, options) { const { render = true } = options || {}; await super.setData(data, options); if (render) { this.scheduleRender(); } } async setDataProgress(data, context, options) { const { dataset, progressbar } = options || {}; this.cancelScheduledRender(); this.emit( "startSetData", (...args) => progressbar?.subscribeSync(...args) || (() => { }) ); await progressbar?.setState({ stage: "prepare" }); await this.setData(data, { dataset, setPrepareWorkTitle: progressbar?.setStateStep.bind(progressbar), render: false }); await progressbar?.setState({ stage: "initui" }); this.scheduleRender(); await Promise.all([ this.dom.wrapper.parentNode ? this.dom.ready : true, this.enforceScheduledRenders() ]); progressbar?.finish(); } // // UI // initDom(styles) { const wrapper = createElement("div", "discovery"); const shadow = wrapper.attachShadow({ mode: "open" }); const container = shadow.appendChild(createElement("div")); const pageContent = createElement("article"); const nav = createElement("div", "discovery-nav discovery-hidden-in-dzen"); const sidebar = createElement("nav", "discovery-sidebar discovery-hidden-in-dzen"); const content = createElement("main", "discovery-content", [pageContent]); const readyStyles = injectStyles( shadow, this.info.icon ? styles?.concat(`.discovery-root{--discovery-app-icon:url(${JSON.stringify(this.info.icon)}`) : styles ); this.dom = { ready: readyStyles, wrapper, root: shadow, container, nav, sidebar, content, pageContent, detachColorScheme: this.colorScheme.subscribe( (value) => container.classList.toggle("discovery-root-darkmode", value === "dark"), true ) }; container.classList.add("discovery-root", "discovery"); container.append(nav, sidebar, content); shadow.addEventListener("click", (event) => { const linkEl = event.target?.closest("a"); if (!linkEl || linkEl.getAttribute("target")) { return; } if (linkEl.origin !== location.origin || linkEl.pathname !== location.pathname) { return; } event.preventDefault(); event.stopPropagation(); if (!linkEl.classList.contains("ignore-href")) { if (!this.setPageHash(linkEl.hash)) { this.applyPageAnchor(); } ; } }, true); } setContainer(container) { if (container instanceof HTMLElement) { container.append(this.dom.wrapper); } else { this.dom.wrapper.remove(); } } disposeDom() { if (typeof this.dom.detachColorScheme === "function") { this.dom.detachColorScheme(); this.dom.detachColorScheme = null; } this.dom.container.remove(); this.dom = null; } addGlobalEventListener(type, listener, options) { document.addEventListener(type, listener, options); return () => document.removeEventListener(type, listener, options); } addHostElEventListener(type, listener, options) { const el = this.dom.container; el.addEventListener(type, listener, options); return () => el.removeEventListener(type, listener, options); } // // Render common // scheduleRender(...subjects) { let allSubjects = false; if (subjects.length === 0) { allSubjects = true; subjects = [...renderSubjects]; } for (const subject of subjects) { this.#renderScheduler.add(subject); } if (this.#renderScheduler.timer) { clearTimeout(this.#renderScheduler.timer); } this.#renderScheduler.timer = setTimeout( () => this.enforceScheduledRenders(), this.#renderSchedulerTimeout ); this.logger.debug(`Scheduled renders: ${[...this.#renderScheduler].join(", ")} (requested: ${allSubjects ? "all" : subjects.join(", ")})`); } async enforceScheduledRenders() { const renders = [...this.#renderScheduler]; this.logger.debug(`Enforce scheduled renders: ${renders.join(", ") || "none"}`); this.cancelScheduledRender(); for (const subject of renders) { switch (subject) { case "nav": await this.renderNav(); break; case "sidebar": await this.renderSidebar(); break; case "page": await this.renderPage(); break; } } if (this.initRenderTriggers !== noop) { this.initRenderTriggers(); this.initRenderTriggers = noop; this.#renderSchedulerTimeout = 0; } } cancelScheduledRender(subject) { const scheduledRenders = [...this.#renderScheduler]; if (subject) { this.#renderScheduler.delete(subject); } else { this.#renderScheduler.clear(); } if (this.#renderScheduler.size === 0 && this.#renderScheduler.timer) { clearTimeout(this.#renderScheduler.timer); this.#renderScheduler.timer = null; } if (this.#renderScheduler.size !== scheduledRenders.length) { this.logger.debug(`Canceled renders: ${scheduledRenders.join(", ")}`); } } getRenderContext() { return { page: this.pageId, id: this.pageRef, params: this.pageParams, ...this.getContext() }; } // // Nav // renderNav() { this.cancelScheduledRender("nav"); return this.nav.render(this.dom.nav, this.data, this.getRenderContext()); } // // Sidebar // renderSidebar() { this.cancelScheduledRender("sidebar"); if (this.hasDatasets() && this.view.isDefined("sidebar")) { const renderStartTime = Date.now(); const data = this.data; const context = this.getRenderContext(); this.view.setViewRoot(this.dom.sidebar, "sidebar", { data, context }); this.dom.sidebar.innerHTML = ""; return this.view.render(this.dom.sidebar, "sidebar", data, context).finally(() => this.logger.perf(`Sidebar rendered in ${Date.now() - renderStartTime}ms`)); } } // // Page // encodePageHash(pageId, pageRef = null, pageParams, pageAnchor = null) { const encodedPageId = pageId || this.defaultPageId; const encodeParams = getPageMethod(this, pageId, "encodeParams", defaultEncodeParams); const encodedParams = encodeParams(pageParams || {}); return super.encodePageHash( encodedPageId !== this.defaultPageId ? encodedPageId : "", pageRef, encodedParams, pageAnchor ); } decodePageHash(hash) { const { pageId, pageRef, pageParams, pageAnchor } = super.decodePageHash( hash, (pageId2) => getPageMethod(this, pageId2 || this.defaultPageId, "decodeParams", defaultDecodeParams) ); return { pageId: pageId || this.defaultPageId, pageRef, pageParams, pageAnchor }; } setPageHashState(pageState = {}, replace = false) { return this.setPageHashStateWithAnchor({ ...pageState, anchor: null }, replace); } overridePageHashState(pageState, replace = false) { return this.setPageHashState({ ...this.getPageHashState(), ...pageState }, replace); } getPageHashState() { return { id: this.pageId, ref: this.pageRef, params: this.pageParams }; } setPageHashStateWithAnchor(pageStateWithAnchor, replace = false) { const { id: pageId = null, ref: pageRef = null, params: pageParams = {}, anchor: pageAnchor = null } = pageStateWithAnchor; return this.setPageHash( this.encodePageHash(pageId || this.defaultPageId, pageRef, pageParams, pageAnchor), replace ); } overridePageHashStateWithAnchor(pageState, replace = false) { return this.setPageHashStateWithAnchor({ ...this.getPageHashStateWithAnchor(), ...pageState }, replace); } getPageHashStateWithAnchor() { return { ...this.getPageHashState(), anchor: this.pageAnchor }; } setPage(pageId, pageRef, pageParams, replace = false) { return this.setPageHashState({ id: pageId, ref: pageRef, params: pageParams }, replace); } setPageRef(pageRef = null, replace = false) { return this.overridePageHashStateWithAnchor({ ref: pageRef }, replace); } setPageParams(pageParams, replace = false) { return this.overridePageHashStateWithAnchor({ params: pageParams }, replace); } setPageAnchor(pageAnchor, replace = false) { return this.overridePageHashStateWithAnchor({ anchor: pageAnchor }, replace); } setPageHash(hash, replace = false) { if (!hash.startsWith("#")) { hash = "#" + hash; } if (hash.startsWith("#!")) { hash = this.stripAnchorFromHash(this.pageHash) + (hash.length > 2 ? "&!anchor=" + hash.slice(2) : ""); } const { pageId, pageRef, pageParams, pageAnchor } = this.decodePageHash(hash); if (this.reportToDiscoveryRedirect && pageId === "report" && this.discoveryPageId !== "report") { setTimeout(() => this.pageId === "report" && this.setPageHashStateWithAnchor({ id: this.discoveryPageId, ref: pageRef, params: pageParams, anchor: pageAnchor }, true)); } if (this.pageId !== pageId || this.pageRef !== pageRef || !deepEqual(this.pageParams, pageParams)) { const prev = this.getPageHashState(); this.pageId = pageId; this.pageRef = pageRef; this.pageParams = pageParams; this.emit("pageStateChange", prev); } if (pageAnchor !== this.pageAnchor) { const prev = this.pageAnchor; this.pageAnchor = pageAnchor; this.emit("pageAnchorChange", prev); } if (hash !== this.pageHash) { this.pageHash = hash; this.emit("pageHashChange", replace); return true; } return false; } applyPageAnchor(onlyIfPageAnchorPresent = false) { const pageEl = this.dom.pageContent; if (this.pageAnchor) { const anchorEl = pageEl.querySelector("#" + CSS.escape("!anchor:" + this.pageAnchor)); if (anchorEl) { const pageHeaderEl = pageEl.querySelector(".view-page-header"); anchorEl.style.scrollMargin = pageHeaderEl ? pageHeaderEl.offsetHeight + "px" : ""; anchorEl.scrollIntoView(true); } } else if (!onlyIfPageAnchorPresent) { this.dom.content.scrollTop = 0; } } renderPage() { this.cancelScheduledRender("page"); const { data, pageId, pageParams, compact } = this; const context = this.getRenderContext(); this.logger.debug(`Start page "${pageId}" rendering...`); const renderStartTime = Date.now(); const { pageEl, renderState, config } = this.page.render( this.dom.pageContent, pageId, data, context ); this.view.setViewRoot(pageEl, `Page: ${pageId}`, { inspectable: false, config, data, context }); this.dom.pageContent = pageEl; setDatasetValue(this.dom.container, "page", pageId); setDatasetValue(this.dom.container, "dzen", Boolean(pageParams.dzen)); setDatasetValue(this.dom.container, "compact", Boolean(compact)); renderState.then(async () => { await this.dom.ready; this.applyPageAnchor(true); }).catch((e) => { this.logger.error(`Page "${pageId}" render error:`, e); }); return renderState.finally(() => { this.logger.perf(`Page "${pageId}" render done in ${Date.now() - renderStartTime}ms`); }); } }