From 693f4ed601569671c890b4faa1820445a814d37b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 20 Jun 2026 12:23:52 -0700 Subject: [PATCH] Show insert message in correct place, 1s inactive delay Since I want this to be usable by untrusted datasette-apps it is important that they cannot show the dialog in a way that tricks the user into clicking insert. So a 1s delay on making that button active. --- datasette/static/edit-tools.js | 68 +++++++++++++++++++++++++++------- tests/test_playwright.py | 62 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 04f06ad0..dc51eab5 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -13,22 +13,27 @@ function datasetteManagerPath(manager, path) { function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); - if (status) { - return status; + var tableWrapper = document.querySelector(manager.selectors.tableWrapper); + var content = document.querySelector("section.content"); + var fallbackParent = content && content.parentNode; + + if (!status) { + status = document.createElement("p"); + status.className = "row-mutation-status"; + status.hidden = true; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + status.setAttribute("tabindex", "-1"); } - status = document.createElement("p"); - status.className = "row-mutation-status"; - status.hidden = true; - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - status.setAttribute("tabindex", "-1"); - - var tableWrapper = document.querySelector(manager.selectors.tableWrapper); if (tableWrapper && tableWrapper.parentNode) { tableWrapper.parentNode.insertBefore(status, tableWrapper); - } else { + } else if (content && fallbackParent) { + fallbackParent.insertBefore(status, content); + } else if (!status.parentNode) { document.body.appendChild(status); + } else { + document.body.insertBefore(status, document.body.firstChild); } return status; } @@ -1189,7 +1194,10 @@ function showRowEditDialogError(state, message) { function updateRowEditDialogButtons(state) { state.saveButton.disabled = - state.isLoading || state.isSaving || !state.hasLoaded; + state.isLoading || + state.isSaving || + !state.hasLoaded || + state.submitDelayActive; state.cancelButton.disabled = state.isSaving; var saveLabel = state.mode === "insert" ? "Insert row" : "Save"; state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel; @@ -1210,6 +1218,30 @@ function setRowEditDialogSaving(state, isSaving) { updateRowEditDialogButtons(state); } +function clearRowEditDialogSubmitDelay(state) { + if (state.submitDelayTimer) { + clearTimeout(state.submitDelayTimer); + } + state.submitDelayTimer = null; + state.submitDelayActive = false; +} + +function setRowEditDialogSubmitDelay(state, delayMs) { + clearRowEditDialogSubmitDelay(state); + delayMs = Number(delayMs) || 0; + if (delayMs <= 0) { + updateRowEditDialogButtons(state); + return; + } + state.submitDelayActive = true; + state.submitDelayTimer = setTimeout(function () { + state.submitDelayTimer = null; + state.submitDelayActive = false; + updateRowEditDialogButtons(state); + }, delayMs); + updateRowEditDialogButtons(state); +} + function valueFromRowEditControl(control) { var value = control.value; return valueFromRowEditText( @@ -1545,7 +1577,12 @@ function addInsertedRowToPage(rowElement) { } async function saveRowEditDialog(state) { - if (state.isLoading || state.isSaving || !state.hasLoaded) { + if ( + state.isLoading || + state.isSaving || + !state.hasLoaded || + state.submitDelayActive + ) { return; } clearRowEditDialogError(state); @@ -1907,6 +1944,8 @@ function ensureRowEditDialog(manager) { isSaving: false, isClosePending: false, hasLoaded: false, + submitDelayActive: false, + submitDelayTimer: null, shouldRestoreFocus: true, }; @@ -1955,6 +1994,7 @@ function ensureRowEditDialog(manager) { state.isClosePending = false; clearRowEditDialogError(state); state.hasLoaded = false; + clearRowEditDialogSubmitDelay(state); destroyRowEditFields(state); setRowEditDialogLoading(state, false); setRowEditDialogSaving(state, false); @@ -2173,6 +2213,7 @@ function openRowInsertDialogWithData(options) { clearRowEditDialogError(state); setRowEditDialogLoading(state, false); destroyRowEditFields(state); + setRowEditDialogSubmitDelay(state, options.submitDelayMs); setRowDialogTitle( state.title, insertData.table_name ? "Insert row into" : "Insert row", @@ -2232,6 +2273,7 @@ async function insertDialog(manager, database, table, row, message, options) { resolve: resolve, flashMessage: !!options.flashMessage, refreshAfterInsert: false, + submitDelayMs: options.submitDelayMs, }); if (!opened) { reject(new Error("Could not open the insert dialog")); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 69eb1fed..eed9efb8 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -416,6 +416,15 @@ def test_insert_dialog_javascript_api_returns_inserted_row(page, datasette_serve assert result["row"]["notes"] == "Created through insertDialog()" assert result["row"]["license"] == "apache-2.0" assert result["row"]["score"] == 7 + page.locator(".row-mutation-status", has_text="Inserted row 2").wait_for() + assert page.evaluate(""" + () => { + const status = document.querySelector(".row-mutation-status"); + const footer = document.querySelector("footer.ft"); + return !!(status.compareDocumentPosition(footer) & + Node.DOCUMENT_POSITION_FOLLOWING); + } + """) data = project_row(datasette_server, 2) assert data["title"] == "Suggested from JavaScript" @@ -425,6 +434,59 @@ def test_insert_dialog_javascript_api_returns_inserted_row(page, datasette_serve assert data["score"] == 7 +@pytest.mark.playwright +def test_insert_dialog_submit_delay_blocks_submit_until_elapsed(page, datasette_server): + page.goto(datasette_server) + page.wait_for_function("window.datasette && window.datasette.insertDialog") + + page.evaluate(""" + () => { + window.delayedInsertSettled = false; + window.delayedInsertResult = window.datasette.insertDialog( + "data", + "projects", + { + title: "Delayed JavaScript insert", + metadata: '{"delayed": true}', + notes: "The submit button starts disabled.", + license: "mit" + }, + "Review this suggested project before inserting it.", + {submitDelayMs: 1000} + ); + window.delayedInsertResult.then(() => { + window.delayedInsertSettled = true; + }); + } + """) + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + save = dialog.locator(".row-edit-save") + save.wait_for() + assert save.is_disabled() + + page.evaluate(""" + () => document.querySelector("#row-edit-dialog form").requestSubmit() + """) + page.wait_for_timeout(100) + assert page.evaluate("() => window.delayedInsertSettled") is False + assert not project_rows(datasette_server, title="Delayed JavaScript insert") + + page.wait_for_function(""" + () => !document.querySelector("#row-edit-dialog .row-edit-save").disabled + """) + save.click() + result = page.evaluate("() => window.delayedInsertResult") + assert result["ok"] is True + assert result["status"] == "inserted" + assert result["row"]["title"] == "Delayed JavaScript insert" + + data = project_row(datasette_server, 2) + assert data["title"] == "Delayed JavaScript insert" + assert data["license"] == "mit" + + @pytest.mark.playwright def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): page.goto(f"{datasette_server}data/projects")