From 18a64fbb29271ce607937110bbdb55488c43f4e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Oct 2020 20:45:15 -0700 Subject: [PATCH] Navigation menu plus menu_links() hook Closes #1064, refs #690. --- datasette/app.py | 12 +++++++++ datasette/default_menu_links.py | 40 +++++++++++++++++++++++++++ datasette/hookspecs.py | 5 ++++ datasette/plugins.py | 1 + datasette/static/app.css | 31 ++++++++++++++++++--- datasette/templates/base.html | 48 ++++++++++++++++++++++++++++----- docs/plugin_hooks.rst | 32 ++++++++++++++++++++++ tests/fixtures.py | 2 ++ tests/plugins/my_plugin.py | 6 +++++ tests/plugins/my_plugin_2.py | 9 +++++++ tests/test_auth.py | 3 +-- tests/test_plugins.py | 17 ++++++++++++ 12 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 datasette/default_menu_links.py diff --git a/datasette/app.py b/datasette/app.py index 3016043a..fb5c34a4 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -750,11 +750,22 @@ class Datasette: ) extra_template_vars.update(extra_vars) + async def menu_links(): + links = [] + for hook in pm.hook.menu_links( + datasette=self, actor=request.actor if request else None + ): + extra_links = await await_me_maybe(hook) + if extra_links: + links.extend(extra_links) + return links + template_context = { **context, **{ "urls": self.urls, "actor": request.actor if request else None, + "menu_links": menu_links, "display_actor": display_actor, "show_logout": request is not None and "ds_actor" in request.cookies, "app_css_hash": self.app_css_hash(), @@ -1161,6 +1172,7 @@ class DatasetteRouter: info, urls=self.ds.urls, app_css_hash=self.ds.app_css_hash(), + menu_links=lambda: [], ) ), status=status, diff --git a/datasette/default_menu_links.py b/datasette/default_menu_links.py new file mode 100644 index 00000000..11374fb5 --- /dev/null +++ b/datasette/default_menu_links.py @@ -0,0 +1,40 @@ +from datasette import hookimpl + + +@hookimpl +def menu_links(datasette, actor): + if actor and actor.get("id") == "root": + 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("/-/metadata"), + "label": "Metadata", + }, + { + "href": datasette.urls.path("/-/config"), + "label": "Config", + }, + { + "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"}, + ] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index f7e90e4e..7bad262a 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -97,3 +97,8 @@ def register_magic_parameters(datasette): @hookspec def forbidden(datasette, request, message): "Custom response for a 403 forbidden error" + + +@hookspec +def menu_links(datasette, actor): + "Links for the navigation menu" diff --git a/datasette/plugins.py b/datasette/plugins.py index 1c2f392f..50791988 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -13,6 +13,7 @@ DEFAULT_PLUGINS = ( "datasette.default_permissions", "datasette.default_magic_parameters", "datasette.blob_renderer", + "datasette.default_menu_links", ) pm = pluggy.PluginManager("datasette") diff --git a/datasette/static/app.css b/datasette/static/app.css index 8b462b35..2fd5371b 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -261,13 +261,13 @@ footer p { header .crumbs { float: left; } -header .logout { +header .actor { float: right; text-align: right; padding-left: 1rem; -} -header .logout form { - display: inline; + padding-right: 1rem; + position: relative; + top: -3px; } footer a:link, @@ -312,6 +312,29 @@ footer { margin-top: 1rem; } +/* Navigation menu */ +details.nav-menu > summary { + list-style: none; + display: inline; + float: right; + position: relative; +} +details.nav-menu > summary::-webkit-details-marker { + display: none; +} +details .nav-menu-inner { + position: absolute; + top: 2rem; + right: 10px; + width: 180px; + background-color: #276890; + padding: 1rem; + z-index: 1000; +} +.nav-menu-inner a { + display: block; +} + /* Components ============================================================== */ diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 03de2115..ec1fd00e 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -13,15 +13,33 @@ {% block extra_head %}{% endblock %} -
@@ -41,6 +59,22 @@ + {% for body_script in body_scripts %} {% endfor %} diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index b2c62ccd..82bc56a9 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -989,3 +989,35 @@ The function can alternatively return an awaitable function if it needs to make return Response.html(await datasette.render_template("forbidden.html")) return inner + +.. _plugin_hook_menu_links: + +menu_links(datasette, actor) +---------------------------- + +``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. + +``request`` - object + The current HTTP :ref:`internals_request`. + +This hook provides items to be included in the menu displayed by Datasette's top right menu icon. + +The hook should return a list of ``{"href": "...", "label": "..."}`` menu items. These will be added to the menu. + +It can alternatively return an ``async def`` awaitable function which returns a list of menu items. + +This example adds a new menu item but only if the signed in user is ``"root"``: + +.. code-block:: python + + from datasette import hookimpl + + @hookimpl + def menu_links(datasette, actor): + if actor and actor.get("id") == "root": + return [ + {"href": datasette.urls.path("/-/edit-schema"), "label": "Edit schema"}, + ] + +Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`config_base_url` setting into account. diff --git a/tests/fixtures.py b/tests/fixtures.py index 31638fc8..69853b7d 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -43,6 +43,7 @@ EXPECTED_PLUGINS = [ "extra_js_urls", "extra_template_vars", "forbidden", + "menu_links", "permission_allowed", "prepare_connection", "prepare_jinja2_environment", @@ -64,6 +65,7 @@ EXPECTED_PLUGINS = [ "canned_queries", "extra_js_urls", "extra_template_vars", + "menu_links", "permission_allowed", "render_cell", "startup", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 0dd0ad26..7f8a4871 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -290,3 +290,9 @@ def forbidden(datasette, request, message): datasette._last_forbidden_message = message if request.path == "/data2": return Response.redirect("/login?message=" + message) + + +@hookimpl +def menu_links(datasette, actor): + if actor: + return [{"href": datasette.urls.instance(), "label": "Hello"}] diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index ae0f338a..981b24cc 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -146,3 +146,12 @@ def canned_queries(datasette, database): } return inner + + +@hookimpl(trylast=True) +def menu_links(datasette, actor): + async def inner(): + if actor: + return [{"href": datasette.urls.instance(), "label": "Hello 2"}] + + return inner diff --git a/tests/test_auth.py b/tests/test_auth.py index f244f268..34138aa6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -101,7 +101,7 @@ def test_logout_button_in_navigation(app_client, path): ) anon_response = app_client.get(path) for fragment in ( - "test ·", + "test", '
', ): assert fragment in response.text @@ -112,5 +112,4 @@ def test_logout_button_in_navigation(app_client, path): def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(app_client, path): response = app_client.get(path + "?_bot=1") assert "bot" in response.text - assert "bot ·" not in response.text assert '' not in response.text diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 08ed2e6b..191d943d 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -765,3 +765,20 @@ def test_hook_forbidden(restore_working_directory): assert 302 == response2.status assert "/login?message=view-database" == response2.headers["Location"] assert "view-database" == client.ds._last_forbidden_message + + +def test_hook_menu_links(app_client): + def get_menu_links(html): + soup = Soup(html, "html.parser") + return [ + {"label": a.text, "href": a["href"]} for a in soup.find("nav").select("a") + ] + + response = app_client.get("/") + assert get_menu_links(response.text) == [] + + response_2 = app_client.get("/?_bot=1") + assert get_menu_links(response_2.text) == [ + {"label": "Hello", "href": "/"}, + {"label": "Hello 2", "href": "/"}, + ]