diff --git a/datasette/app.py b/datasette/app.py index c9605af3..088403e0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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 diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index a4d8143b..cf95abcb 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -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 diff --git a/datasette/jump.py b/datasette/jump.py index 96e18547..6ec7ae13 100644 --- a/datasette/jump.py +++ b/datasette/jump.py @@ -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"(? { + 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, diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 49cc172c..9e24681b 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -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 `
`; + }) + .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 - ? `
${startContent}
` + 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) { diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 6a55bd1f..02365e68 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -71,13 +71,6 @@ {% if select_templates %}{% endif %} -{% if jump_start is defined %} -{% set jump_start_html = jump_start() %} -{% else %} -{% set jump_start_html = "" %} -{% endif %} -{% if jump_start_html %} - -{% endif %} + diff --git a/datasette/views/special.py b/datasette/views/special.py index 990714cf..cb6524dc 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -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} ) diff --git a/docs/changelog.rst b/docs/changelog.rst index 51188461..5d52d04d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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. diff --git a/docs/introspection.rst b/docs/introspection.rst index 9f0358ac..a5a9753a 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -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 `_: diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index e7ee6817..805938c0 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -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 ```` 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 = ''; + node.querySelector('button').addEventListener('click', () => { + location.href = '/-/agent/new'; + }); + } + } + ]; + } + }); + }); + .. _javascript_plugins_makeAboveTablePanelConfigs: makeAboveTablePanelConfigs() diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 041f3d9d..c3ce3ed9 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -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 `. - -``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( - '

Start a new chat

' - ) - .. _plugin_actions: Action hooks diff --git a/tests/test_jump.py b/tests/test_jump.py index 55325b95..acffe4a9 100644 --- a/tests/test_jump.py +++ b/tests/test_jump.py @@ -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( - '
' - "

Agent chat

" - 'Start a new agent chat' - "
" - ) - - 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 "