mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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 <swillison@gmail.com>
This commit is contained in:
parent
4b534b89a5
commit
452a587e23
6 changed files with 399 additions and 19 deletions
210
datasette/static/datasette-manager.js
Normal file
210
datasette/static/datasette-manager.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue