From 21a79b34b8eab84f6f9668f1b92d559ad8124f06 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 23 May 2026 20:28:02 -0700 Subject: [PATCH] Improvements to Jump SQL columns - Removed database_name and resource_name - url can now optionally return JSON to reuse datasette.urls. methods - description is now used as a truncated text description --- datasette/default_debug_menu.py | 65 ++++++++++++++++++++----- datasette/default_jump_items.py | 29 ++++++----- datasette/jump.py | 2 - datasette/static/navigation-search.js | 24 ++++++++- datasette/views/special.py | 41 +++++++++++----- docs/changelog.rst | 5 +- docs/introspection.rst | 10 ++-- docs/plugin_hooks.rst | 16 ++---- docs/plugins.rst | 18 +++---- tests/test_jump.py | 70 ++++++++++++++++++++++++--- 10 files changed, 207 insertions(+), 73 deletions(-) diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py index d1c2cd6c..e9576d99 100644 --- a/datasette/default_debug_menu.py +++ b/datasette/default_debug_menu.py @@ -2,16 +2,56 @@ from datasette import hookimpl from datasette.jump import JumpSQL DEBUG_MENU_ITEMS = ( - ("/-/databases", "Databases"), - ("/-/plugins", "Installed plugins"), - ("/-/versions", "Version info"), - ("/-/settings", "Settings"), - ("/-/permissions", "Debug permissions"), - ("/-/messages", "Debug messages"), - ("/-/allow-debug", "Debug allow rules"), - ("/-/threads", "Debug threads"), - ("/-/actor", "Debug actor"), - ("/-/patterns", "Pattern portfolio"), + ( + "/-/databases", + "Databases", + "List of databases known to this Datasette instance.", + ), + ( + "/-/plugins", + "Installed plugins", + "Review loaded plugins, their versions and their registered hooks.", + ), + ( + "/-/versions", + "Version info", + "Check the Python, SQLite and dependency versions used by this server.", + ), + ( + "/-/settings", + "Settings", + "Inspect the active Datasette settings and configuration values.", + ), + ( + "/-/permissions", + "Debug permissions", + "Test permission checks for actors, actions and resources.", + ), + ( + "/-/messages", + "Debug messages", + "Try out temporary flash messages shown to users.", + ), + ( + "/-/allow-debug", + "Debug allow rules", + "Explore how allow blocks match actors against permission rules.", + ), + ( + "/-/threads", + "Debug threads", + "Inspect worker threads and database tasks.", + ), + ( + "/-/actor", + "Debug actor", + "View the actor object for the current signed-in user.", + ), + ( + "/-/patterns", + "Pattern portfolio", + "Browse Datasette UI patterns.", + ), ) @@ -25,12 +65,13 @@ def jump_items_sql(datasette, actor, request): JumpSQL.menu_item( label=label, url=datasette.urls.path(path), - description="Debug menu", + description=description, source="datasette.default_debug_menu", sort_key=70 + index, + search_text=f"debug {label} {description}", item_type="debug", ) - for index, (path, label) in enumerate(DEBUG_MENU_ITEMS) + for index, (path, label, description) in enumerate(DEBUG_MENU_ITEMS) ] return inner diff --git a/datasette/default_jump_items.py b/datasette/default_jump_items.py index bacc3649..844fb6b6 100644 --- a/datasette/default_jump_items.py +++ b/datasette/default_jump_items.py @@ -23,10 +23,11 @@ def jump_items_sql(datasette, actor, request): SELECT 'database' AS type, parent AS label, - 'Database' AS description, - NULL AS url, - parent AS database_name, - NULL AS resource_name, + NULL AS description, + json_object( + 'method', 'database', + 'database', parent + ) AS url, parent AS search_text, 10 AS sort_key, 'datasette' AS source, @@ -43,10 +44,12 @@ def jump_items_sql(datasette, actor, request): SELECT CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type, allowed_tables.parent || ': ' || allowed_tables.child AS label, - CASE WHEN catalog_views.view_name IS NULL THEN 'Table' ELSE 'View' END AS description, - NULL AS url, - allowed_tables.parent AS database_name, - allowed_tables.child AS resource_name, + NULL AS description, + json_object( + 'method', 'table', + 'database', allowed_tables.parent, + 'table', allowed_tables.child + ) AS url, allowed_tables.parent || ' ' || allowed_tables.child AS search_text, CASE WHEN catalog_views.view_name IS NULL THEN 20 ELSE 25 END AS sort_key, 'datasette' AS source, @@ -66,10 +69,12 @@ def jump_items_sql(datasette, actor, request): SELECT 'query' AS type, allowed_queries.parent || ': ' || allowed_queries.child AS label, - 'Canned query' AS description, - NULL AS url, - allowed_queries.parent AS database_name, - allowed_queries.child AS resource_name, + NULL AS description, + json_object( + 'method', 'query', + 'database', allowed_queries.parent, + 'query', allowed_queries.child + ) AS url, allowed_queries.parent || ' ' || allowed_queries.child AS search_text, 30 AS sort_key, 'datasette' AS source, diff --git a/datasette/jump.py b/datasette/jump.py index 7ef5ce2b..5a67d49e 100644 --- a/datasette/jump.py +++ b/datasette/jump.py @@ -34,8 +34,6 @@ class JumpSQL: :label AS label, :description AS description, :url AS url, - NULL AS database_name, - NULL AS resource_name, :search_text AS search_text, :sort_key AS sort_key, :source AS source, diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 34ed2fc3..29a2f143 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -100,6 +100,11 @@ class NavigationSearch extends HTMLElement { background-color: #dbeafe; } + .result-item > div { + flex: 1; + min-width: 0; + } + .jump-start-content { border-bottom: 1px solid #e5e7eb; margin-bottom: 0.5rem; @@ -120,7 +125,7 @@ class NavigationSearch extends HTMLElement { color: #4b5563; } - .result-description { + .result-type { color: #4b5563; font-size: 0.75rem; font-weight: 600; @@ -132,6 +137,17 @@ class NavigationSearch extends HTMLElement { color: #6b7280; } + .result-description { + color: #374151; + display: -webkit-box; + font-size: 0.8125rem; + line-height: 1.35; + margin-top: 0.35rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + .results-heading { color: #4b5563; font-size: 0.75rem; @@ -500,6 +516,9 @@ class NavigationSearch extends HTMLElement { match.display_name && match.display_name !== match.name ? `
${this.escapeHtml(match.name)}
` : ""; + const type = match.type + ? `
${this.escapeHtml(match.type)}
` + : ""; const description = match.description ? `
${this.escapeHtml( match.description, @@ -513,10 +532,11 @@ class NavigationSearch extends HTMLElement { aria-selected="${index === this.selectedIndex}" >
- ${description} + ${type}
${this.escapeHtml(displayName)}
${label}
${this.escapeHtml(match.url)}
+ ${description}
`; diff --git a/datasette/views/special.py b/datasette/views/special.py index 2022e4a7..5b468f51 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -944,16 +944,33 @@ class JumpView(BaseView): raise TypeError("jump_items_sql must return JumpSQL instances") return fragments - def _url_for_row(self, row): - if row["url"]: - return row["url"] - if row["type"] == "database": - return self.ds.urls.database(row["database_name"]) - if row["type"] in ("table", "view"): - return self.ds.urls.table(row["database_name"], row["resource_name"]) - if row["type"] == "query": - return self.ds.urls.query(row["database_name"], row["resource_name"]) - return "" + def _resolve_url(self, url): + if not url or url.startswith("/"): + return url + + descriptor = json.loads(url) + if not isinstance(descriptor, dict): + raise TypeError("jump item url JSON must be an object") + method_name = descriptor.get("method") + if not isinstance(method_name, str) or not method_name: + raise TypeError("jump item url JSON must include a method") + if method_name.startswith("_"): + raise AttributeError(f"datasette.urls has no method named {method_name!r}") + try: + method = getattr(self.ds.urls, method_name) + except AttributeError as ex: + raise AttributeError( + f"datasette.urls has no method named {method_name!r}" + ) from ex + if not callable(method): + raise TypeError(f"datasette.urls.{method_name} is not callable") + kwargs = {key: value for key, value in descriptor.items() if key != "method"} + try: + return method(**kwargs) + except TypeError as ex: + raise TypeError( + f"Invalid arguments for datasette.urls.{method_name}(): {ex}" + ) from ex async def get(self, request): q = request.args.get("q", "").strip() @@ -975,8 +992,6 @@ class JumpView(BaseView): label, description, url, - database_name, - resource_name, search_text, sort_key, source, @@ -1016,7 +1031,7 @@ class JumpView(BaseView): for row in rows: match = { "name": row["label"], - "url": self._url_for_row(row), + "url": self._resolve_url(row["url"]), "type": row["type"], "description": row["description"], } diff --git a/docs/changelog.rst b/docs/changelog.rst index d2479590..84015b46 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,10 +14,11 @@ Unreleased - Fixed a Safari bug with the table search mechanism triggered by pressing ``/``. (:issue:`2724`) - New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`) - The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``. -- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL that queries the internal catalog. +- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL that queries the internal catalog. Each row returned by this hook includes a ``url`` value, which can be a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods. - ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog. - New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query. -- Debug menu links now appear in the jump-to menu instead of the top-right app menu. +- Jump menu results now show their ``type`` as a category label, and can show optional longer ``description`` text for individual results. +- Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item. - New documented :ref:`datasette.fixtures.populate_fixture_database(conn) ` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites. .. _v1_0_a29: diff --git a/docs/introspection.rst b/docs/introspection.rst index 8476c22a..d2eb8efd 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -151,6 +151,8 @@ Shows currently attached databases. `Databases example `_: @@ -163,19 +165,19 @@ The endpoint supports a ``?q=`` query parameter for filtering items by name. "name": "fixtures", "url": "/fixtures", "type": "database", - "description": "Database" + "description": null }, { "name": "fixtures: facetable", "url": "/fixtures/facetable", "type": "table", - "description": "Table" + "description": null }, { "name": "fixtures: recent_releases", "url": "/fixtures/recent_releases", "type": "query", - "description": "Canned query" + "description": null } ], "truncated": false @@ -191,7 +193,7 @@ Search example with ``?q=facet`` returns only items matching ``.*facet.*``: "name": "fixtures: facetable", "url": "/fixtures/facetable", "type": "table", - "description": "Table" + "description": null } ], "truncated": false diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5a0c8af1..c0b188cb 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1902,22 +1902,16 @@ Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Ea The SQL query must return these columns: ``type`` - A short type string for the result, for example ``"app"`` or ``"dashboard"``. + A short type string for the result, for example ``"app"`` or ``"dashboard"``. The jump menu displays this above the item as a category label. ``label`` The stable name for the result. This is returned as ``name`` in the JSON API and is used for sorting. ``description`` - A short description shown above the item in the jump menu. + Optional longer text describing this individual item, or ``NULL``. The jump menu displays this below the item's URL when it is present. ``url`` - The URL to navigate to when the item is selected. - -``database_name`` - The database name for Datasette resources, or ``NULL`` for custom plugin results. - -``resource_name`` - The table, view or query name for Datasette resources, or ``NULL`` for custom plugin results. + The URL to navigate to when the item is selected. This can be either a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods. For example, ``json_object('method', 'table', 'database', 'fixtures', 'table', 'facetable')`` calls ``datasette.urls.table(database='fixtures', table='facetable')``. Unknown methods or invalid named arguments will result in an error. ``search_text`` Text that should be searched by the ``?q=`` parameter. @@ -1946,7 +1940,7 @@ This example adds a "Plugin dashboard" result for signed-in users: return JumpSQL.menu_item( item_type="dashboard", label="plugin-dashboard", - description="Dashboard", + description="Review plugin status and configuration.", url="/-/plugin-dashboard", search_text="plugin dashboard", sort_key=80, @@ -1956,7 +1950,7 @@ This example adds a "Plugin dashboard" result for signed-in users: Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before combining SQL fragments from different plugins. -``JumpSQL.menu_item(...)`` is a shortcut for adding a single jump menu item that is not backed by a resource in Datasette's internal catalog tables. It returns ``NULL`` for ``database_name`` and ``resource_name`` and accepts the keyword arguments shown above. +``JumpSQL.menu_item(...)`` is a shortcut for adding a single jump menu item from Python code. It accepts the keyword arguments shown above. .. _plugin_actions: diff --git a/docs/plugins.rst b/docs/plugins.rst index e79acfe0..77958205 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -216,6 +216,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "register_column_types" ] }, + { + "name": "datasette.default_debug_menu", + "static": false, + "templates": false, + "version": null, + "hooks": [ + "jump_items_sql" + ] + }, { "name": "datasette.default_jump_items", "static": false, @@ -234,15 +243,6 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "register_magic_parameters" ] }, - { - "name": "datasette.default_debug_menu", - "static": false, - "templates": false, - "version": null, - "hooks": [ - "jump_items_sql" - ] - }, { "name": "datasette.default_permissions", "static": false, diff --git a/tests/test_jump.py b/tests/test_jump.py index af8f4856..3c5cf1a1 100644 --- a/tests/test_jump.py +++ b/tests/test_jump.py @@ -5,6 +5,7 @@ from datasette import hookimpl from datasette.app import Datasette from datasette.jump import JumpSQL from datasette.plugins import pm +from datasette.views.special import JumpView @pytest_asyncio.fixture @@ -113,7 +114,7 @@ async def test_jump_uses_canned_query_names_not_titles(ds_for_jump): "name": "content: release_notes", "url": "/content/release_notes", "type": "query", - "description": "Canned query", + "description": None, } ] @@ -160,8 +161,6 @@ async def test_jump_sql_menu_item_helper(ds_for_jump): "label": "Plugin dashboard", "description": "Plugin tool", "url": "/-/plugin-dashboard", - "database_name": None, - "resource_name": None, "search_text": "dashboard plugin", "sort_key": 70, "source": "test-plugin", @@ -196,7 +195,13 @@ async def test_debug_menu_items_are_in_jump_for_debug_menu_permission(): "Debug actor": "/-/actor", "Pattern portfolio": "/-/patterns", } - assert {match["description"] for match in debug_matches} == {"Debug menu"} + descriptions_by_name = { + match["name"]: match["description"] for match in debug_matches + } + assert all(descriptions_by_name.values()) + assert descriptions_by_name["Databases"] == ( + "Inspect the databases, tables, views and columns known to this Datasette instance." + ) @pytest.mark.asyncio @@ -222,8 +227,6 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump): 'plugin-dashboard: ' || :actor_id AS label, 'Plugin supplied item' AS description, '/-/plugin-dashboard' AS url, - NULL AS database_name, - NULL AS resource_name, 'plugin dashboard ' || :actor_id AS search_text, 80 AS sort_key, 'test-plugin' AS source, @@ -256,6 +259,61 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump): ] +@pytest.mark.asyncio +async def test_jump_resolves_url_descriptors_from_sql(ds_for_jump): + class JumpPlugin: + @hookimpl + def jump_items_sql(self, datasette, actor, request): + return JumpSQL(sql=""" + SELECT + 'plugin' AS type, + 'Table descriptor' AS label, + NULL AS description, + json_object( + 'method', 'table', + 'database', 'content', + 'table', 'comments' + ) AS url, + 'table descriptor comments' AS search_text, + 80 AS sort_key, + 'test-plugin' AS source, + NULL AS display_name + """) + + plugin = JumpPlugin() + pm.register(plugin, name="test-jump-url-descriptor-plugin") + try: + response = await ds_for_jump.client.get( + "/-/jump.json?q=descriptor", actor={"id": "alice"} + ) + finally: + pm.unregister(name="test-jump-url-descriptor-plugin") + + assert response.status_code == 200 + plugin_matches = [ + match for match in response.json()["matches"] if match["type"] == "plugin" + ] + assert plugin_matches == [ + { + "name": "Table descriptor", + "url": "/content/comments", + "type": "plugin", + "description": None, + } + ] + + +@pytest.mark.asyncio +async def test_jump_url_descriptor_errors(ds_for_jump): + view = JumpView(ds_for_jump) + with pytest.raises(AttributeError): + view._resolve_url('{"method": "not_a_url_method"}') + with pytest.raises(TypeError): + view._resolve_url( + '{"method": "table", "database_name": "content", "table_name": "comments"}' + ) + + @pytest.mark.asyncio async def test_tables_endpoint_removed(ds_for_jump): response = await ds_for_jump.client.get("/-/tables.json")