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
This commit is contained in:
Simon Willison 2026-05-23 20:28:02 -07:00
commit 21a79b34b8
10 changed files with 203 additions and 69 deletions

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
: "";
const type = match.type
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
: "";
const description = match.description
? `<div class="result-description">${this.escapeHtml(
match.description,
@ -513,10 +532,11 @@ class NavigationSearch extends HTMLElement {
aria-selected="${index === this.selectedIndex}"
>
<div>
${description}
${type}
<div class="result-name">${this.escapeHtml(displayName)}</div>
${label}
<div class="result-url">${this.escapeHtml(match.url)}</div>
${description}
</div>
</div>
`;

View file

@ -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"],
}

View file

@ -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) <datasette_fixtures_populate_fixture_database>` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites.
.. _v1_0_a29:

View file

@ -151,6 +151,8 @@ Shows currently attached databases. `Databases example <https://latest.datasette
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and canned queries, and plugins can contribute additional items.
Each item includes a ``type`` string used as a category label in the menu. Items can also include an optional ``description`` with longer text describing that individual result.
The endpoint supports a ``?q=`` query parameter for filtering items by name.
`Jump example <https://latest.datasette.io/-/jump>`_:
@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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")