Replace jump_start() hook with JavaScript makeJumpSections() hook

This commit is contained in:
Simon Willison 2026-05-22 20:48:42 -07:00
commit 8568320a23
14 changed files with 288 additions and 112 deletions

View file

@ -2039,22 +2039,6 @@ class Datasette:
links.extend(extra_links)
return links
async def jump_start():
html_bits = []
for hook in pm.hook.jump_start(
datasette=self,
actor=request.actor if request else None,
request=request or None,
):
extra_html = await await_me_maybe(hook)
if not extra_html:
continue
if isinstance(extra_html, (list, tuple)):
html_bits.extend(extra_html)
else:
html_bits.append(extra_html)
return Markup("").join(Markup(html) for html in html_bits)
template_context = {
**context,
**{
@ -2063,7 +2047,6 @@ class Datasette:
"urls": self.urls,
"actor": request.actor if request else None,
"menu_links": menu_links,
"jump_start": jump_start,
"display_actor": display_actor,
"show_logout": request is not None
and "ds_actor" in request.cookies

View file

@ -159,12 +159,7 @@ def menu_links(datasette, actor, request):
@hookspec
def jump_items_sql(datasette, actor, request):
"""SQL fragments for extra items in the jump menu, optionally with display_name"""
@hookspec
def jump_start(datasette, actor, request):
"""HTML to display in the jump menu before the user types"""
"""SQL fragments for extra items in the jump menu"""
@hookspec

View file

@ -9,7 +9,6 @@ from typing import Any
class JumpSQL:
sql: str
params: dict[str, Any] | None = None
has_display_name: bool = False
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")

View file

@ -82,6 +82,19 @@ const datasetteManager = {
return columnActions;
},
makeJumpSections: (context) => {
let jumpSections = [];
datasetteManager.plugins.forEach((plugin) => {
if (plugin.makeJumpSections) {
const sections = plugin.makeJumpSections(context) || [];
jumpSections.push(...sections);
}
});
return jumpSections;
},
/**
* 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
@ -192,7 +205,6 @@ const initializeDatasette = () => {
// 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,

View file

@ -447,9 +447,46 @@ class NavigationSearch extends HTMLElement {
this.renderResults();
}
startContentHtml() {
const template = this.querySelector("template[data-jump-start]");
return template ? template.innerHTML.trim() : "";
jumpSections() {
const manager = window.__DATASETTE__;
if (!manager || typeof manager.makeJumpSections !== "function") {
return [];
}
const sections = manager.makeJumpSections({
navigationSearch: this,
});
return Array.isArray(sections)
? sections.filter(
(section) => section && typeof section.render === "function",
)
: [];
}
jumpSectionsHtml(jumpSections) {
return jumpSections
.map((section, index) => {
const id = section.id
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
: "";
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
})
.join("");
}
renderJumpSections(container, jumpSections) {
jumpSections.forEach((section, index) => {
const node = container.querySelector(
`[data-jump-section-index="${index}"]`,
);
if (!node) {
return;
}
section.render(node, {
navigationSearch: this,
container,
input: this.shadowRoot.querySelector(".search-input"),
});
});
}
resultItemHtml(match, index) {
@ -484,9 +521,9 @@ class NavigationSearch extends HTMLElement {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
const showStartContent = !input.value.trim();
const startContent = showStartContent ? this.startContentHtml() : "";
const startBlock = startContent
? `<div class="jump-start-content">${startContent}</div>`
const jumpSections = showStartContent ? this.jumpSections() : [];
const startBlock = showStartContent
? this.jumpSectionsHtml(jumpSections)
: "";
const recentItems = showStartContent ? this.loadRecentItems() : [];
const defaultMatches = showStartContent ? [] : this.matches;
@ -507,6 +544,7 @@ class NavigationSearch extends HTMLElement {
if (renderedMatches.length === 0) {
if (startBlock) {
container.innerHTML = startBlock;
this.renderJumpSections(container, jumpSections);
} else if (showStartContent) {
container.innerHTML = "";
} else {
@ -529,6 +567,7 @@ class NavigationSearch extends HTMLElement {
)
.join("");
container.innerHTML = startBlock + recentHtml + defaultHtml;
this.renderJumpSections(container, jumpSections);
// Scroll selected item into view
if (this.selectedIndex >= 0) {

View file

@ -71,13 +71,6 @@
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}{% if navigation_search_js_hash is defined %}?{{ navigation_search_js_hash }}{% endif %}" defer></script>
{% if jump_start is defined %}
{% set jump_start_html = jump_start() %}
{% else %}
{% set jump_start_html = "" %}
{% endif %}
<navigation-search url="/-/jump">{% if jump_start_html %}
<template data-jump-start>{{ jump_start_html }}</template>
{% endif %}</navigation-search>
<navigation-search url="/-/jump"></navigation-search>
</body>
</html>

View file

@ -980,7 +980,6 @@ class JumpView(BaseView):
FROM allowed_databases
""",
params=database_params,
has_display_name=True,
),
JumpSQL(
sql=f"""
@ -1004,7 +1003,6 @@ class JumpView(BaseView):
AND catalog_views.view_name = allowed_tables.child
""",
params=table_params,
has_display_name=True,
),
JumpSQL(
sql=f"""
@ -1031,7 +1029,6 @@ class JumpView(BaseView):
AND query_display_names.query_name = allowed_queries.child
""",
params={**query_params, **query_display_names_params},
has_display_name=True,
),
]
@ -1095,7 +1092,7 @@ class JumpView(BaseView):
search_text,
sort_key,
source,
{"display_name" if fragment.has_display_name else "NULL AS display_name"}
display_name
FROM (
{fragment_sql}
)

View file

@ -274,7 +274,7 @@ Other changes
~~~~~~~~~~~~~
- The internal ``catalog_views`` table now tracks SQLite views alongside tables in the introspection database. (:issue:`2495`)
- Hitting the ``/`` brings up a search interface for navigating to tables that the current user can view. A new ``/-/tables`` endpoint supports this functionality. (:issue:`2523`)
- Hitting the ``/`` brings up a search interface for navigating to databases, tables, views, canned queries and plugin-provided items that the current user can view. A new ``/-/jump`` endpoint supports this functionality, and JavaScript plugins can add custom blank-state sections using ``makeJumpSections()``. (:issue:`2523`)
- Datasette attempts to detect some configuration errors on startup.
- Datasette now supports Python 3.14 and no longer tests against Python 3.9.

View file

@ -155,7 +155,7 @@ The endpoint supports a ``?q=`` query parameter for filtering items by name.
Canned queries with a configured ``title`` also include a ``display_name`` in
their results, and can be found by searching for that title. Plugins can provide
the same extra field from ``jump_items_sql`` by returning a ``display_name``
column and setting ``JumpSQL(..., has_display_name=True)``.
column.
`Jump example <https://latest.datasette.io/-/jump>`_:

View file

@ -58,6 +58,48 @@ JavaScript plugins are blocks of code that can be registered with Datasette usin
The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
.. _javascript_plugins_makeJumpSections:
makeJumpSections()
~~~~~~~~~~~~~~~~~~
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search.
Each object should have the following:
``id`` - string
A unique string ID for the section, for example ``agent-chat``
``render(node, context)`` - function
A function that will be called with a DOM node to render the section into
The ``context`` object has the following keys:
``navigationSearch``
The ``<navigation-search>`` custom element instance.
This example shows how a plugin might add a button for starting a new chat:
.. code-block:: javascript
document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('agent-plugin', {
version: 0.1,
makeJumpSections: () => {
return [
{
id: 'agent-chat',
render: node => {
node.innerHTML = '<button type="button">Start a new chat</button>';
node.querySelector('button').addEventListener('click', () => {
location.href = '/-/agent/new';
});
}
}
];
}
});
});
.. _javascript_plugins_makeAboveTablePanelConfigs:
makeAboveTablePanelConfigs()

View file

@ -1928,7 +1928,8 @@ The SQL query must return these columns:
``source``
A string identifying the plugin that supplied the result.
If the SQL query also returns a ``display_name`` column, set ``has_display_name=True`` on the ``JumpSQL`` object. Datasette will return that value as ``display_name`` in the JSON API, and the jump menu will show it as the primary readable label with ``name`` shown underneath.
``display_name``
A human-readable label for the result, or ``NULL``. Datasette returns this as ``display_name`` in the JSON API, and the jump menu shows it as the primary readable label with ``name`` shown underneath.
This example adds a "Plugin dashboard" result for signed-in users:
@ -1955,46 +1956,11 @@ This example adds a "Plugin dashboard" result for signed-in users:
80 AS sort_key,
'my-plugin' AS source,
'Plugin dashboard' AS display_name
""",
has_display_name=True,
"""
)
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before combining SQL fragments from different plugins.
.. _plugin_hook_jump_start:
jump_start(datasette, actor, request)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
``actor`` - dictionary or None
The currently authenticated :ref:`actor <authentication_actor>`.
``request`` - :ref:`internals_request` or None
The current HTTP request. This can be ``None`` if the request object is not available.
This hook allows plugins to add custom HTML to the default blank state of Datasette's ``/`` jump menu, before the user starts typing a search.
The hook can return a string, a ``markupsafe.Markup`` object, or an awaitable function that returns either of those. Return ``None`` to add nothing.
This example shows a link for starting a new chat if the user is signed in:
.. code-block:: python
from datasette import hookimpl
from markupsafe import Markup
@hookimpl
def jump_start(actor):
if not actor:
return None
return Markup(
'<p><a href="/-/agent/new">Start a new chat</a></p>'
)
.. _plugin_actions:
Action hooks

View file

@ -1,6 +1,5 @@
import pytest
import pytest_asyncio
from markupsafe import Markup
from datasette import hookimpl
from datasette.app import Datasette
@ -163,7 +162,6 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
'Plugin dashboard for ' || :actor_id AS display_name
""",
params={"actor_id": actor["id"] if actor else "anonymous"},
has_display_name=True,
)
plugin = JumpPlugin()
@ -190,34 +188,6 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
]
@pytest.mark.asyncio
async def test_jump_start_hook_renders_empty_state_template(ds_for_jump):
class JumpStartPlugin:
@hookimpl
def jump_start(self, datasette, actor, request):
if not actor:
return None
return Markup(
'<section class="agent-jump-start">'
"<h3>Agent chat</h3>"
'<a href="/-/agent/new">Start a new agent chat</a>'
"</section>"
)
plugin = JumpStartPlugin()
pm.register(plugin, name="test-jump-start-plugin")
try:
anonymous = await ds_for_jump.client.get("/")
authenticated = await ds_for_jump.client.get("/", actor={"id": "alice"})
finally:
pm.unregister(name="test-jump-start-plugin")
assert 'url="/-/jump"' in authenticated.text
assert "<template data-jump-start>" not in anonymous.text
assert "<template data-jump-start>" in authenticated.text
assert "Start a new agent chat" in authenticated.text
@pytest.mark.asyncio
async def test_tables_endpoint_removed(ds_for_jump):
response = await ds_for_jump.client.get("/-/tables.json")

View file

@ -197,3 +197,178 @@ def test_navigation_search_tracks_and_renders_recent_items():
"/item-2",
]
assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases"
def test_navigation_search_renders_jump_sections_from_javascript_plugins():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const documentListeners = {};
class FakeElement {
constructor(tagName = "div", parent = null) {
this._innerHTML = "";
this.value = "";
this.dataset = {};
this.open = false;
this.parent = parent;
this.tagName = tagName.toUpperCase();
}
set textContent(value) {
this.innerHTML = String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
get innerHTML() {
return this._innerHTML;
}
set innerHTML(value) {
this._innerHTML = String(value);
if (this.parent) {
this.parent._innerHTML += this._innerHTML;
}
}
addEventListener() {}
appendChild(child) {
this._innerHTML += child.innerHTML || "";
return child;
}
close() { this.open = false; }
focus() {}
querySelector(selector) {
if (selector.startsWith("[data-jump-section-index=")) {
return new FakeElement("div", this);
}
return { scrollIntoView() {} };
}
showModal() { this.open = true; }
}
class FakeShadowRoot {
constructor() {
this.innerHTML = "";
this.dialog = new FakeElement("dialog");
this.input = new FakeElement("input");
this.results = new FakeElement("div");
}
querySelector(selector) {
if (selector == "dialog") return this.dialog;
if (selector == ".search-input") return this.input;
if (selector == ".results-container") return this.results;
return new FakeElement();
}
}
global.HTMLElement = class {
constructor() {
this.attributes = {};
}
attachShadow() {
this.shadowRoot = new FakeShadowRoot();
return this.shadowRoot;
}
dispatchEvent() {}
getAttribute(name) {
return this.attributes[name] || null;
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
};
global.CustomEvent = class {
constructor(name, options) {
this.name = name;
this.type = name;
this.detail = options ? options.detail : undefined;
}
};
global.customElements = {
registry: new Map(),
define(name, cls) {
this.registry.set(name, cls);
},
};
global.document = {
addEventListener(name, callback) {
documentListeners[name] = documentListeners[name] || [];
documentListeners[name].push(callback);
},
activeElement: null,
createElement(tagName) {
return new FakeElement(tagName);
},
dispatchEvent(event) {
for (const callback of documentListeners[event.type] || []) {
callback(event);
}
},
querySelectorAll() {
return [];
},
};
global.localStorage = {
getItem() { return null; },
setItem() {},
removeItem() {},
};
global.window = { datasetteVersion: "test", location: { href: "" } };
vm.runInThisContext(
fs.readFileSync("datasette/static/datasette-manager.js", "utf8"),
{ filename: "datasette-manager.js" }
);
for (const callback of documentListeners.DOMContentLoaded || []) {
callback();
}
window.__DATASETTE__.registerPlugin("agent", {
version: "0.1",
makeJumpSections() {
return [
{
id: "agent-chat",
render(node, context) {
if (!context.navigationSearch) {
throw new Error("Expected navigationSearch in render context");
}
node.innerHTML = [
'<section class="agent-jump-start">',
'<button>Start a new agent chat</button>',
'</section>',
].join('');
},
},
];
},
});
vm.runInThisContext(
fs.readFileSync("datasette/static/navigation-search.js", "utf8"),
{ filename: "navigation-search.js" }
);
const Component = customElements.registry.get("navigation-search");
const element = new Component();
element.shadowRoot.input.value = "";
element.renderResults();
const html = element.shadowRoot.results.innerHTML;
if (!html.includes("Start a new agent chat")) {
throw new Error(`Missing jump section content: ${html}`);
}
process.stdout.write("ok");
""")
result = subprocess.run(
["node", "-e", script],
cwd=".",
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.endswith("ok")

View file

@ -43,6 +43,11 @@ def test_plugin_hooks_have_tests(plugin_hook):
assert ok, f"Plugin hook is missing tests: {plugin_hook}"
def test_hook_jump_items_sql():
# Detailed behavior is covered in tests/test_jump.py.
assert "jump_items_sql" in dir(pm.hook)
@pytest.mark.asyncio
async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
response = await ds_client.get(