From 20824bd707c35ec56830ba8465cb3b5f7698a6a3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 13 Jun 2026 14:40:29 -0700 Subject: [PATCH] Delete icon on table page now works --- datasette/static/app.css | 159 +++++++++++++++++++ datasette/static/table.js | 273 ++++++++++++++++++++++++++++++++ datasette/templates/_table.html | 2 +- datasette/views/table.py | 37 ++++- tests/test_table_html.py | 47 ++++++ 5 files changed, 513 insertions(+), 5 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index ec3a85fb..36335146 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1192,6 +1192,147 @@ dialog.set-column-type-dialog::backdrop { cursor: wait; } +.row-delete-status { + margin: 0 0 0.75rem; + padding: 8px 10px; + border-left: 4px solid #54AC8E; + background: rgba(103,201,141,0.12); + color: #222; +} + +.row-delete-status[hidden] { + display: none; +} + +.row-delete-status-error { + border-left-color: #D0021B; + background: rgba(208,2,27,0.12); +} + +dialog.row-delete-dialog { + --ink: #0f0f0f; + --paper: #f5f3ef; + --muted: #6b6b6b; + --rule: #e2dfd8; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(440px, calc(100vw - 32px)); + max-width: 95vw; + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.row-delete-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-delete-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.row-delete-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-shrink: 0; +} + +.row-delete-dialog .modal-title { + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-delete-message, +.row-delete-error { + margin: 0; + padding: 16px 24px 0; +} + +.row-delete-message { + color: var(--ink); + font-size: 0.95rem; +} + +.row-delete-id { + display: inline; + padding: 2px 5px; + border: 1px solid var(--rule); + border-radius: 4px; + background: var(--paper); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.92em; + overflow-wrap: anywhere; +} + +.row-delete-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.row-delete-dialog .modal-footer { + padding: 18px 20px 14px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); + margin-top: 18px; +} + +.row-delete-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.row-delete-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-delete-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-delete-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-delete-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-delete-dialog .btn:disabled { + opacity: 0.65; + cursor: wait; +} + .row-link-with-actions { display: inline-flex; align-items: center; @@ -1284,6 +1425,24 @@ dialog.set-column-type-dialog::backdrop { padding-right: 18px; } + dialog.row-delete-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-delete-dialog .modal-header, + .row-delete-message, + .row-delete-error { + padding-left: 18px; + padding-right: 18px; + } + + .row-delete-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + .row-inline-action { min-height: 30px; min-width: 30px; diff --git a/datasette/static/table.js b/datasette/static/table.js index e9115453..cd69f474 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -12,6 +12,8 @@ var DROPDOWN_ICON_SVG = ` + Delete row + +

Delete row ?

+ + + `; + document.body.appendChild(dialog); + + rowDeleteDialogState = { + dialog: dialog, + message: dialog.querySelector(".row-delete-message"), + rowId: dialog.querySelector(".row-delete-id"), + error: dialog.querySelector(".row-delete-error"), + cancelButton: dialog.querySelector(".row-delete-cancel"), + confirmButton: dialog.querySelector(".row-delete-confirm"), + currentRow: null, + currentDeleteUrl: null, + currentPkPath: null, + manager: manager, + isBusy: false, + shouldRestoreFocus: true, + }; + + rowDeleteDialogState.cancelButton.addEventListener("click", function () { + if (!rowDeleteDialogState.isBusy) { + rowDeleteDialogState.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog && !rowDeleteDialogState.isBusy) { + rowDeleteDialogState.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if ( + ev.key === "Enter" && + document.activeElement === rowDeleteDialogState.confirmButton + ) { + ev.preventDefault(); + if (!rowDeleteDialogState.isBusy) { + rowDeleteDialogState.confirmButton.click(); + } + return; + } + if (ev.key !== "Escape") { + return; + } + if (rowDeleteDialogState.isBusy) { + ev.preventDefault(); + return; + } + ev.preventDefault(); + rowDeleteDialogState.shouldRestoreFocus = true; + dialog.close(); + }); + + dialog.addEventListener("cancel", function (ev) { + if (rowDeleteDialogState.isBusy) { + ev.preventDefault(); + } else { + rowDeleteDialogState.shouldRestoreFocus = true; + } + }); + + dialog.addEventListener("close", function () { + var state = rowDeleteDialogState; + clearRowDeleteDialogError(state); + setRowDeleteDialogBusy(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + rowDeleteDialogState.confirmButton.addEventListener("click", async function () { + var state = rowDeleteDialogState; + clearRowDeleteDialogError(state); + setRowDeleteDialogBusy(state, true); + + try { + var response = await fetch(state.currentDeleteUrl, { + method: "POST", + headers: { + Accept: "application/json", + }, + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + throw rowDeleteRequestError(response, data); + } + + var focusTarget = nextRowDeleteFocusTarget(state.currentRow, state.manager); + var statusMessage = state.currentPkPath + ? "Deleted row " + state.currentPkPath + "." + : "Deleted row."; + state.shouldRestoreFocus = false; + state.dialog.close(); + state.currentRow.remove(); + showRowDeleteStatus(state.manager, statusMessage, false); + if (focusTarget && document.contains(focusTarget)) { + focusTarget.focus(); + } else { + ensureRowDeleteStatus(state.manager).focus(); + } + } catch (error) { + setRowDeleteDialogBusy(state, false); + showRowDeleteDialogError(state, error.message || "Delete failed"); + } + }); + + return rowDeleteDialogState; +} + +function openRowDeleteDialog(button, manager) { + var row = button.closest("tr[data-row-delete-url]"); + if (!row || !row.dataset.rowDeleteUrl) { + return; + } + var state = ensureRowDeleteDialog(manager); + if (!state) { + return; + } + + state.manager = manager; + state.currentButton = button; + state.currentRow = row; + state.currentDeleteUrl = row.dataset.rowDeleteUrl; + state.currentPkPath = row.dataset.rowPkPath || ""; + state.shouldRestoreFocus = true; + + clearRowDeleteDialogError(state); + setRowDeleteDialogBusy(state, false); + state.rowId.textContent = state.currentPkPath || "this row"; + + if (!state.dialog.open) { + state.dialog.showModal(); + } + state.confirmButton.focus(); +} + +function initRowDeleteActions(manager) { + if (!window.fetch || !window.HTMLDialogElement) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-row-action="delete"]'); + if (!button) { + return; + } + ev.preventDefault(); + openRowDeleteDialog(button, manager); + }); +} + function canChooseColumns() { return !!( document.querySelector("column-chooser") && window._columnChooserData @@ -750,6 +1022,7 @@ document.addEventListener("datasette_init", function (evt) { // Main table initDatasetteTable(manager); + initRowDeleteActions(manager); // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index f47a325f..36b0ef7e 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 49238ff4..f1d4ea45 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -59,8 +59,19 @@ LINK_WITH_VALUE = '{id}' class Row: - def __init__(self, cells): + def __init__( + self, + cells, + pk_path=None, + row_path=None, + row_url=None, + delete_url=None, + ): self.cells = cells + self.pk_path = pk_path + self.row_path = row_path + self.row_url = row_url + self.delete_url = delete_url def __iter__(self): return iter(self.cells) @@ -241,8 +252,14 @@ 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) + table_path = datasette.urls.table(database_name, table_name) + row_url = "{table_path}/{row_path}".format( + table_path=table_path, + row_path=row_path, + ) + delete_url = "{row_url}/-/delete".format(row_url=row_url) row_link = '{flat_pks}'.format( - table_path=datasette.urls.table(database_name, table_name), + table_path=table_path, flat_pks=str(markupsafe.escape(pk_path)), flat_pks_quoted=row_path, ) @@ -280,7 +297,8 @@ async def display_columns_and_rows( if row_action_permissions.get("delete-row"): row_actions.append( '".format( delete_icon=delete_icon, row_label=markupsafe.escape(pk_path), @@ -404,7 +422,18 @@ async def display_columns_and_rows( ), } ) - cell_rows.append(Row(cells)) + if link_column: + cell_rows.append( + Row( + cells, + pk_path=pk_path, + row_path=row_path, + row_url=row_url, + delete_url=delete_url, + ) + ) + else: + cell_rows.append(Row(cells)) if link_column: # Add the link column header. diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 63e233fa..14d463ae 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1,4 +1,5 @@ from datasette.app import Datasette +from datasette.database import Database from bs4 import BeautifulSoup as Soup from .fixtures import make_app_client import pathlib @@ -828,6 +829,52 @@ async def test_mobile_column_actions_present(ds_client, path): assert len(ths) >= 1 +@pytest.mark.asyncio +async def test_row_delete_action_data_attributes(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "delete-row": {"id": "root"}, + }, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_row_delete_actions"), 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.get("/data/items", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + row = soup.select_one("table.rows-and-columns tbody tr") + assert row["data-row-pk-path"] == "1" + assert row["data-row-path"] == "1" + assert row["data-row-url"] == "/data/items/1" + assert row["data-row-delete-url"] == "/data/items/1/-/delete" + + button = row.select_one( + 'button.row-inline-action-delete[data-row-action="delete"]' + ) + assert button is not None + assert button["aria-label"] == "Delete row 1" + assert button["title"] == "Delete row" + assert button.find("svg") is not None + finally: + ds.close() + + @pytest.mark.asyncio async def test_zero_row_table_renders_thead(ds_client): response = await ds_client.get("/fixtures/123_starts_with_digits")