From e91c646ee6890b564cc9433c86d21b2ac5804f5b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 13 Jun 2026 21:43:48 -0700 Subject: [PATCH] Use column label, if available in edit/delete dialog --- datasette/static/app.css | 30 ++++++++++++++++++++++- datasette/static/table.js | 42 ++++++++++++++++++++++++++------- datasette/templates/_table.html | 2 +- datasette/views/table.py | 28 ++++++++++++++++++++-- tests/test_table_html.py | 19 +++++++++++---- 5 files changed, 105 insertions(+), 16 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 9226580b..8e68c1bf 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -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; diff --git a/datasette/static/table.js b/datasette/static/table.js index c8109c62..53c4592b 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -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; diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index 38454da3..171b6442 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {{ cell.value }} {% endfor %} diff --git a/datasette/views/table.py b/datasette/views/table.py index c58e39f3..c962637f 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -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 = '{flat_pks}'.format( table_path=table_path, @@ -407,7 +430,7 @@ async def display_columns_and_rows( 'data-row-action="edit">' "{edit_icon}".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}".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: diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 71d4b852..364457d9 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -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):