From 452a587e236ef642cbc6ae345b58767ea8420cb5 Mon Sep 17 00:00:00 2001 From: Cameron Yick Date: Thu, 12 Oct 2023 20:00:27 -0400 Subject: [PATCH] JavaScript Plugin API, providing custom panels and column menu items Thanks, Cameron Yick. https://github.com/simonw/datasette/pull/2052 Co-authored-by: Simon Willison --- datasette/static/datasette-manager.js | 210 ++++++++++++++++++ datasette/static/table.js | 83 +++++-- datasette/templates/_table.html | 2 + datasette/templates/base.html | 2 + demos/plugins/example_js_manager_plugins.py | 21 ++ demos/plugins/static/table-example-plugins.js | 100 +++++++++ 6 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 datasette/static/datasette-manager.js create mode 100644 demos/plugins/example_js_manager_plugins.py create mode 100644 demos/plugins/static/table-example-plugins.js diff --git a/datasette/static/datasette-manager.js b/datasette/static/datasette-manager.js new file mode 100644 index 00000000..10716cc5 --- /dev/null +++ b/datasette/static/datasette-manager.js @@ -0,0 +1,210 @@ +// Custom events for use with the native CustomEvent API +const DATASETTE_EVENTS = { + INIT: "datasette_init", // returns datasette manager instance in evt.detail +}; + +// Datasette "core" -> Methods/APIs that are foundational +// Plugins will have greater stability if they use the functional hooks- but if they do decide to hook into +// literal DOM selectors, they'll have an easier time using these addresses. +const DOM_SELECTORS = { + /** Should have one match */ + jsonExportLink: ".export-links a[href*=json]", + + /** Event listeners that go outside of the main table, e.g. existing scroll listener */ + tableWrapper: ".table-wrapper", + table: "table.rows-and-columns", + aboveTablePanel: ".above-table-panel", + + // These could have multiple matches + /** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */ + tableHeaders: `table.rows-and-columns th`, + + /** Used to add "where" clauses to query using direct manipulation */ + filterRows: ".filter-row", + /** Used to show top available enum values for a column ("facets") */ + facetResults: ".facet-results [data-column]", +}; + +/** + * Monolith class for interacting with Datasette JS API + * Imported with DEFER, runs after main document parsed + * For now, manually synced with datasette/version.py + */ +const datasetteManager = { + VERSION: window.datasetteVersion, + + // TODO: Should order of registration matter more? + + // Should plugins be allowed to clobber others or is it last-in takes priority? + // Does pluginMetadata need to be serializable, or can we let it be stateful / have functions? + plugins: new Map(), + + registerPlugin: (name, pluginMetadata) => { + if (datasetteManager.plugins.has(name)) { + console.warn(`Warning -> plugin ${name} was redefined`); + } + datasetteManager.plugins.set(name, pluginMetadata); + + // If the plugin participates in the panel... update the panel. + if (pluginMetadata.makeAboveTablePanelConfigs) { + datasetteManager.renderAboveTablePanel(); + } + }, + + /** + * New DOM elements are created on each click, so the data is not stale. + * + * Items + * - must provide label (text) + * - might provide href (string) or an onclick ((evt) => void) + * + * columnMeta is metadata stored on the column header (TH) as a DOMStringMap + * - column: string + * - columnNotNull: boolean + * - columnType: sqlite datatype enum (text, number, etc) + * - isPk: boolean + */ + makeColumnActions: (columnMeta) => { + let columnActions = []; + + // Accept function that returns list of columnActions with keys + // Required: label (text) + // Optional: onClick or href + datasetteManager.plugins.forEach((plugin) => { + if (plugin.makeColumnActions) { + // Plugins can provide multiple columnActions if they want + // If multiple try to create entry with same label, the last one deletes the others + columnActions.push(...plugin.makeColumnActions(columnMeta)); + } + }); + + // TODO: Validate columnAction configs and give informative error message if missing keys. + return columnActions; + }, + + /** + * In MVP, each plugin can only have 1 instance. + * In future, panels could be repeated. We omit that for now since so many plugins depend on + * shared URL state, so having multiple instances of plugin at same time is problematic. + * Currently, we never destroy any panels, we just hide them. + * + * TODO: nicer panel css, show panel selection state. + * TODO: does this hook need to take any arguments? + */ + renderAboveTablePanel: () => { + const aboveTablePanel = document.querySelector( + DOM_SELECTORS.aboveTablePanel + ); + + if (!aboveTablePanel) { + console.warn( + "This page does not have a table, the renderAboveTablePanel cannot be used." + ); + return; + } + + let aboveTablePanelWrapper = aboveTablePanel.querySelector(".panels"); + + // First render: create wrappers. Otherwise, reuse previous. + if (!aboveTablePanelWrapper) { + aboveTablePanelWrapper = document.createElement("div"); + aboveTablePanelWrapper.classList.add("tab-contents"); + const panelNav = document.createElement("div"); + panelNav.classList.add("tab-controls"); + + // Temporary: css for minimal amount of breathing room. + panelNav.style.display = "flex"; + panelNav.style.gap = "8px"; + panelNav.style.marginTop = "4px"; + panelNav.style.marginBottom = "20px"; + + aboveTablePanel.appendChild(panelNav); + aboveTablePanel.appendChild(aboveTablePanelWrapper); + } + + datasetteManager.plugins.forEach((plugin, pluginName) => { + const { makeAboveTablePanelConfigs } = plugin; + + if (makeAboveTablePanelConfigs) { + const controls = aboveTablePanel.querySelector(".tab-controls"); + const contents = aboveTablePanel.querySelector(".tab-contents"); + + // Each plugin can make multiple panels + const configs = makeAboveTablePanelConfigs(); + + configs.forEach((config, i) => { + const nodeContentId = `${pluginName}_${config.id}_panel-content`; + + // quit if we've already registered this plugin + // TODO: look into whether plugins should be allowed to ask + // parent to re-render, or if they should manage that internally. + if (document.getElementById(nodeContentId)) { + return; + } + + // Add tab control button + const pluginControl = document.createElement("button"); + pluginControl.textContent = config.label; + pluginControl.onclick = () => { + contents.childNodes.forEach((node) => { + if (node.id === nodeContentId) { + node.style.display = "block"; + } else { + node.style.display = "none"; + } + }); + }; + controls.appendChild(pluginControl); + + // Add plugin content area + const pluginNode = document.createElement("div"); + pluginNode.id = nodeContentId; + config.render(pluginNode); + pluginNode.style.display = "none"; // Default to hidden unless you're ifrst + + contents.appendChild(pluginNode); + }); + + // Let first node be selected by default + if (contents.childNodes.length) { + contents.childNodes[0].style.display = "block"; + } + } + }); + }, + + /** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */ + selectors: DOM_SELECTORS, + + // Future API ideas + // Fetch page's data in array, and cache so plugins could reuse it + // Provide knowledge of what datasette JS or server-side via traditional console autocomplete + // State helpers: URL params https://github.com/simonw/datasette/issues/1144 and localstorage + // UI Hooks: command + k, tab manager hook + // Should we notify plugins that have dependencies + // when all dependencies were fulfilled? (leaflet, codemirror, etc) + // https://github.com/simonw/datasette-leaflet -> this way + // multiple plugins can all request the same copy of leaflet. +}; + +const initializeDatasette = () => { + // Hide the global behind __ prefix. Ideally they should be listening for the + // DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window. + + window.__DATASETTE__ = datasetteManager; + console.debug("Datasette Manager Created!"); + + const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, { + detail: datasetteManager, + }); + + document.dispatchEvent(initDatasetteEvent); +}; + +/** + * Main function + * Fires AFTER the document has been parsed + */ +document.addEventListener("DOMContentLoaded", function () { + initializeDatasette(); +}); diff --git a/datasette/static/table.js b/datasette/static/table.js index 51e901a5..778457c5 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -17,7 +17,8 @@ var DROPDOWN_ICON_SVG = ` `; -(function () { +/** Main initialization function for Datasette Table interactions */ +const initDatasetteTable = function (manager) { // Feature detection if (!window.URLSearchParams) { return; @@ -68,13 +69,11 @@ var DROPDOWN_ICON_SVG = ` { - var tableWrapper = document.querySelector(".table-wrapper"); - if (tableWrapper) { - tableWrapper.addEventListener("scroll", closeMenu); - } - }); + + const tableWrapper = document.querySelector(manager.selectors.tableWrapper); + if (tableWrapper) { + tableWrapper.addEventListener("scroll", closeMenu); + } document.body.addEventListener("click", (ev) => { /* was this click outside the menu? */ var target = ev.target; @@ -85,7 +84,8 @@ var DROPDOWN_ICON_SVG = ` { + // Remove items from previous render. We assume entries have unique labels. + const existingItems = menuList.querySelectorAll(`li`); + Array.from(existingItems).filter(item => item.innerText === itemConfig.label).forEach(node => { + node.remove(); + }); + + const newLink = document.createElement('a'); + newLink.textContent = itemConfig.label; + newLink.href = itemConfig.href ?? '#'; + if (itemConfig.onClick) { + newLink.onclick = itemConfig.onClick; + } + + // Attach new elements to DOM + const menuItem = document.createElement('li'); + menuItem.appendChild(newLink); + menuList.appendChild(menuItem); + }); + } + var svg = document.createElement("div"); svg.innerHTML = DROPDOWN_ICON_SVG; svg = svg.querySelector("*"); @@ -197,21 +230,21 @@ var DROPDOWN_ICON_SVG = ` { if (!th.querySelector("a")) { return; } var icon = svg.cloneNode(true); - icon.addEventListener("click", iconClicked); + icon.addEventListener("click", onTableHeaderClick); th.appendChild(icon); }); -})(); +}; /* Add x buttons to the filter rows */ -(function () { +function addButtonsToFilterRows(manager) { var x = "✖"; - var rows = Array.from(document.querySelectorAll(".filter-row")).filter((el) => + var rows = Array.from(document.querySelectorAll(manager.selectors.filterRow)).filter((el) => el.querySelector(".filter-op") ); rows.forEach((row) => { @@ -234,13 +267,13 @@ var DROPDOWN_ICON_SVG = ` +
{% if display_rows %}
diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 83f87614..ceead217 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -7,6 +7,8 @@ {% for url in extra_css_urls %} {% endfor %} + + {% for url in extra_js_urls %} {% endfor %} diff --git a/demos/plugins/example_js_manager_plugins.py b/demos/plugins/example_js_manager_plugins.py new file mode 100644 index 00000000..7db45464 --- /dev/null +++ b/demos/plugins/example_js_manager_plugins.py @@ -0,0 +1,21 @@ +from datasette import hookimpl + +# Test command: +# datasette fixtures.db \ --plugins-dir=demos/plugins/ +# \ --static static:demos/plugins/static + +# Create a set with view names that qualify for this JS, since plugins won't do anything on other pages +# Same pattern as in Nteract data explorer +# https://github.com/hydrosquall/datasette-nteract-data-explorer/blob/main/datasette_nteract_data_explorer/__init__.py#L77 +PERMITTED_VIEWS = {"table", "query", "database"} + + +@hookimpl +def extra_js_urls(view_name): + print(view_name) + if view_name in PERMITTED_VIEWS: + return [ + { + "url": f"/static/table-example-plugins.js", + } + ] diff --git a/demos/plugins/static/table-example-plugins.js b/demos/plugins/static/table-example-plugins.js new file mode 100644 index 00000000..8c19d9a6 --- /dev/null +++ b/demos/plugins/static/table-example-plugins.js @@ -0,0 +1,100 @@ +/** + * Example usage of Datasette JS Manager API + */ + +document.addEventListener("datasette_init", function (evt) { + const { detail: manager } = evt; + // === Demo plugins: remove before merge=== + addPlugins(manager); +}); + +/** + * Examples for to test datasette JS api + */ +const addPlugins = (manager) => { + + manager.registerPlugin("column-name-plugin", { + version: 0.1, + makeColumnActions: (columnMeta) => { + const { column } = columnMeta; + + return [ + { + label: "Copy name to clipboard", + onClick: (evt) => copyToClipboard(column), + }, + { + label: "Log column metadata to console", + onClick: (evt) => console.log(column), + }, + ]; + }, + }); + + manager.registerPlugin("panel-plugin-graphs", { + version: 0.1, + makeAboveTablePanelConfigs: () => { + return [ + { + id: 'first-panel', + label: "First", + render: node => { + const description = document.createElement('p'); + description.innerText = 'Hello world'; + node.appendChild(description); + } + }, + { + id: 'second-panel', + label: "Second", + render: node => { + const iframe = document.createElement('iframe'); + iframe.src = "https://observablehq.com/embed/@d3/sortable-bar-chart?cell=viewof+order&cell=chart"; + iframe.width = 800; + iframe.height = 635; + iframe.frameborder = '0'; + node.appendChild(iframe); + } + }, + ]; + }, + }); + + manager.registerPlugin("panel-plugin-maps", { + version: 0.1, + makeAboveTablePanelConfigs: () => { + return [ + { + // ID only has to be unique within a plugin, manager namespaces for you + id: 'first-map-panel', + label: "Map plugin", + // datasette-vega, leafleft can provide a "render" function + render: node => node.innerHTML = "Here sits a map", + }, + { + id: 'second-panel', + label: "Image plugin", + render: node => { + const img = document.createElement('img'); + img.src = 'https://datasette.io/static/datasette-logo.svg' + node.appendChild(img); + }, + } + ]; + }, + }); + + // Future: dispatch message to some other part of the page with CustomEvent API + // Could use to drive filter/sort query builder actions without page refresh. +} + + + +async function copyToClipboard(str) { + try { + await navigator.clipboard.writeText(str); + } catch (err) { + /** Rejected - text failed to copy to the clipboard. Browsers didn't give permission */ + console.error('Failed to copy: ', err); + } +}