From ad3456dc4af9f574c3c34f79951bf087bff7ffb0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 13 Jun 2026 14:48:44 -0700 Subject: [PATCH] Display of edit modal (no save yet) --- datasette/static/app.css | 226 +++++++++++++++++++++++++ datasette/static/table.js | 287 ++++++++++++++++++++++++++++++++ datasette/templates/_table.html | 2 +- datasette/views/table.py | 7 +- tests/test_table_html.py | 10 ++ 5 files changed, 530 insertions(+), 2 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 36335146..5c92d59c 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1333,6 +1333,199 @@ dialog.row-delete-dialog::backdrop { cursor: wait; } +dialog.row-edit-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(720px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, calc(100vh - 32px)); + 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-edit-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.row-edit-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-edit-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.row-edit-dialog .modal-title { + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.row-edit-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.row-edit-summary, +.row-edit-loading, +.row-edit-error { + margin: 0; + padding: 12px 24px 0; +} + +.row-edit-summary, +.row-edit-loading { + color: var(--muted); + font-size: 0.9rem; +} + +.row-edit-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-edit-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.row-edit-fields { + display: grid; + gap: 14px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.row-edit-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.row-edit-label { + padding-top: 8px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.row-edit-control-wrap { + display: grid; + gap: 5px; +} + +.row-edit-input { + box-sizing: border-box; + width: 100%; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +textarea.row-edit-input { + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.row-edit-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.row-edit-input[readonly] { + color: var(--muted); + background: var(--paper); +} + +.row-edit-field-meta { + color: var(--muted); + font-size: 0.78rem; +} + +.row-edit-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.row-edit-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-edit-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.row-edit-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.row-edit-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.row-edit-dialog .btn-primary:hover { + background: #1949b8; +} + +.row-edit-dialog .btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .row-link-with-actions { display: inline-flex; align-items: center; @@ -1443,6 +1636,35 @@ dialog.row-delete-dialog::backdrop { padding-right: 18px; } + dialog.row-edit-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .row-edit-dialog .modal-header, + .row-edit-summary, + .row-edit-loading, + .row-edit-error, + .row-edit-fields { + padding-left: 18px; + padding-right: 18px; + } + + .row-edit-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .row-edit-label { + padding-top: 0; + } + + .row-edit-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + .row-inline-action { min-height: 30px; min-width: 30px; @@ -1507,6 +1729,10 @@ dialog.row-delete-dialog::backdrop { font-size: 0.8em; } + .row-inline-actions { + margin-bottom: 0.35rem; + } + .select-wrapper { width: 100px; } diff --git a/datasette/static/table.js b/datasette/static/table.js index cd69f474..044c9509 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -14,6 +14,8 @@ var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog"; var setColumnTypeDialogState = null; var ROW_DELETE_DIALOG_ID = "row-delete-dialog"; var rowDeleteDialogState = null; +var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; +var rowEditDialogState = null; function getParams() { return new URLSearchParams(location.search); @@ -627,6 +629,290 @@ function initRowDeleteActions(manager) { }); } +function rowJsonUrl(row) { + var url = new URL(row.dataset.rowUrl, location.href); + url.pathname = url.pathname + ".json"; + url.searchParams.set("_extra", "columns,column_types"); + return url.toString(); +} + +function valueToEditText(value) { + if (value === null || typeof value === "undefined") { + return ""; + } + if (typeof value === "object") { + return JSON.stringify(value, null, 2); + } + return String(value); +} + +function shouldUseTextarea(value) { + if (value && typeof value === "object") { + return true; + } + var text = valueToEditText(value); + return text.length > 80 || text.indexOf("\n") !== -1; +} + +function createRowEditField(column, value, isPk, columnType, index) { + var field = document.createElement("div"); + field.className = "row-edit-field"; + + var fieldId = "row-edit-field-" + index; + var metaId = "row-edit-field-meta-" + index; + var label = document.createElement("label"); + label.className = "row-edit-label"; + label.setAttribute("for", fieldId); + label.textContent = column; + + var controlWrap = document.createElement("div"); + controlWrap.className = "row-edit-control-wrap"; + + var control = shouldUseTextarea(value) + ? document.createElement("textarea") + : document.createElement("input"); + control.className = "row-edit-input"; + control.id = fieldId; + control.name = column; + control.value = valueToEditText(value); + control.setAttribute("aria-describedby", metaId); + control.dataset.originalValue = valueToEditText(value); + + if (control.nodeName === "TEXTAREA") { + control.rows = Math.min(8, Math.max(3, control.value.split("\n").length)); + } else { + control.type = "text"; + } + + if (isPk) { + control.readOnly = true; + } + + var meta = document.createElement("span"); + meta.id = metaId; + meta.className = "row-edit-field-meta"; + var metaParts = []; + if (isPk) { + metaParts.push("Primary key"); + } + if (value === null) { + metaParts.push("Current value: NULL"); + control.placeholder = "NULL"; + } + if (columnType && columnType.type) { + metaParts.push("Custom type: " + columnType.type); + } + meta.textContent = metaParts.join(" ยท "); + + controlWrap.appendChild(control); + if (meta.textContent) { + controlWrap.appendChild(meta); + } + field.appendChild(label); + field.appendChild(controlWrap); + return field; +} + +function clearRowEditDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; +} + +function showRowEditDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; +} + +function setRowEditDialogLoading(state, isLoading) { + state.isLoading = isLoading; + state.loading.hidden = !isLoading; +} + +function renderRowEditFields(state, data) { + var row = data.rows && data.rows.length ? data.rows[0] : null; + var columns = data.columns || (row ? Object.keys(row) : []); + var primaryKeys = data.primary_keys || []; + var columnTypes = data.column_types || {}; + + state.fields.innerHTML = ""; + columns.forEach(function (column, index) { + state.fields.appendChild( + createRowEditField( + column, + row ? row[column] : null, + primaryKeys.indexOf(column) !== -1, + columnTypes[column], + index, + ), + ); + }); + + var firstEditable = state.fields.querySelector(".row-edit-input:not([readonly])"); + var firstField = state.fields.querySelector(".row-edit-input"); + (firstEditable || firstField || state.cancelButton).focus(); +} + +function ensureRowEditDialog(manager) { + if (rowEditDialogState) { + return rowEditDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = ROW_EDIT_DIALOG_ID; + dialog.className = "row-edit-dialog"; + dialog.setAttribute("aria-labelledby", "row-edit-title"); + dialog.setAttribute("aria-describedby", "row-edit-summary"); + dialog.innerHTML = ` + +
+

Editing row

+

Loading row...

+ +
+ +
+ `; + document.body.appendChild(dialog); + + rowEditDialogState = { + dialog: dialog, + form: dialog.querySelector(".row-edit-form"), + rowId: dialog.querySelector(".row-edit-id"), + loading: dialog.querySelector(".row-edit-loading"), + error: dialog.querySelector(".row-edit-error"), + fields: dialog.querySelector(".row-edit-fields"), + cancelButton: dialog.querySelector(".row-edit-cancel"), + saveButton: dialog.querySelector(".row-edit-save"), + currentButton: null, + currentRow: null, + currentPkPath: null, + loadId: 0, + manager: manager, + isLoading: false, + shouldRestoreFocus: true, + }; + + rowEditDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + }); + + rowEditDialogState.cancelButton.addEventListener("click", function () { + rowEditDialogState.shouldRestoreFocus = true; + dialog.close(); + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + rowEditDialogState.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + rowEditDialogState.shouldRestoreFocus = true; + dialog.close(); + }); + + dialog.addEventListener("cancel", function () { + rowEditDialogState.shouldRestoreFocus = true; + }); + + dialog.addEventListener("close", function () { + var state = rowEditDialogState; + state.loadId += 1; + clearRowEditDialogError(state); + setRowEditDialogLoading(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return rowEditDialogState; +} + +async function openRowEditDialog(button, manager) { + var row = button.closest("tr[data-row-url]"); + if (!row || !row.dataset.rowUrl) { + return; + } + var state = ensureRowEditDialog(manager); + if (!state) { + return; + } + + state.manager = manager; + state.currentButton = button; + state.currentRow = row; + state.currentPkPath = row.dataset.rowPkPath || ""; + state.shouldRestoreFocus = true; + state.loadId += 1; + var loadId = state.loadId; + + clearRowEditDialogError(state); + setRowEditDialogLoading(state, true); + state.fields.innerHTML = ""; + state.rowId.textContent = state.currentPkPath || "this row"; + + if (!state.dialog.open) { + state.dialog.showModal(); + } + state.cancelButton.focus(); + + try { + var response = await fetch(rowJsonUrl(row), { + headers: { + Accept: "application/json", + }, + }); + var data = await response.json(); + if (loadId !== state.loadId) { + return; + } + if (!response.ok || data.ok === false) { + throw rowDeleteRequestError(response, data); + } + setRowEditDialogLoading(state, false); + renderRowEditFields(state, data); + } catch (error) { + if (loadId !== state.loadId) { + return; + } + setRowEditDialogLoading(state, false); + showRowEditDialogError(state, error.message || "Could not load row"); + state.cancelButton.focus(); + } +} + +function initRowEditActions(manager) { + if (!window.fetch || !window.HTMLDialogElement) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-row-action="edit"]'); + if (!button) { + return; + } + ev.preventDefault(); + openRowEditDialog(button, manager); + }); +} + function canChooseColumns() { return !!( document.querySelector("column-chooser") && window._columnChooserData @@ -1022,6 +1308,7 @@ document.addEventListener("datasette_init", function (evt) { // Main table initDatasetteTable(manager); + initRowEditActions(manager); initRowDeleteActions(manager); // Other UI functions with interactive JS needs diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index 36b0ef7e..2ecf5cc2 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 f1d4ea45..cd6a016a 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -66,12 +66,14 @@ class Row: row_path=None, row_url=None, delete_url=None, + update_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 + self.update_url = update_url def __iter__(self): return iter(self.cells) @@ -258,6 +260,7 @@ async def display_columns_and_rows( row_path=row_path, ) delete_url = "{row_url}/-/delete".format(row_url=row_url) + update_url = "{row_url}/-/update".format(row_url=row_url) row_link = '{flat_pks}'.format( table_path=table_path, flat_pks=str(markupsafe.escape(pk_path)), @@ -288,7 +291,8 @@ async def display_columns_and_rows( if row_action_permissions.get("update-row"): row_actions.append( '".format( edit_icon=edit_icon, row_label=markupsafe.escape(pk_path), @@ -430,6 +434,7 @@ async def display_columns_and_rows( row_path=row_path, row_url=row_url, delete_url=delete_url, + update_url=update_url, ) ) else: diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 14d463ae..6be6942d 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -839,6 +839,7 @@ async def test_row_delete_action_data_attributes(): "tables": { "items": { "permissions": { + "update-row": {"id": "root"}, "delete-row": {"id": "root"}, }, }, @@ -863,6 +864,15 @@ async def test_row_delete_action_data_attributes(): assert row["data-row-path"] == "1" assert row["data-row-url"] == "/data/items/1" assert row["data-row-delete-url"] == "/data/items/1/-/delete" + assert row["data-row-update-url"] == "/data/items/1/-/update" + + 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["title"] == "Edit row" + assert edit_button.find("svg") is not None button = row.select_one( 'button.row-inline-action-delete[data-row-action="delete"]'