diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 6ce1e85e..35468cc3 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -140,6 +140,11 @@ def menu_links(datasette, actor, request): """Links for the navigation menu""" +@hookspec +def row_actions(datasette, actor, request, database, table, row): + """Links for the row actions menu""" + + @hookspec def table_actions(datasette, actor, database, table, request): """Links for the table actions menu""" diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 6d4b996e..c3e6bed7 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -22,6 +22,37 @@ {% block content %}

{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}

+{% set links = row_actions %}{% if links %} +
+ +
+{% endif %} + {{ top_row() }} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/views/row.py b/datasette/views/row.py index 4d20e41a..49d390f6 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -3,10 +3,12 @@ from datasette.database import QueryInterrupted from datasette.events import UpdateRowEvent, DeleteRowEvent from .base import DataView, BaseView, _error from datasette.utils import ( + await_me_maybe, make_slot_function, to_css_class, escape_sqlite, ) +from datasette.plugins import pm import json import sqlite_utils from .table import display_columns_and_rows @@ -55,6 +57,20 @@ class RowView(DataView): ) for column in display_columns: column["sortable"] = False + + row_actions = [] + for hook in pm.hook.row_actions( + datasette=self.ds, + actor=request.actor, + request=request, + database=database, + table=table, + row=rows[0], + ): + extra_links = await await_me_maybe(hook) + if extra_links: + row_actions.extend(extra_links) + return { "private": private, "foreign_key_tables": await self.foreign_key_tables( @@ -68,6 +84,7 @@ class RowView(DataView): f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html", "_table.html", ], + "row_actions": row_actions, "metadata": (self.ds.metadata("databases") or {}) .get(database, {}) .get("tables", {}) diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index e43aa784..90ed8924 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1557,6 +1557,10 @@ Action hooks Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() `, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing. +Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail. + +They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items. + .. _plugin_hook_table_actions: table_actions(datasette, actor, database, table, request) @@ -1577,10 +1581,6 @@ table_actions(datasette, actor, database, table, request) ``request`` - :ref:`internals_request` or None The current HTTP request. This can be ``None`` if the request object is not available. -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, with optional ``"description": "..."`` keys describing each action in more detail. - -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 @@ -1653,7 +1653,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params) ``params`` - dictionary The parameters passed to the SQL query, if any. -This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the canned query and arbitrary SQL query pages. +Populates a "Query actions" menu on the canned query and arbitrary SQL query pages. This example adds a new query action linking to a page for explaining a query: @@ -1684,6 +1684,49 @@ This example adds a new query action linking to a page for explaining a query: Example: `datasette-create-view `_ +.. _plugin_hook_row_actions: + +row_actions(datasette, actor, request, database, table, row) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``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 `. + +``request`` - :ref:`internals_request` or None + The current HTTP request. + +``database`` - string + The name of the database. + +``table`` - string + The name of the table. + +``row`` - ``sqlite.Row`` + The SQLite row object being dispayed on the page. + +Return links for the "Row actions" menu shown at the top of the row page. + +This example displays the row in JSON plus some additional debug information if the user is signed in: + +.. code-block:: python + + from datasette import hookimpl + + + @hookimpl + def row_actions(datasette, database, table, actor, row): + if actor: + return [ + { + "href": datasette.urls.instance(), + "label": f"Row details for {actor['id']}", + "description": json.dumps(dict(row), default=repr), + }, + ] + .. _plugin_hook_database_actions: database_actions(datasette, actor, database, request) @@ -1701,7 +1744,7 @@ database_actions(datasette, actor, database, request) ``request`` - :ref:`internals_request` The current HTTP request. -This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page. +Populates an actions menu on the database page. This example adds a new database action for creating a table, if the user has the ``edit-schema`` permission: @@ -1749,7 +1792,7 @@ homepage_actions(datasette, actor, request) ``request`` - :ref:`internals_request` The current HTTP request. -This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the index page of the Datasette instance. +Populates an actions menu on the top-level index homepage of the Datasette instance. This example adds a link an imagined tool for editing the homepage, only for signed in users: diff --git a/tests/fixtures.py b/tests/fixtures.py index d668a8ea..af6b610b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -53,6 +53,7 @@ EXPECTED_PLUGINS = [ "register_permissions", "register_routes", "render_cell", + "row_actions", "skip_csrf", "startup", "table_actions", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 509418d4..9ef10181 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -423,6 +423,18 @@ def query_actions(datasette, database, query_name, sql): ] +@hookimpl +def row_actions(datasette, database, table, actor, row): + if actor: + return [ + { + "href": datasette.urls.instance(), + "label": f"Row details for {actor['id']}", + "description": json.dumps(dict(row), default=repr), + }, + ] + + @hookimpl def database_actions(datasette, database, actor, request): if actor: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index fdfd531e..9c8d02ec 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1000,6 +1000,24 @@ async def test_hook_query_actions(ds_client, path, expected_url): ] +@pytest.mark.asyncio +async def test_hook_row_actions(ds_client): + response = await ds_client.get("/fixtures/facet_cities/1") + assert get_actions_links(response.text) == [] + + response_2 = await ds_client.get( + "/fixtures/facet_cities/1", + cookies={"ds_actor": ds_client.actor_cookie({"id": "sam"})}, + ) + assert get_actions_links(response_2.text) == [ + { + "label": "Row details for sam", + "href": "/", + "description": '{"id": 1, "name": "San Francisco"}', + } + ] + + @pytest.mark.asyncio async def test_hook_database_actions(ds_client): response = await ds_client.get("/fixtures")