Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Javascript Plugin API (Custom panels, column menu items with JS actions) #2052

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
305a4de
feat: first cut at datasette JS api
hydrosquall Mar 24, 2023
ab9bc5f
test: register table plugin using a test API
hydrosquall Mar 24, 2023
b30e609
feat(datasette): proof of concept - create JS plugin for adding colum…
hydrosquall Apr 2, 2023
2993896
Update API to support passing in other types of column metadata, such…
hydrosquall Apr 2, 2023
d348dfe
refactor: rename the dropdown icon in table.js
hydrosquall Apr 2, 2023
50cdf9b
feat: make datasette manager responsible for filter-rows API
hydrosquall Apr 2, 2023
e6b9301
add simple DOM selector plugin hooks example
hydrosquall Apr 2, 2023
d82b80a
first cut at a panel manager API
hydrosquall Apr 2, 2023
c2e7218
move demo plugins into own directory
hydrosquall Apr 2, 2023
3d48754
tidy: remove stale functions / todos from datasette JS plugin proposal
hydrosquall Apr 2, 2023
2d92b93
refactor(datasette): rename getColumnHeaderItems => getColumnActions …
hydrosquall Apr 14, 2023
37d7e3f
prf: InitDatasette -> datasette_init for case sensitivity / using pre…
hydrosquall Apr 20, 2023
8f744e7
prf: prefer has() to get() for set membership
hydrosquall Apr 20, 2023
9b773c7
prf: Convert column metadata names in column actions
hydrosquall Apr 20, 2023
06b4829
prf: provide plugin hook even for tables with 0 rows
hydrosquall Apr 20, 2023
cf504fe
prf: add JS handling for situations where tables have no rows
hydrosquall Apr 20, 2023
0536f5d
prf: getColumnActions -> makeColumnActions for a more action oriented…
hydrosquall Apr 20, 2023
cef6740
prf: getAboveTablePanelConfigs -> makeAboveTablePanelConfigs for more…
hydrosquall Apr 20, 2023
92e0916
feat(datasette-manager): Sync manager version with python version
hydrosquall Jul 1, 2023
a134f91
dev: relocate example plugins file to a demos folder
hydrosquall Jul 1, 2023
eb1f408
fix: move example plugins to correct directory, dynamically set datas…
hydrosquall Jul 11, 2023
cf5a9df
fix: return early if the page doesn't have a table element to attach to
hydrosquall Jul 11, 2023
b0aca42
Tiny whitespace tweak
simonw Oct 12, 2023
a7e7942
Fixed typo in plugin example
simonw Oct 12, 2023
e4538e7
Fixed typos in comments
simonw Oct 12, 2023
8ae479c
Ran Prettier
simonw Oct 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions datasette/static/datasette-manager.js
Original file line number Diff line number Diff line change
@@ -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!");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was worried that there might be browsers in which this would cause an error (because console.debug might not be defined), but as far as I can tell this has been supported in every modern browser for years at this point: https://console.spec.whatwg.org/ and https://developer.mozilla.org/en-US/docs/Web/API/console#browser_compatibility - so this is fine.


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();
});
83 changes: 64 additions & 19 deletions datasette/static/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
</svg>`;

(function () {
/** Main initialization function for Datasette Table interactions */
const initDatasetteTable = function (manager) {
// Feature detection
if (!window.URLSearchParams) {
return;
Expand Down Expand Up @@ -68,13 +69,11 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
menu.style.display = "none";
menu.classList.remove("anim-scale-in");
}
// When page loads, add scroll listener on .table-wrapper
document.addEventListener("DOMContentLoaded", () => {
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;
Expand All @@ -85,7 +84,8 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
closeMenu();
}
});
function iconClicked(ev) {

function onTableHeaderClick(ev) {
ev.preventDefault();
ev.stopPropagation();
var th = ev.target;
Expand Down Expand Up @@ -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.display = "block";
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");
svg.innerHTML = DROPDOWN_ICON_SVG;
svg = svg.querySelector("*");
Expand All @@ -197,21 +230,21 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
menu.style.display = "none";
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) => {
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) => {
Expand All @@ -234,13 +267,13 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
a.style.display = "none";
}
});
})();
};

/* Set up datalist autocomplete for filter values */
(function () {
function initAutocompleteForFilterValues(manager) {
function createDataLists() {
var facetResults = document.querySelectorAll(
".facet-results [data-column]"
manager.selectors.facetResults
);
Array.from(facetResults).forEach(function (facetResult) {
// Use link text from all links in the facet result
Expand All @@ -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) {
if (event.target.name === "_filter_column") {
event.target
.closest(".filter-row")
.closest(manager.selectors.filterRow)
.querySelector(".filter-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);
});
2 changes: 2 additions & 0 deletions datasette/templates/_table.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="table-wrapper">
<table class="rows-and-columns">
Expand Down
2 changes: 2 additions & 0 deletions datasette/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
{% for url in extra_css_urls %}
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
{% endfor %}
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
{% 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>
{% endfor %}
Expand Down
21 changes: 21 additions & 0 deletions demos/plugins/example_js_manager_plugins.py
Original file line number Diff line number Diff line change
@@ -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",
}
]
Loading
Loading