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.
This commit is contained in:
Simon Willison 2026-06-20 12:23:52 -07:00
commit 693f4ed601
2 changed files with 117 additions and 13 deletions

View file

@ -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"));

View file

@ -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")