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