UNPKG

@triply/yasr

Version:

Yet Another SPARQL Resultset GUI

610 lines (572 loc) 23.5 kB
import { EventEmitter } from "events"; import { merge, filter, mapValues, uniqueId } from "lodash-es"; import getDefaults from "./defaults"; import { Plugin } from "./plugins"; import { Storage as YStorage, drawFontAwesomeIconAsSvg, drawSvgStringAsElement, removeClass, addClass, hasClass, } from "@triply/yasgui-utils"; import Parser from "./parsers"; export { default as Parser } from "./parsers"; import { addScript, addCss, sanitize } from "./helpers"; import * as faDownload from "@fortawesome/free-solid-svg-icons/faDownload"; import * as faQuestionCircle from "@fortawesome/free-solid-svg-icons/faQuestionCircle"; require("./main.scss"); export interface PersistentConfig { selectedPlugin?: string; pluginsConfig?: { [pluginName: string]: any }; } export interface Yasr { on(event: "change", listener: (instance: Yasr) => void): this; emit(event: "change", instance: Yasr): boolean; on(event: "draw", listener: (instance: Yasr, plugin: Plugin<any>) => void): this; emit(event: "draw", instance: Yasr, plugin: Plugin<any>): boolean; on(event: "drawn", listener: (instance: Yasr, plugin: Plugin<any>) => void): this; emit(event: "drawn", instance: Yasr, plugin: Plugin<any>): boolean; on(event: "toggle-help", listener: (instance: Yasr) => void): this; emit(event: "toggle-help", instance: Yasr): boolean; } export class Yasr extends EventEmitter { public results?: Parser; public rootEl: HTMLDivElement; public headerEl: HTMLDivElement; public fallbackInfoEl: HTMLDivElement; public resultsEl: HTMLDivElement; public pluginControls!: HTMLDivElement; public config: Config; public storage: YStorage; public plugins: { [name: string]: Plugin<any> } = {}; // private persistentConfig: PersistentConfig; public helpDrawn: Boolean = false; private drawnPlugin: string | undefined; private selectedPlugin: string | undefined; // Utils public utils = { addScript: addScript, addCSS: addCss, sanitize: sanitize }; constructor(parent: HTMLElement, conf: Partial<Config> = {}, data?: any) { super(); if (!parent) throw new Error("No parent passed as argument. Dont know where to draw YASR"); this.rootEl = document.createElement("div"); this.rootEl.className = "yasr"; parent.appendChild(this.rootEl); this.config = merge({}, Yasr.defaults, conf); //Do some post processing this.storage = new YStorage(Yasr.storageNamespace); this.getConfigFromStorage(); this.headerEl = document.createElement("div"); this.headerEl.className = "yasr_header"; this.rootEl.appendChild(this.headerEl); this.fallbackInfoEl = document.createElement("div"); this.fallbackInfoEl.className = "yasr_fallback_info"; this.rootEl.appendChild(this.fallbackInfoEl); this.resultsEl = document.createElement("div"); this.resultsEl.className = "yasr_results"; this.resultsEl.id = uniqueId("resultsId"); this.rootEl.appendChild(this.resultsEl); this.initializePlugins(); this.drawHeader(); const resp = data || this.getResponseFromStorage(); if (resp) this.setResponse(resp); } private getConfigFromStorage() { const storageId = this.getStorageId(this.config.persistenceLabelConfig); if (storageId) { const persistentConfig: PersistentConfig | undefined = this.storage.get(storageId); if (persistentConfig) { this.selectedPlugin = persistentConfig.selectedPlugin; for (const pluginName in persistentConfig.pluginsConfig) { const pConf = persistentConfig.pluginsConfig[pluginName]; if (pConf && this.config.plugins[pluginName]) this.config.plugins[pluginName].dynamicConfig = pConf; } } } } /** * Ask the [[ Config.errorRenderers | configured error renderers ]] for a * custom rendering of [[`error`]]. * * @param error the error for which to find a custom rendering * @return the first custom rendering found, or `undefined` if none was found. */ public async renderError(error: Parser.ErrorSummary): Promise<HTMLElement | undefined> { // Chain the errorRenderers to get the first special rendering of the error // if no special rendering is found, return undefined let element: HTMLElement | undefined = undefined; if (this.config.errorRenderers !== undefined) { for (const renderer of this.config.errorRenderers) { element = await renderer(error); if (element !== undefined) break; // we found the first special case, so return that! } } return element; } public getStorageId(label: string, getter?: Config["persistenceId"]): string | undefined { const persistenceId = getter || this.config.persistenceId; if (!persistenceId) return; if (typeof persistenceId === "string") return persistenceId + "_" + label; return persistenceId(this) + "_" + label; } public somethingDrawn() { return !!this.resultsEl.children.length; } /** * Empties fallback element * CSS will make sure the outline is hidden when empty */ public emptyFallbackElement() { // This is quicker than `<node>.innerHtml = ""` while (this.fallbackInfoEl.firstChild) this.fallbackInfoEl.removeChild(this.fallbackInfoEl.firstChild); } public getSelectedPluginName(): string { return this.selectedPlugin || this.config.defaultPlugin; } public getSelectedPlugin() { if (this.plugins[this.getSelectedPluginName()]) { return this.plugins[this.getSelectedPluginName()]; } console.warn(`Tried using plugin ${this.getSelectedPluginName()}, but seems this plugin isnt registered in yasr.`); } /** * Update selectors, based on whether they can actuall draw something, and which plugin is currently selected */ private updatePluginSelectors(enabledPlugins?: string[]) { if (!this.pluginSelectorsEl) return; for (const pluginName in this.config.plugins) { const plugin = this.plugins[pluginName]; if (plugin && !plugin.hideFromSelection) { if (enabledPlugins) { if (enabledPlugins.indexOf(pluginName) >= 0) { //make sure enabled removeClass(this.pluginSelectorsEl.querySelector(".select_" + pluginName), "disabled"); } else { //make sure disabled addClass(this.pluginSelectorsEl.querySelector(".select_" + pluginName), "disabled"); } } if (pluginName === this.getSelectedPluginName()) { addClass(this.pluginSelectorsEl.querySelector(".select_" + pluginName), "selected"); } else { removeClass(this.pluginSelectorsEl.querySelector(".select_" + pluginName), "selected"); } } } } private getCompatiblePlugins(): string[] { if (!this.results) return Object.keys( filter(this.config.plugins, (val) => (typeof val === "object" && val.enabled) || val === true) ); const supportedPlugins: { name: string; priority: number }[] = []; for (const pluginName in this.plugins) { if (this.plugins[pluginName].canHandleResults()) { supportedPlugins.push({ name: pluginName, priority: this.plugins[pluginName].priority }); } } return supportedPlugins.sort((p1, p2) => p2.priority - p1.priority).map((p) => p.name); } public draw() { this.updateHelpButton(); this.updateResponseInfo(); if (!this.results) return; const compatiblePlugins = this.getCompatiblePlugins(); if (this.drawnPlugin && this.getSelectedPluginName() !== this.drawnPlugin) { while (this.pluginControls.firstChild) { this.pluginControls.firstChild.remove(); } this.plugins[this.drawnPlugin].destroy?.(); } let pluginToDraw: string | undefined; if (this.getSelectedPlugin() && this.getSelectedPlugin()?.canHandleResults()) { pluginToDraw = this.getSelectedPluginName(); // When present remove fallback box this.emptyFallbackElement(); } else if (compatiblePlugins[0]) { if (this.drawnPlugin) { this.plugins[this.drawnPlugin].destroy?.(); } pluginToDraw = compatiblePlugins[0]; // Signal to create the plugin+ this.fillFallbackBox(pluginToDraw); } if (pluginToDraw) { this.drawnPlugin = pluginToDraw; this.emit("draw", this, this.plugins[pluginToDraw]); const plugin = this.plugins[pluginToDraw]; const initPromise = plugin.initialize ? plugin.initialize() : Promise.resolve(); initPromise.then( async () => { if (pluginToDraw) { // make sure to clear the object _here_ // otherwise we run into race conditions when draw is executed // shortly after each other, and the plugin uses an initialize function // as a result, things can be rendered _twice_ while (this.resultsEl.firstChild) this.resultsEl.firstChild.remove(); await this.plugins[pluginToDraw].draw(this.config.plugins[pluginToDraw].dynamicConfig); this.emit("drawn", this, this.plugins[pluginToDraw]); this.updateExportHeaders(); this.updatePluginSelectors(compatiblePlugins); } }, (_e) => console.error ); } else { this.resultsEl.textContent = "cannot render result"; this.updateExportHeaders(); this.updatePluginSelectors(compatiblePlugins); } } //just an alias for `draw`. That way, we've got a consistent api with yasqe public refresh() { this.draw(); } public destroy() { if (this.drawnPlugin) this.plugins[this.drawnPlugin]?.destroy?.(); this.removeAllListeners(); this.rootEl.remove(); } getPrefixes(): Prefixes { if (this.config.prefixes) { if (typeof this.config.prefixes === "function") { return this.config.prefixes(this); } else { return this.config.prefixes; } } return {}; } public selectPlugin(plugin: string) { if (this.selectedPlugin === plugin) { // Don't re-render when selecting the same plugin. Also see #1893 return; } if (this.config.plugins[plugin]) { this.selectedPlugin = plugin; } else { console.warn(`Plugin ${plugin} does not exist.`); this.selectedPlugin = this.config.defaultPlugin; } this.storeConfig(); this.emit("change", this); this.updatePluginSelectors(); this.draw(); } private pluginSelectorsEl!: HTMLUListElement; drawPluginSelectors() { this.pluginSelectorsEl = document.createElement("ul"); this.pluginSelectorsEl.className = "yasr_btnGroup"; const pluginOrder = this.config.pluginOrder; Object.keys(this.config.plugins) .sort() .forEach((plugin) => { if (pluginOrder.indexOf(plugin) === -1) pluginOrder.push(plugin); }); for (const pluginName of pluginOrder) { if (!this.config.plugins[pluginName] || !this.config.plugins[pluginName].enabled) { continue; } const plugin = this.plugins[pluginName]; if (!plugin) continue; //plugin not loaded if (plugin.hideFromSelection) continue; const name = plugin.label || pluginName; const button = document.createElement("button"); addClass(button, "yasr_btn", "select_" + pluginName); button.title = name; button.type = "button"; button.setAttribute("aria-label", `Shows ${name} view`); if (plugin.getIcon) { const icon = plugin.getIcon(); if (icon) { // icon.className = ''; addClass(icon, "plugin_icon"); button.appendChild(icon); } } const nameEl = document.createElement("span"); nameEl.textContent = name; button.appendChild(nameEl); button.addEventListener("click", () => this.selectPlugin(pluginName)); const li = document.createElement("li"); li.appendChild(button); this.pluginSelectorsEl.appendChild(li); } if (this.pluginSelectorsEl.children.length >= 1) this.headerEl.appendChild(this.pluginSelectorsEl); this.updatePluginSelectors(); } private fillFallbackBox(fallbackElement?: string) { this.emptyFallbackElement(); // Do not show fallback render warnings for plugins without a selector. if (this.plugins[fallbackElement || this.drawnPlugin || ""]?.hideFromSelection) return; const selectedPlugin = this.getSelectedPlugin(); const fallbackPluginLabel = this.plugins[fallbackElement || this.drawnPlugin || ""]?.label || fallbackElement || this.drawnPlugin; const selectedPluginLabel = selectedPlugin?.label || this.getSelectedPluginName(); const textElement = document.createElement("p"); textElement.innerText = `Could not render results with the ${selectedPluginLabel} plugin, the results currently are rendered with the ${fallbackPluginLabel} plugin. ${ this.getSelectedPlugin()?.helpReference ? "See " : "" }`; if (selectedPlugin?.helpReference) { const linkElement = document.createElement("a"); linkElement.innerText = `${selectedPluginLabel} documentation`; linkElement.href = selectedPlugin.helpReference; linkElement.rel = "noopener noreferrer"; linkElement.target = "_blank"; textElement.append(linkElement); textElement.innerHTML += " for more information."; } this.fallbackInfoEl.appendChild(textElement); } private drawPluginElement() { const spaceElement = document.createElement("div"); addClass(spaceElement, "space_element"); this.headerEl.appendChild(spaceElement); this.pluginControls = document.createElement("div"); this.pluginControls.setAttribute("id", "yasr_plugin_control"); addClass(this.pluginControls, "yasr_plugin_control"); this.pluginControls.setAttribute("aria-controls", this.resultsEl.id); this.headerEl.appendChild(this.pluginControls); } private drawHeader() { this.drawPluginSelectors(); this.drawResponseInfo(); this.drawPluginElement(); this.drawDownloadIcon(); this.drawDocumentationButton(); } private downloadBtn: HTMLAnchorElement | undefined; private drawDownloadIcon() { this.downloadBtn = document.createElement("a"); addClass(this.downloadBtn, "yasr_btn", "yasr_downloadIcon", "btn_icon"); this.downloadBtn.download = ""; // should default to the file name of the blob this.downloadBtn.setAttribute("aria-label", "Download Results"); this.downloadBtn.setAttribute("tabindex", "0"); // anchor elements with no href are not automatically included in the tabindex this.downloadBtn.setAttribute("role", "button"); const iconEl = drawSvgStringAsElement(drawFontAwesomeIconAsSvg(faDownload)); iconEl.setAttribute("aria-hidden", "true"); this.downloadBtn.appendChild(iconEl); this.downloadBtn.addEventListener("click", () => { if (hasClass(this.downloadBtn, "disabled")) return; this.download(); }); this.downloadBtn.addEventListener("keydown", (event) => { // needed for accessibility if (event.code === "Space" || event.code === "Enter") { if (hasClass(this.downloadBtn, "disabled")) return; this.download(); } }); this.headerEl.appendChild(this.downloadBtn); } private dataElement!: HTMLDivElement; private drawResponseInfo() { this.dataElement = document.createElement("div"); addClass(this.dataElement, "yasr_response_chip"); this.headerEl.appendChild(this.dataElement); this.updateResponseInfo(); } private updateResponseInfo() { let innerText = ""; if (this.results) { removeClass(this.dataElement, "empty"); const bindings = this.results.getBindings(); if (bindings) { innerText += `${bindings.length} result${bindings.length === 1 ? "" : "s"}`; // Set amount of results } const responseTime = this.results.getResponseTime(); if (responseTime) { if (!innerText) innerText = "Response"; const time = responseTime / 1000; innerText += ` in ${time} second${time === 1 ? "" : "s"}`; } } else { addClass(this.dataElement, "empty"); } this.dataElement.innerText = innerText; } private updateHelpButton() { const selectedPlugin = this.getSelectedPlugin(); if (selectedPlugin?.helpReference) { const titleLabel = `View documentation of ${selectedPlugin.label || this.getSelectedPluginName()}`; this.documentationLink.href = selectedPlugin.helpReference; this.documentationLink.title = titleLabel; this.documentationLink.setAttribute("aria-label", titleLabel); removeClass(this.documentationLink, "disabled"); } else { addClass(this.documentationLink, "disabled"); this.documentationLink.title = "This plugin doesn't have a help reference yet. Please contact the maintainer to fix this"; } } updateExportHeaders() { if (this.downloadBtn && this.drawnPlugin) { this.downloadBtn.title = ""; const plugin = this.plugins[this.drawnPlugin]; if (plugin && plugin.download) { const downloadInfo = plugin.download(this.config.getDownloadFileName?.()); removeClass(this.downloadBtn, "disabled"); if (downloadInfo) { if (downloadInfo.title) this.downloadBtn.title = downloadInfo.title; return; } } this.downloadBtn.title = "Download not supported"; addClass(this.downloadBtn, "disabled"); } } private documentationLink!: HTMLAnchorElement; private drawDocumentationButton() { this.documentationLink = document.createElement("a"); addClass(this.documentationLink, "yasr_btn", "yasr_external_ref_btn"); this.documentationLink.appendChild(drawSvgStringAsElement(drawFontAwesomeIconAsSvg(faQuestionCircle))); this.documentationLink.href = "//triply.cc/docs/yasgui"; this.documentationLink.target = "_blank"; this.documentationLink.rel = "noopener noreferrer"; this.headerEl.appendChild(this.documentationLink); // We can do this as long as the help-element is the last item in the row } download() { if (!this.drawnPlugin) return; const currentPlugin = this.plugins[this.drawnPlugin]; if (currentPlugin && currentPlugin.download) { const downloadInfo = currentPlugin.download(this.config.getDownloadFileName?.()); if (!downloadInfo) return; const data = downloadInfo.getData(); let downloadUrl: string; if (data.startsWith("data:")) { downloadUrl = data; } else { const blob = new Blob([data], { type: downloadInfo.contentType ?? "text/plain" }); downloadUrl = window.URL.createObjectURL(blob); } const mockLink = document.createElement("a"); mockLink.href = downloadUrl; mockLink.download = downloadInfo.filename; mockLink.click(); } } public handleLocalStorageQuotaFull(_e: any) { console.warn("Localstorage quota exceeded. Clearing all queries"); Yasr.clearStorage(); } public getResponseFromStorage() { const storageId = this.getStorageId(this.config.persistenceLabelResponse); if (storageId) { return this.storage.get(storageId); } } public getPersistentConfig(): PersistentConfig { return { selectedPlugin: this.getSelectedPluginName(), pluginsConfig: mapValues(this.config.plugins, (plugin) => plugin.dynamicConfig), }; } //This doesnt store the plugin complete config. Only those configs we want persisted public storePluginConfig(pluginName: string, conf: any) { this.config.plugins[pluginName].dynamicConfig = conf; this.storeConfig(); this.emit("change", this); } private storeConfig() { const storageId = this.getStorageId(this.config.persistenceLabelConfig); if (storageId) { this.storage.set( storageId, this.getPersistentConfig(), this.config.persistencyExpire, this.handleLocalStorageQuotaFull ); } } private storeResponse() { const storageId = this.getStorageId(this.config.persistenceLabelResponse); if (storageId && this.results) { const storeObj = this.results.getAsStoreObject(this.config.maxPersistentResponseSize); if (storeObj && !storeObj.error) { this.storage.set(storageId, storeObj, this.config.persistencyExpire, this.handleLocalStorageQuotaFull); } else { //remove old string; this.storage.remove(storageId); } } } public setResponse(data: any, duration?: number) { if (!data) return; this.results = new Parser(data, duration); this.draw(); this.storeResponse(); } private initializePlugins() { for (const plugin in this.config.plugins) { if (!this.config.plugins[plugin]) continue; //falsy value, so assuming it should be disabled if (Yasr.plugins[plugin]) { this.plugins[plugin] = new (<any>Yasr.plugins[plugin])(this); } else { console.warn("Wanted to initialize plugin " + plugin + " but could not find a matching registered plugin"); } } } static defaults: Config = getDefaults(); static plugins: { [key: string]: typeof Plugin & { defaults?: any } } = {}; static registerPlugin(name: string, plugin: typeof Plugin, enable = true) { Yasr.plugins[name] = plugin; if (enable) { Yasr.defaults.plugins[name] = { enabled: true }; } else { Yasr.defaults.plugins[name] = { enabled: false }; } } /** * Collection of Promises to load external scripts used by Yasr Plugins * That way, the plugins wont load similar scripts simultaneously */ static Dependencies: { [name: string]: Promise<any> } = {}; static storageNamespace = "triply"; static clearStorage() { const storage = new YStorage(Yasr.storageNamespace); storage.removeNamespace(); } } export type Prefixes = { [prefixLabel: string]: string }; export interface PluginConfig { dynamicConfig?: any; staticConfig?: any; enabled?: boolean; } export interface Config { persistenceId: ((yasr: Yasr) => string) | string | null; persistenceLabelResponse: string; persistenceLabelConfig: string; maxPersistentResponseSize: number; persistencyExpire: number; getPlainQueryLinkToEndpoint: (() => string | undefined) | undefined; getDownloadFileName?: () => string | undefined; plugins: { [pluginName: string]: PluginConfig }; pluginOrder: string[]; defaultPlugin: string; prefixes: Prefixes | ((yasr: Yasr) => Prefixes); /** * Custom renderers for errors. * Allow multiple to be able to add new custom renderers without having to * overwrite or explicitly call previously added or default ones. */ errorRenderers?: ((error: Parser.ErrorSummary) => Promise<HTMLElement | undefined>)[]; } export function registerPlugin(name: string, plugin: typeof Plugin, enable = true) { Yasr.plugins[name] = plugin; if (enable) { Yasr.defaults.plugins[name] = { enabled: true }; } else { Yasr.defaults.plugins[name] = { enabled: false }; } } import * as YasrPluginTable from "./plugins/table"; import * as YasrPluginBoolean from "./plugins/boolean"; import * as YasrPluginResponse from "./plugins/response"; import * as YasrPluginError from "./plugins/error"; Yasr.registerPlugin("table", YasrPluginTable.default as any); Yasr.registerPlugin("boolean", YasrPluginBoolean.default as any); Yasr.registerPlugin("response", YasrPluginResponse.default as any); Yasr.registerPlugin("error", YasrPluginError.default as any); export { Plugin, DownloadInfo } from "./plugins"; export default Yasr;