From 2f7731e9e5ff9b324beb5039fbe2be55d704a184 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 29 Oct 2020 22:16:41 -0700 Subject: [PATCH] table_actions() plugin hook plus menu, closes #1066 Refs #690 --- datasette/hookspecs.py | 5 ++++ datasette/static/app.css | 34 ++++++++++++++++++++++++++- datasette/templates/base.html | 16 ++++++------- datasette/templates/table.html | 25 ++++++++++++++++++-- datasette/views/table.py | 16 +++++++++++++ docs/plugin_hooks.rst | 42 +++++++++++++++++++++++++++++++--- tests/fixtures.py | 2 ++ tests/plugins/my_plugin.py | 12 ++++++++++ tests/plugins/my_plugin_2.py | 9 ++++++++ tests/test_plugins.py | 19 +++++++++++++++ 10 files changed, 166 insertions(+), 14 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 7bad262a57..78070e6714 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -102,3 +102,8 @@ def forbidden(datasette, request, message): @hookspec def menu_links(datasette, actor): "Links for the navigation menu" + + +@hookspec +def table_actions(datasette, actor, database, table): + "Links for the table actions menu" diff --git a/datasette/static/app.css b/datasette/static/app.css index 2fd5371b70..9545776636 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -118,7 +118,7 @@ h6, .header3, .header4, .header5, -.header6 { +.header6 { font-weight: 700; font-size: 1rem; margin: 0; @@ -162,6 +162,29 @@ h6, text-decoration: underline; } +.page-header { + padding-left: 10px; + border-left: 10px solid #666; + margin-bottom: 0.75rem; + margin-top: 1rem; +} +.page-header h1 { + display: inline; + margin: 0; + font-size: 2rem; + padding-right: 0.2em; +} +.page-header details { + display: inline; +} +.page-header details > summary { + list-style: none; + display: inline; +} +.page-header details > summary::-webkit-details-marker { + display: none; +} + div, section, article, @@ -335,6 +358,15 @@ details .nav-menu-inner { display: block; } +/* Table actions menu */ +.table-menu-links { + position: relative; +} +.table-menu-links .dropdown-menu { + position: absolute; + top: 2rem; + right: 0; +} /* Components ============================================================== */ diff --git a/datasette/templates/base.html b/datasette/templates/base.html index ec1fd00e1b..d860df376c 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -60,19 +60,19 @@ {% for body_script in body_scripts %} diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 6c27beee86..13f6a8323c 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -25,8 +25,29 @@ {% endblock %} {% block content %} - -

{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}

+ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/views/table.py b/datasette/views/table.py index 079e0b0ae8..65fe7f8b80 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -7,6 +7,7 @@ from datasette.plugins import pm from datasette.database import QueryInterrupted from datasette.utils import ( + await_me_maybe, CustomRow, MultiParams, append_querystring, @@ -840,7 +841,21 @@ async def extra_template(): elif use_rowid: sort = "rowid" + async def table_actions(): + links = [] + for hook in pm.hook.table_actions( + datasette=self.ds, + table=table, + database=database, + actor=request.actor, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + links.extend(extra_links) + return links + return { + "table_actions": table_actions, "supports_search": bool(fts_table), "search": search or "", "use_rowid": use_rowid, @@ -959,6 +974,7 @@ async def template_data(): ) for column in display_columns: column["sortable"] = False + return { "foreign_key_tables": await self.foreign_key_tables( database, table, pk_values diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 82bc56a908..1c28c72e4a 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -998,10 +998,10 @@ 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`. +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. -This hook provides items to be included in the menu displayed by Datasette's top right menu icon. +This hook allows additional 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. @@ -1021,3 +1021,39 @@ This example adds a new menu item but only if the signed in user is ``"root"``: ] Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`config_base_url` setting into account. + + +.. _plugin_hook_table_actions: + +table_actions(datasette, actor, database, table) +------------------------------------------------ + +``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. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``database`` - string + The name of the database. + +``table`` - string + The name of the table. + +This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items. + +It can alternatively return an ``async def`` awaitable function which returns a list of menu items. + +This example adds a new table action if the signed in user is ``"root"``: + +.. code-block:: python + + from datasette import hookimpl + + @hookimpl + def table_actions(datasette, actor): + if actor and actor.get("id") == "root": + return [{ + "href": datasette.urls.path("/-/edit-schema/{}/{}".format(database, table)), + "label": "Edit schema for this table", + }] diff --git a/tests/fixtures.py b/tests/fixtures.py index 69853b7d1e..2f8383ef61 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -52,6 +52,7 @@ "register_routes", "render_cell", "startup", + "table_actions", ], }, { @@ -69,6 +70,7 @@ "permission_allowed", "render_cell", "startup", + "table_actions", ], }, { diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 7f8a48719e..8fc6a1b40c 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -296,3 +296,15 @@ def forbidden(datasette, request, message): def menu_links(datasette, actor): if actor: return [{"href": datasette.urls.instance(), "label": "Hello"}] + + +@hookimpl +def table_actions(datasette, database, table, actor): + if actor: + return [ + { + "href": datasette.urls.instance(), + "label": "Database: {}".format(database), + }, + {"href": datasette.urls.instance(), "label": "Table: {}".format(table)}, + ] diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index 981b24cca0..7d8095eda1 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -155,3 +155,12 @@ async def inner(): return [{"href": datasette.urls.instance(), "label": "Hello 2"}] return inner + + +@hookimpl +def table_actions(datasette, database, table, actor): + async def inner(): + if actor: + return [{"href": datasette.urls.instance(), "label": "From async"}] + + return inner diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 191d943de0..be36a517bf 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -782,3 +782,22 @@ def get_menu_links(html): {"label": "Hello", "href": "/"}, {"label": "Hello 2", "href": "/"}, ] + + +def test_hook_table_actions(app_client): + def get_table_actions_links(html): + soup = Soup(html, "html.parser") + details = soup.find("details", {"class": "table-menu-links"}) + if details is None: + return [] + return [{"label": a.text, "href": a["href"]} for a in details.select("a")] + + response = app_client.get("/fixtures/facetable") + assert get_table_actions_links(response.text) == [] + + response_2 = app_client.get("/fixtures/facetable?_bot=1") + assert get_table_actions_links(response_2.text) == [ + {"label": "From async", "href": "/"}, + {"label": "Database: fixtures", "href": "/"}, + {"label": "Table: facetable", "href": "/"}, + ]