Refactor edit/delete tools to work on row pages too

Refs https://github.com/simonw/datasette/pull/2781#issuecomment-4703303274

Refs #2780
This commit is contained in:
Simon Willison 2026-06-14 15:59:08 -07:00
commit 82c95a1a13
8 changed files with 2261 additions and 1948 deletions

View file

@ -2320,6 +2320,7 @@ class Datasette:
and "ds_actor" in request.cookies
and request.actor,
"app_css_hash": self.app_css_hash(),
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
"table_js_hash": self.static_hash("table.js"),
"zip": zip,
"body_scripts": body_scripts,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,13 @@
{% block extra_head %}
{{- super() -}}
{% if row_mutation_ui %}
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
{% endif %}
<style>
@media only screen and (max-width: 576px) {
{% for column in columns %}

View file

@ -9,6 +9,7 @@
{% if table_page_data.foreignKeys %}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endif %}
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>

View file

@ -7,6 +7,7 @@ from datasette.utils import (
await_me_maybe,
CustomRow,
make_slot_function,
path_from_row_pks,
to_css_class,
escape_sqlite,
)
@ -15,7 +16,11 @@ import json
import markupsafe
import sqlite_utils
from datasette.extras import extra_names_from_request
from .table import display_columns_and_rows
from .table import (
display_columns_and_rows,
_table_page_data,
row_label_from_label_column,
)
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
@ -49,6 +54,7 @@ class RowView(DataView):
pks = resolved.pks
async def template_data():
is_table = await db.table_exists(table)
# Reorder columns so primary keys come first
pk_set = set(pks)
pk_cols = [d for d in results.description if d[0] in pk_set]
@ -117,7 +123,60 @@ class RowView(DataView):
"<strong>{}</strong>".format(cell["value"])
)
label_column = await db.label_column_for_table(table) if is_table else None
row_path = path_from_row_pks(rows[0], pks, False)
pk_path = path_from_row_pks(rows[0], pks, False, False)
row_label = row_label_from_label_column(expanded_rows[0], label_column)
for display_row in display_rows:
display_row.pk_path = pk_path
display_row.row_path = row_path
display_row.row_label = row_label
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
row_action_permissions = {}
if is_table and db.is_mutable:
row_action_permissions = await self.ds.allowed_many(
actions=["update-row", "delete-row"],
resource=TableResource(database=database, table=table),
actor=request.actor,
)
row_actions = []
if row_action_permissions.get("update-row"):
attrs = {
"aria-label": "Edit row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "edit",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Edit row",
"description": "Open a dialog to edit this row.",
"attrs": attrs,
}
)
if row_action_permissions.get("delete-row"):
attrs = {
"aria-label": "Delete row {}".format(row_action_label),
"data-row": row_path,
"data-row-action": "delete",
}
if row_label:
attrs["data-row-label"] = row_label
row_actions.append(
{
"type": "button",
"label": "Delete row",
"description": "Open a confirmation dialog to delete this row.",
"attrs": attrs,
}
)
for hook in pm.hook.row_actions(
datasette=self.ds,
actor=request.actor,
@ -144,6 +203,16 @@ class RowView(DataView):
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
"_table.html",
],
"row_mutation_ui": any(row_action_permissions.values()),
"table_page_data": await _table_page_data(
self.ds,
request,
db,
database,
table,
not is_table,
None,
),
"row_actions": row_actions,
"top_row": make_slot_function(
"top_row",
@ -249,6 +318,27 @@ class RowError(Exception):
self.error = error
ROW_FLASH_LABEL_MAX_LENGTH = 80
def _truncated_row_flash_label(label):
label = " ".join(str(label).split())
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
return label
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
async def _row_flash_message(db, action, resolved, row=None):
pk_label = ", ".join(resolved.pk_values)
label_column = await db.label_column_for_table(resolved.table)
label = row_label_from_label_column(row or resolved.row, label_column)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:
return "{} row {} ({})".format(action, pk_label, label)
return "{} row {}".format(action, pk_label)
async def _resolve_row_and_check_permission(datasette, request, permission):
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
@ -303,6 +393,15 @@ class RowDeleteView(BaseView):
)
)
if request.args.get("_redirect_to_table"):
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
self.ds.add_message(
request,
await _row_flash_message(resolved.db, "Deleted", resolved),
self.ds.INFO,
)
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
return Response.json({"ok": True}, status=200)
@ -364,11 +463,13 @@ class RowUpdateView(BaseView):
return _error([str(e)], 400)
result = {"ok": True}
returned_row = None
if data.get("return"):
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
result["row"] = results.dicts()[0]
returned_row = results.dicts()[0]
result["row"] = returned_row
await self.ds.track_event(
UpdateRowEvent(
@ -379,4 +480,19 @@ class RowUpdateView(BaseView):
)
)
if request.args.get("_message"):
message_row = returned_row
if message_row is None:
results = await resolved.db.execute(
resolved.sql, resolved.params, truncate=True
)
message_row = results.first()
self.ds.add_message(
request,
await _row_flash_message(
resolved.db, "Updated", resolved, row=message_row
),
self.ds.INFO,
)
return Response.json(result, status=200)

View file

@ -80,7 +80,7 @@ def test_table_plugin_column_field_api():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const tableJs = __TABLE_JS__;
const editToolsJs = __EDIT_TOOLS_JS__;
class FakeEvent {
constructor(type, options) {
@ -180,8 +180,8 @@ def test_table_plugin_column_field_api():
},
};
vm.runInThisContext(fs.readFileSync(tableJs, "utf8"), {
filename: "table.js",
vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), {
filename: "edit-tools.js",
});
const context = columnFormControlContext(
@ -452,7 +452,7 @@ def test_table_plugin_column_field_api():
}
process.stdout.write("ok");
""").replace("__TABLE_JS__", json.dumps(str(STATIC_DIR / "table.js")))
""").replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js")))
result = subprocess.run(
["node", "-e", script],
text=True,
@ -467,7 +467,7 @@ def test_builtin_json_column_field_validation():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const tableJs = __TABLE_JS__;
const editToolsJs = __EDIT_TOOLS_JS__;
class FakeEvent {
constructor(type, options) {
@ -547,8 +547,8 @@ def test_builtin_json_column_field_validation():
},
};
vm.runInThisContext(fs.readFileSync(tableJs, "utf8"), {
filename: "table.js",
vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), {
filename: "edit-tools.js",
});
const plugins = [];
@ -648,7 +648,7 @@ def test_builtin_json_column_field_validation():
}
process.stdout.write("ok");
""").replace("__TABLE_JS__", json.dumps(str(STATIC_DIR / "table.js")))
""").replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js")))
result = subprocess.run(
["node", "-e", script],
text=True,

View file

@ -1151,6 +1151,160 @@ async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
assert rows[0]["data-row-label"] == "hello"
@pytest.mark.asyncio
async def test_row_page_edit_delete_action_menu_buttons():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"update-row": {"id": "root"},
"delete-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_page_edit_delete_actions"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text, score integer);
insert into items (id, name, score) values (1, 'One', 5);
""")
response = await ds.client.get("/data/items/1", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert table_data_from_soup(soup) == {
"database": "data",
"table": "items",
"tableUrl": "/data/items",
}
script_srcs = [script.get("src") or "" for script in soup.find_all("script")]
assert any("edit-tools.js" in src for src in script_srcs)
assert not any("table.js" in src for src in script_srcs)
row = soup.select_one("table.rows-and-columns tbody tr")
assert row["data-row"] == "1"
assert row["data-row-label"] == "One"
edit_button = soup.select_one(
'details.actions-menu-links button.action-menu-button[data-row-action="edit"]'
)
assert edit_button is not None
assert edit_button["aria-label"] == "Edit row 1 One"
assert edit_button["data-row"] == "1"
assert edit_button["data-row-label"] == "One"
assert edit_button["role"] == "menuitem"
assert edit_button.find("span", class_="dropdown-description").text.strip() == (
"Open a dialog to edit this row."
)
edit_button.find("span").extract()
assert edit_button.text.strip() == "Edit row"
delete_button = soup.select_one(
'details.actions-menu-links button.action-menu-button[data-row-action="delete"]'
)
assert delete_button is not None
assert delete_button["aria-label"] == "Delete row 1 One"
assert delete_button["data-row"] == "1"
assert delete_button["data-row-label"] == "One"
assert delete_button["role"] == "menuitem"
assert delete_button.find(
"span", class_="dropdown-description"
).text.strip() == ("Open a confirmation dialog to delete this row.")
delete_button.find("span").extract()
assert delete_button.text.strip() == "Delete row"
finally:
ds.close()
@pytest.mark.asyncio
async def test_row_delete_redirect_to_table_sets_message():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"delete-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_delete_redirect"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
insert into items (id, name) values (1, 'One');
""")
response = await ds.client.post(
"/data/items/1/-/delete?_redirect_to_table=1", actor={"id": "root"}
)
assert response.status_code == 200
assert response.json() == {"ok": True, "redirect": "/data/items"}
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Deleted row 1 (One)", ds.INFO]
]
finally:
ds.close()
@pytest.mark.asyncio
async def test_row_update_sets_message():
ds = Datasette(
[],
config={
"databases": {
"data": {
"tables": {
"items": {
"permissions": {
"update-row": {"id": "root"},
},
},
},
},
},
},
)
try:
db = ds.add_database(
Database(ds, memory_name="test_row_update_message"), name="data"
)
await db.execute_write_script("""
create table items (id integer primary key, name text);
insert into items (id, name) values (1, 'One');
""")
long_name = "Two " + ("long label " * 12)
truncated_name = long_name[:79] + "\u2026"
response = await ds.client.post(
"/data/items/1/-/update?_message=1",
actor={"id": "root"},
json={"update": {"name": long_name}, "return": True},
)
assert response.status_code == 200
assert response.json()["row"]["name"] == long_name
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Updated row 1 ({})".format(truncated_name), ds.INFO]
]
finally:
ds.close()
def test_table_data_uses_base_url(app_client_base_url_prefix):
response = app_client_base_url_prefix.get("/prefix/fixtures/simple_primary_key")
assert response.status_code == 200