diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py new file mode 100644 index 00000000..d1c2cd6c --- /dev/null +++ b/datasette/default_debug_menu.py @@ -0,0 +1,36 @@ +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"), +) + + +@hookimpl +def jump_items_sql(datasette, actor, request): + async def inner(): + if not await datasette.allowed(action="debug-menu", actor=actor): + return [] + + return [ + JumpSQL.menu_item( + label=label, + url=datasette.urls.path(path), + description="Debug menu", + source="datasette.default_debug_menu", + sort_key=70 + index, + item_type="debug", + ) + for index, (path, label) in enumerate(DEBUG_MENU_ITEMS) + ] + + return inner diff --git a/datasette/default_menu_links.py b/datasette/default_menu_links.py deleted file mode 100644 index 85032387..00000000 --- a/datasette/default_menu_links.py +++ /dev/null @@ -1,41 +0,0 @@ -from datasette import hookimpl - - -@hookimpl -def menu_links(datasette, actor): - async def inner(): - if not await datasette.allowed(action="debug-menu", actor=actor): - return [] - - return [ - {"href": datasette.urls.path("/-/databases"), "label": "Databases"}, - { - "href": datasette.urls.path("/-/plugins"), - "label": "Installed plugins", - }, - { - "href": datasette.urls.path("/-/versions"), - "label": "Version info", - }, - { - "href": datasette.urls.path("/-/settings"), - "label": "Settings", - }, - { - "href": datasette.urls.path("/-/permissions"), - "label": "Debug permissions", - }, - { - "href": datasette.urls.path("/-/messages"), - "label": "Debug messages", - }, - { - "href": datasette.urls.path("/-/allow-debug"), - "label": "Debug allow rules", - }, - {"href": datasette.urls.path("/-/threads"), "label": "Debug threads"}, - {"href": datasette.urls.path("/-/actor"), "label": "Debug actor"}, - {"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"}, - ] - - return inner diff --git a/datasette/jump.py b/datasette/jump.py index 6ec7ae13..7ef5ce2b 100644 --- a/datasette/jump.py +++ b/datasette/jump.py @@ -10,6 +10,49 @@ class JumpSQL: sql: str params: dict[str, Any] | None = None + @classmethod + def menu_item( + cls, + *, + label: str, + url: str, + description: str = "Menu item", + source: str = "datasette", + sort_key: int = 50, + search_text: str | None = None, + display_name: str | None = None, + item_type: str = "menu", + ) -> "JumpSQL": + if search_text is None: + search_text = " ".join( + text for text in (label, display_name, description) if text is not None + ) + return cls( + sql=""" + SELECT + :type AS type, + :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, + :display_name AS display_name + """, + params={ + "type": item_type, + "label": label, + "description": description, + "url": url, + "search_text": search_text, + "sort_key": sort_key, + "source": source, + "display_name": display_name, + }, + ) + _PARAM_RE = re.compile(r"(?` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites. .. _v1_0_a29: @@ -274,7 +279,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 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`) +- 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`) - 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 b6ee1690..8476c22a 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -152,8 +152,6 @@ Shows currently attached databases. `Databases example `_: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index b855d8b2..71d429ac 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1943,22 +1943,21 @@ This example adds a "Plugin dashboard" result for signed-in users: def jump_items_sql(actor): if not actor: return None - return JumpSQL(sql=""" - SELECT - 'dashboard' AS type, - 'plugin-dashboard' AS label, - 'Dashboard' AS description, - '/-/plugin-dashboard' AS url, - NULL AS database_name, - NULL AS resource_name, - 'plugin dashboard' AS search_text, - 80 AS sort_key, - 'my-plugin' AS source, - 'Plugin dashboard' AS display_name - """) + return JumpSQL.menu_item( + item_type="dashboard", + label="plugin-dashboard", + description="Dashboard", + url="/-/plugin-dashboard", + search_text="plugin dashboard", + sort_key=80, + source="my-plugin", + display_name="Plugin dashboard", + ) 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. + .. _plugin_actions: Action hooks diff --git a/docs/plugins.rst b/docs/plugins.rst index 90bc9d35..e79acfe0 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -235,12 +235,12 @@ If you run ``datasette plugins --all`` it will include default plugins that ship ] }, { - "name": "datasette.default_menu_links", + "name": "datasette.default_debug_menu", "static": false, "templates": false, "version": null, "hooks": [ - "menu_links" + "jump_items_sql" ] }, { diff --git a/tests/test_html.py b/tests/test_html.py index 4da321d2..efc1040d 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -994,7 +994,7 @@ def test_edit_sql_link_not_shown_if_user_lacks_permission(has_permission): [ (None, None, None), ("test", None, ["/-/permissions"]), - ("root", ["/-/permissions", "/-/allow-debug"], None), + ("root", None, ["/-/permissions", "/-/allow-debug"]), ], ) async def test_navigation_menu_links( diff --git a/tests/test_jump.py b/tests/test_jump.py index 27238695..af8f4856 100644 --- a/tests/test_jump.py +++ b/tests/test_jump.py @@ -3,6 +3,7 @@ import pytest_asyncio from datasette import hookimpl from datasette.app import Datasette +from datasette.jump import JumpSQL from datasette.plugins import pm @@ -140,9 +141,77 @@ async def test_jump_respects_resource_permissions(ds_for_jump): @pytest.mark.asyncio -async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump): - from datasette.jump import JumpSQL +async def test_jump_sql_menu_item_helper(ds_for_jump): + fragment = JumpSQL.menu_item( + label="Plugin dashboard", + url="/-/plugin-dashboard", + description="Plugin tool", + source="test-plugin", + sort_key=70, + search_text="dashboard plugin", + display_name="Plugin Dashboard", + item_type="plugin", + ) + result = await ds_for_jump.get_internal_database().execute( + fragment.sql, fragment.params + ) + assert dict(result.first()) == { + "type": "plugin", + "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", + "display_name": "Plugin Dashboard", + } + +@pytest.mark.asyncio +async def test_debug_menu_items_are_in_jump_for_debug_menu_permission(): + ds = Datasette( + config={ + "permissions": { + "debug-menu": {"id": "debugger"}, + } + } + ) + await ds.invoke_startup() + response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "debugger"}) + assert response.status_code == 200 + debug_matches = [ + match for match in response.json()["matches"] if match["type"] == "debug" + ] + assert {match["name"]: match["url"] for match in debug_matches} == { + "Databases": "/-/databases", + "Installed plugins": "/-/plugins", + "Version info": "/-/versions", + "Settings": "/-/settings", + "Debug permissions": "/-/permissions", + "Debug messages": "/-/messages", + "Debug allow rules": "/-/allow-debug", + "Debug threads": "/-/threads", + "Debug actor": "/-/actor", + "Pattern portfolio": "/-/patterns", + } + assert {match["description"] for match in debug_matches} == {"Debug menu"} + + +@pytest.mark.asyncio +async def test_debug_menu_items_are_hidden_without_debug_menu_permission(): + ds = Datasette() + await ds.invoke_startup() + response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "regular"}) + assert response.status_code == 200 + assert [ + match for match in response.json()["matches"] if match["type"] == "debug" + ] == [] + + +@pytest.mark.asyncio +async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump): class JumpPlugin: @hookimpl def jump_items_sql(self, datasette, actor, request): diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 0c09e773..8166532f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -430,7 +430,6 @@ async def test_permissions_debug(ds_client, filter_): "result": True, "actor": {"id": "root"}, }, - {"action": "debug-menu", "result": False, "actor": None}, { "action": "view-instance", "result": True,