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