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