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();
|
||||||
|
});
|
||||||
|
|
@ -17,7 +17,8 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
(function () {
|
/** Main initialization function for Datasette Table interactions */
|
||||||
|
const initDatasetteTable = function (manager) {
|
||||||
// Feature detection
|
// Feature detection
|
||||||
if (!window.URLSearchParams) {
|
if (!window.URLSearchParams) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -68,13 +69,11 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
menu.style.display = "none";
|
menu.style.display = "none";
|
||||||
menu.classList.remove("anim-scale-in");
|
menu.classList.remove("anim-scale-in");
|
||||||
}
|
}
|
||||||
// When page loads, add scroll listener on .table-wrapper
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
const tableWrapper = document.querySelector(manager.selectors.tableWrapper);
|
||||||
var tableWrapper = document.querySelector(".table-wrapper");
|
if (tableWrapper) {
|
||||||
if (tableWrapper) {
|
tableWrapper.addEventListener("scroll", closeMenu);
|
||||||
tableWrapper.addEventListener("scroll", closeMenu);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
document.body.addEventListener("click", (ev) => {
|
document.body.addEventListener("click", (ev) => {
|
||||||
/* was this click outside the menu? */
|
/* was this click outside the menu? */
|
||||||
var target = ev.target;
|
var target = ev.target;
|
||||||
|
|
@ -85,7 +84,8 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
closeMenu();
|
closeMenu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function iconClicked(ev) {
|
|
||||||
|
function onTableHeaderClick(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
var th = ev.target;
|
var th = ev.target;
|
||||||
|
|
@ -185,7 +185,40 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
menu.style.left = menuLeft + "px";
|
menu.style.left = menuLeft + "px";
|
||||||
menu.style.display = "block";
|
menu.style.display = "block";
|
||||||
menu.classList.add("anim-scale-in");
|
menu.classList.add("anim-scale-in");
|
||||||
|
|
||||||
|
// Custom menu items on each render
|
||||||
|
// Plugin hook: allow adding JS-based additional menu items
|
||||||
|
const columnActionsPayload = {
|
||||||
|
columnName: th.dataset.column,
|
||||||
|
columnNotNull: th.dataset.columnNotNull === '1',
|
||||||
|
columnType: th.dataset.columnType,
|
||||||
|
isPk: th.dataset.isPk === '1'
|
||||||
|
};
|
||||||
|
const columnItemConfigs = manager.makeColumnActions(columnActionsPayload);
|
||||||
|
|
||||||
|
const menuList = menu.querySelector('ul');
|
||||||
|
columnItemConfigs.forEach(itemConfig => {
|
||||||
|
// 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");
|
var svg = document.createElement("div");
|
||||||
svg.innerHTML = DROPDOWN_ICON_SVG;
|
svg.innerHTML = DROPDOWN_ICON_SVG;
|
||||||
svg = svg.querySelector("*");
|
svg = svg.querySelector("*");
|
||||||
|
|
@ -197,21 +230,21 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
menu.style.display = "none";
|
menu.style.display = "none";
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
var ths = Array.from(document.querySelectorAll(".rows-and-columns th"));
|
var ths = Array.from(document.querySelectorAll(manager.selectors.tableHeaders));
|
||||||
ths.forEach((th) => {
|
ths.forEach((th) => {
|
||||||
if (!th.querySelector("a")) {
|
if (!th.querySelector("a")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var icon = svg.cloneNode(true);
|
var icon = svg.cloneNode(true);
|
||||||
icon.addEventListener("click", iconClicked);
|
icon.addEventListener("click", onTableHeaderClick);
|
||||||
th.appendChild(icon);
|
th.appendChild(icon);
|
||||||
});
|
});
|
||||||
})();
|
};
|
||||||
|
|
||||||
/* Add x buttons to the filter rows */
|
/* Add x buttons to the filter rows */
|
||||||
(function () {
|
function addButtonsToFilterRows(manager) {
|
||||||
var x = "✖";
|
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")
|
el.querySelector(".filter-op")
|
||||||
);
|
);
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
|
|
@ -234,13 +267,13 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
a.style.display = "none";
|
a.style.display = "none";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
};
|
||||||
|
|
||||||
/* Set up datalist autocomplete for filter values */
|
/* Set up datalist autocomplete for filter values */
|
||||||
(function () {
|
function initAutocompleteForFilterValues(manager) {
|
||||||
function createDataLists() {
|
function createDataLists() {
|
||||||
var facetResults = document.querySelectorAll(
|
var facetResults = document.querySelectorAll(
|
||||||
".facet-results [data-column]"
|
manager.selectors.facetResults
|
||||||
);
|
);
|
||||||
Array.from(facetResults).forEach(function (facetResult) {
|
Array.from(facetResults).forEach(function (facetResult) {
|
||||||
// Use link text from all links in the facet result
|
// Use link text from all links in the facet result
|
||||||
|
|
@ -266,9 +299,21 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
document.body.addEventListener("change", function (event) {
|
document.body.addEventListener("change", function (event) {
|
||||||
if (event.target.name === "_filter_column") {
|
if (event.target.name === "_filter_column") {
|
||||||
event.target
|
event.target
|
||||||
.closest(".filter-row")
|
.closest(manager.selectors.filterRow)
|
||||||
.querySelector(".filter-value")
|
.querySelector(".filter-value")
|
||||||
.setAttribute("list", "datalist-" + event.target.value);
|
.setAttribute("list", "datalist-" + event.target.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
};
|
||||||
|
|
||||||
|
// Ensures Table UI is initialized only after the Manager is ready.
|
||||||
|
document.addEventListener("datasette_init", function (evt) {
|
||||||
|
const { detail: manager } = evt;
|
||||||
|
|
||||||
|
// Main table
|
||||||
|
initDatasetteTable(manager);
|
||||||
|
|
||||||
|
// Other UI functions with interactive JS needs
|
||||||
|
addButtonsToFilterRows(manager);
|
||||||
|
initAutocompleteForFilterValues(manager);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
||||||
|
<div class="above-table-panel"> </div>
|
||||||
{% if display_rows %}
|
{% if display_rows %}
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table class="rows-and-columns">
|
<table class="rows-and-columns">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
{% for url in extra_css_urls %}
|
{% for url in extra_css_urls %}
|
||||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||||
|
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||||
{% for url in extra_js_urls %}
|
{% for url in extra_js_urls %}
|
||||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
21
demos/plugins/example_js_manager_plugins.py
Normal file
21
demos/plugins/example_js_manager_plugins.py
Normal file
|
|
@ -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",
|
||||||
|
}
|
||||||
|
]
|
||||||
100
demos/plugins/static/table-example-plugins.js
Normal file
100
demos/plugins/static/table-example-plugins.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue