Use column label, if available in edit/delete dialog

This commit is contained in:
Simon Willison 2026-06-13 21:43:48 -07:00
commit e91c646ee6
5 changed files with 105 additions and 16 deletions

View file

@ -1264,9 +1264,15 @@ dialog.row-delete-dialog::backdrop {
justify-content: flex-start;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.row-delete-dialog .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
@ -1388,16 +1394,30 @@ dialog.row-edit-dialog::backdrop {
align-items: center;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.row-edit-dialog .modal-title {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.row-edit-dialog .modal-title code {
.row-edit-dialog .modal-title .row-dialog-action,
.row-delete-dialog .modal-title .row-dialog-action {
flex: 0 0 auto;
white-space: nowrap;
}
.row-edit-dialog .modal-title code,
.row-delete-dialog .modal-title code {
display: inline;
flex: 0 0 auto;
padding: 2px 5px;
border: 1px solid var(--rule);
border-radius: 4px;
@ -1407,6 +1427,14 @@ dialog.row-edit-dialog::backdrop {
overflow-wrap: anywhere;
}
.row-edit-dialog .modal-title .row-dialog-label,
.row-delete-dialog .modal-title .row-dialog-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-edit-form {
display: flex;
flex: 1 1 auto;

View file

@ -471,6 +471,10 @@ function rowDisplayLabel(row) {
return tildeDecode(row.getAttribute("data-row") || "");
}
function rowTitleLabel(row) {
return row.getAttribute("data-row-label") || "";
}
function tableBaseUrl() {
var tableUrl =
window._datasetteTableData && window._datasetteTableData.tableUrl;
@ -607,6 +611,7 @@ function ensureRowDeleteDialog(manager) {
rowDeleteDialogState = {
dialog: dialog,
title: dialog.querySelector(".modal-title"),
message: dialog.querySelector(".row-delete-message"),
rowId: dialog.querySelector(".row-delete-id"),
error: dialog.querySelector(".row-delete-error"),
@ -741,6 +746,12 @@ function openRowDeleteDialog(button, manager) {
clearRowDeleteDialogError(state);
setRowDeleteDialogBusy(state, false);
setRowDialogTitle(
state.title,
"Delete row",
state.currentPkPath || "this row",
rowTitleLabel(row),
);
state.rowId.textContent = state.currentPkPath || "this row";
if (!state.dialog.open) {
@ -1335,16 +1346,26 @@ function renderRowInsertFields(state, data) {
(firstControl || state.saveButton).focus();
}
function setRowEditDialogTitle(state, text, codeText) {
state.title.textContent = "";
state.title.appendChild(document.createTextNode(text));
function setRowDialogTitle(title, text, codeText, labelText) {
title.textContent = "";
var action = document.createElement("span");
action.className = "row-dialog-action";
action.textContent = text;
title.appendChild(action);
if (!codeText) {
return;
}
state.title.appendChild(document.createTextNode(" "));
title.appendChild(document.createTextNode(" "));
var code = document.createElement("code");
code.textContent = codeText;
state.title.appendChild(code);
title.appendChild(code);
if (labelText && labelText !== codeText) {
title.appendChild(document.createTextNode(" "));
var label = document.createElement("span");
label.className = "row-dialog-label";
label.textContent = labelText;
title.appendChild(label);
}
}
function ensureRowEditDialog(manager) {
@ -1494,7 +1515,12 @@ async function openRowEditDialog(button, manager) {
setRowEditDialogLoading(state, true);
state.fields.innerHTML = "";
state.dialog.removeAttribute("aria-describedby");
setRowEditDialogTitle(state, "Edit row", state.currentPkPath || "this row");
setRowDialogTitle(
state.title,
"Edit row",
state.currentPkPath || "this row",
rowTitleLabel(row),
);
state.summary.hidden = true;
state.summary.textContent = "";
@ -1561,8 +1587,8 @@ function openRowInsertDialog(button, manager) {
setRowEditDialogLoading(state, false);
state.fields.innerHTML = "";
state.dialog.removeAttribute("aria-describedby");
setRowEditDialogTitle(
state,
setRowDialogTitle(
state.title,
insertData.tableName ? "Insert row into " + insertData.tableName : "Insert row",
);
state.summary.hidden = true;

View file

@ -22,7 +22,7 @@
</thead>
<tbody>
{% for row in display_rows %}
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% endif %}>
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
{% for cell in row %}
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
{% endfor %}

View file

@ -66,10 +66,12 @@ class Row:
cells,
pk_path=None,
row_path=None,
row_label=None,
):
self.cells = cells
self.pk_path = pk_path
self.row_path = row_path
self.row_label = row_label
def __iter__(self):
return iter(self.cells)
@ -96,6 +98,20 @@ class Row:
return json.dumps(d, default=repr, indent=2)
def row_label_from_label_column(row, label_column):
if not label_column:
return None
try:
value = row[label_column]
except KeyError:
return None
if isinstance(value, dict):
value = value.get("label")
if value is None or value == "":
return None
return str(value)
async def run_sequential(*args):
# This used to be swappable for asyncio.gather() to run things in
# parallel, but this lead to hard-to-debug locking issues with
@ -325,6 +341,9 @@ async def display_columns_and_rows(
pks_for_display = pks
if not pks_for_display:
pks_for_display = ["rowid"]
label_column = None
if link_column:
label_column = await db.label_column_for_table(table_name)
row_action_permissions = {}
if link_column and request is not None and db.is_mutable:
row_action_permissions = await datasette.allowed_many(
@ -372,6 +391,10 @@ async def display_columns_and_rows(
is_special_link_column = len(pks) != 1
pk_path = path_from_row_pks(row, pks, not pks, False)
row_path = path_from_row_pks(row, pks, not pks)
row_label = row_label_from_label_column(row, label_column)
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
table_path = datasette.urls.table(database_name, table_name)
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
table_path=table_path,
@ -407,7 +430,7 @@ async def display_columns_and_rows(
'data-row-action="edit">'
"{edit_icon}</button>".format(
edit_icon=edit_icon,
row_label=markupsafe.escape(pk_path),
row_label=markupsafe.escape(row_action_label),
)
)
if row_action_permissions.get("delete-row"):
@ -417,7 +440,7 @@ async def display_columns_and_rows(
'data-row-action="delete">'
"{delete_icon}</button>".format(
delete_icon=delete_icon,
row_label=markupsafe.escape(pk_path),
row_label=markupsafe.escape(row_action_label),
)
)
if row_actions:
@ -544,6 +567,7 @@ async def display_columns_and_rows(
cells,
pk_path=pk_path,
row_path=row_path,
row_label=row_label,
)
)
else:

View file

@ -682,8 +682,10 @@ async def test_table_html_compound_primary_key(ds_client):
rows = table.select("tbody tr")
assert rows[0]["data-row"] == "a,b"
assert "data-row-pk-path" not in rows[0].attrs
assert "data-row-label" not in rows[0].attrs
assert rows[1]["data-row"] == "a~2Fb,~2Ec-d"
assert "data-row-pk-path" not in rows[1].attrs
assert "data-row-label" not in rows[1].attrs
@pytest.mark.asyncio
@ -884,13 +886,17 @@ async def test_row_delete_action_data_attributes():
row = soup.select_one("table.rows-and-columns tbody tr")
assert row["data-row"] == "1"
assert {key for key in row.attrs if key.startswith("data-row")} == {"data-row"}
assert row["data-row-label"] == "One"
assert {key for key in row.attrs if key.startswith("data-row")} == {
"data-row",
"data-row-label",
}
edit_button = row.select_one(
'button.row-inline-action-edit[data-row-action="edit"]'
)
assert edit_button is not None
assert edit_button["aria-label"] == "Edit row 1"
assert edit_button["aria-label"] == "Edit row 1 One"
assert edit_button["title"] == "Edit row"
assert edit_button.find("svg") is not None
@ -898,7 +904,7 @@ async def test_row_delete_action_data_attributes():
'button.row-inline-action-delete[data-row-action="delete"]'
)
assert button is not None
assert button["aria-label"] == "Delete row 1"
assert button["aria-label"] == "Delete row 1 One"
assert button["title"] == "Delete row"
assert button.find("svg") is not None
finally:
@ -1033,7 +1039,11 @@ async def test_table_fragment_endpoint(ds_client):
rows = soup.select("[data-row]")
assert len(rows) == 1
assert rows[0]["data-row"] == "1"
assert {key for key in rows[0].attrs if key.startswith("data-row")} == {"data-row"}
assert rows[0]["data-row-label"] == "hello"
assert {key for key in rows[0].attrs if key.startswith("data-row")} == {
"data-row",
"data-row-label",
}
@pytest.mark.asyncio
@ -1046,6 +1056,7 @@ async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
rows = soup.select("[data-row]")
assert len(rows) == 1
assert rows[0]["data-row"] == "1"
assert rows[0]["data-row-label"] == "hello"
def test_table_data_uses_base_url(app_client_base_url_prefix):