From a76646e3fd21ac8aa509674951a65a9808f73454 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Jun 2026 22:51:21 -0700 Subject: [PATCH 1/4] GET /db/table/-/insert returns data needed for dialog As part of making the insert dialog less dependent on the table page. --- datasette/static/edit-tools.js | 8 +- datasette/views/table.py | 46 +++++++++++- docs/json_api.rst | 42 ++++++++++- tests/test_table_html.py | 129 ++++++++++++++++++++++++++++++++- 4 files changed, 214 insertions(+), 11 deletions(-) diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 8142d02b..bafd6394 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -682,7 +682,7 @@ function columnFormControlContext(column, isPk, columnType, options) { database: pageData.database || null, table: pageData.table || - (tableInsertData() && tableInsertData().tableName) || + (tableInsertData() && tableInsertData().table_name) || null, tableUrl: pageData.tableUrl || null, column: column, @@ -1542,7 +1542,7 @@ async function saveRowEditDialog(state) { data && data.rows && data.rows.length ? data.rows[0] : null; var insertedRowId = rowPathFromRowData( insertedRowData, - insertData.primaryKeys || [], + insertData.primary_keys || [], ); state.shouldRestoreFocus = false; if (!insertedRowId) { @@ -1972,8 +1972,8 @@ function openRowInsertDialog(button, manager) { state.dialog.removeAttribute("aria-describedby"); setRowDialogTitle( state.title, - insertData.tableName - ? "Insert row into " + insertData.tableName + insertData.table_name + ? "Insert row into " + insertData.table_name : "Insert row", ); state.summary.hidden = true; diff --git a/datasette/views/table.py b/datasette/views/table.py index c5448c85..9a3fbbc0 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -345,9 +345,9 @@ async def _table_insert_ui( return { "path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)), - "tableName": table_name, + "table_name": table_name, "columns": columns, - "primaryKeys": pks, + "primary_keys": pks, } @@ -655,6 +655,48 @@ class TableInsertView(BaseView): def __init__(self, datasette): self.ds = datasette + async def get(self, request): + try: + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if resolved.is_view: + return _error(["Cannot insert rows into a view"], 403) + + if not db.is_mutable: + return _error(["Database is immutable"], 403) + + if not await self.ds.allowed( + action="insert-row", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied"], 403) + + pks = await db.primary_keys(table_name) + table_insert_ui = await _table_insert_ui( + self.ds, request, db, database_name, table_name, resolved.is_view, pks + ) + return Response.json( + { + "ok": True, + "insert_row": table_insert_ui, + "table": { + "database": database_name, + "name": table_name, + "url": self.ds.urls.table(database_name, table_name), + }, + "foreign_keys": await _foreign_key_autocomplete_urls( + self.ds, request, db, database_name, table_name + ), + } + ) + async def _validate_data(self, request, db, table_name, pks, upsert): errors = [] diff --git a/docs/json_api.rst b/docs/json_api.rst index f7a0caae..7a20c44b 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -1530,7 +1530,7 @@ Here's how to serve ``data.db`` with CORS enabled:: The JSON write API ------------------ -Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`. +Datasette provides a write API for JSON data. Write operations use ``POST`` and require an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`. .. _ExecuteWriteView: @@ -1643,6 +1643,46 @@ Inserting rows This requires the :ref:`actions_insert_row` permission. +To return metadata describing the insert form for a table, make a ``GET`` request: + +:: + + GET ///-/insert + Authorization: Bearer dstok_ + +This returns a JSON object describing the table, the columns that can be inserted and any foreign key autocomplete URLs available to the actor: + +.. code-block:: json + + { + "ok": true, + "insert_row": { + "path": "/data/dogs/-/insert", + "table_name": "dogs", + "columns": [ + { + "name": "name", + "sqlite_type": "TEXT", + "notnull": 1, + "default": null, + "has_default": false, + "is_pk": false, + "value_kind": "string", + "column_type": null + } + ], + "primary_keys": ["id"] + }, + "table": { + "database": "data", + "name": "dogs", + "url": "/data/dogs" + }, + "foreign_keys": {} + } + +Integer primary key columns that SQLite can populate automatically are omitted from ``insert_row.columns``. If the actor does not have ``insert-row`` permission this endpoint returns a ``403`` response. + A single row can be inserted using the ``"row"`` key: :: diff --git a/tests/test_table_html.py b/tests/test_table_html.py index aa67bb3f..e90a6eb9 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -982,8 +982,8 @@ async def test_table_insert_action_button_and_data(): insert_data = table_data_from_soup(soup)["insertRow"] assert insert_data["path"] == "/data/items/-/insert" - assert insert_data["tableName"] == "items" - assert insert_data["primaryKeys"] == ["id"] + assert insert_data["table_name"] == "items" + assert insert_data["primary_keys"] == ["id"] assert [column["name"] for column in insert_data["columns"]] == [ "name", "score", @@ -1050,8 +1050,8 @@ async def test_table_insert_action_includes_compound_primary_keys(): insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[ "insertRow" ] - assert insert_data["tableName"] == "memberships" - assert insert_data["primaryKeys"] == ["account", "username"] + assert insert_data["table_name"] == "memberships" + assert insert_data["primary_keys"] == ["account", "username"] assert [column["name"] for column in insert_data["columns"]] == [ "account", "username", @@ -1066,6 +1066,127 @@ async def test_table_insert_action_includes_compound_primary_keys(): ds.close() +@pytest.mark.asyncio +async def test_table_insert_metadata_api(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "insert-row": {"id": "root"}, + }, + "column_types": {"body": "textarea"}, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_metadata_api"), name="data" + ) + await db.execute_write_script(""" + create table authors ( + id integer primary key, + name text + ); + create table items ( + id integer primary key, + author_id integer references authors(id), + name text not null, + score integer default 5, + body text + ); + """) + response = await ds.client.get("/data/items/-/insert", actor={"id": "root"}) + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "insert_row": { + "path": "/data/items/-/insert", + "table_name": "items", + "columns": [ + { + "name": "author_id", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": False, + "value_kind": "number", + "column_type": None, + }, + { + "name": "name", + "sqlite_type": "TEXT", + "notnull": 1, + "default": None, + "has_default": False, + "is_pk": False, + "value_kind": "string", + "column_type": None, + }, + { + "name": "score", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": "5", + "has_default": True, + "is_pk": False, + "value_kind": "number", + "column_type": None, + }, + { + "name": "body", + "sqlite_type": "TEXT", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": False, + "value_kind": "string", + "column_type": {"type": "textarea", "config": None}, + }, + ], + "primary_keys": ["id"], + }, + "table": { + "database": "data", + "name": "items", + "url": "/data/items", + }, + "foreign_keys": { + "author_id": "/data/authors/-/autocomplete", + }, + } + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_table_insert_metadata_api_requires_insert_permission(): + ds = Datasette([]) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_metadata_permission"), + name="data", + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + response = await ds.client.get("/data/items/-/insert") + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied"], + } + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_data_includes_foreign_key_autocomplete_urls(): ds = Datasette([]) From f3fa6ecf61861d142c8e0592b01143a2f9a3f1ff Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Jun 2026 23:38:50 -0700 Subject: [PATCH 2/4] Render error pages using datasette.render_template() Avoids risk of important variables not being present. --- datasette/handle_exception.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index 96398a4c..91031b3d 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -59,16 +59,12 @@ def handle_exception(datasette, request, exception): if request.path.split("?")[0].endswith(".json"): return Response.json(info, status=status, headers=headers) else: - environment = datasette.get_jinja_environment(request) - template = environment.select_template(templates) return Response.html( - await template.render_async( - dict( - info, - urls=datasette.urls, - app_css_hash=datasette.app_css_hash(), - menu_links=lambda: [], - ) + await datasette.render_template( + templates, + info, + request=request, + view_name="error", ), status=status, headers=headers, From 76e6a3bc38870e40b3b8aee4909c0e48d4ad3def Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Jun 2026 23:40:15 -0700 Subject: [PATCH 3/4] New .insertDialog() JavaScript method Anywhere in Datasette can now open an insert dialog pre-populated with suggested data. Using it in one place already, on the row page to provide an insert button for related rows. --- datasette/static/app.css | 19 ++ datasette/static/datasette-manager.js | 120 ++++++++- datasette/static/edit-tools.js | 352 +++++++++++++++++++++++--- datasette/templates/base.html | 7 +- datasette/templates/row.html | 14 +- datasette/views/row.py | 25 +- datasette/views/table.py | 39 +++ docs/javascript_plugins.rst | 70 +++++ docs/json_api.rst | 2 + tests/test_api.py | 2 +- tests/test_api_write.py | 1 - tests/test_playwright.py | 80 +++++- tests/test_table_html.py | 112 ++++++++ 13 files changed, 791 insertions(+), 52 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 5fe4502d..c9568780 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1224,6 +1224,25 @@ button.table-insert-row svg { flex-shrink: 0; } +ul.row-foreign-key-tables { + display: grid; + gap: 0.35rem; +} + +ul.row-foreign-key-tables li { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +button.core.row-foreign-key-insert { + margin-left: 0; + padding: 0.15rem 0.45rem; + font-size: 0.78rem; + line-height: 1.15; +} + dialog.row-delete-dialog { --ink: #0f0f0f; --paper: #eef6ff; diff --git a/datasette/static/datasette-manager.js b/datasette/static/datasette-manager.js index b049fc73..80a4e2d2 100644 --- a/datasette/static/datasette-manager.js +++ b/datasette/static/datasette-manager.js @@ -25,6 +25,29 @@ const DOM_SELECTORS = { facetResults: ".facet-results [data-column]", }; +let editToolsPromise = null; +let autocompletePromise = null; + +function datasettePath(path) { + const baseUrl = window.datasetteBaseUrl || "/"; + return baseUrl.replace(/\/?$/, "/") + String(path || "").replace(/^\/+/, ""); +} + +function parseInsertDialogRow(value) { + if (!value) { + return {}; + } + try { + const row = JSON.parse(value); + if (!row || typeof row !== "object" || Array.isArray(row)) { + throw new Error("row must be an object"); + } + return row; + } catch (error) { + throw new Error("Invalid insert dialog row: " + error.message); + } +} + /** * Monolith class for interacting with Datasette JS API * Imported with DEFER, runs after main document parsed @@ -39,6 +62,62 @@ const datasetteManager = { // Does pluginMetadata need to be serializable, or can we let it be stateful / have functions? plugins: new Map(), + path: datasettePath, + + loadAutocomplete: () => { + if (!window.customElements) { + return Promise.resolve(null); + } + if (customElements.get("datasette-autocomplete")) { + return Promise.resolve(customElements.get("datasette-autocomplete")); + } + if (!autocompletePromise) { + const url = + window.datasetteAutocompleteUrl || + datasettePath("/-/static/autocomplete.js"); + autocompletePromise = import(url).then(() => + customElements.get("datasette-autocomplete"), + ); + } + return autocompletePromise; + }, + + loadEditTools: () => { + if (window.__DATASETTE_EDIT_TOOLS__) { + if (window.__DATASETTE_EDIT_TOOLS__.install) { + window.__DATASETTE_EDIT_TOOLS__.install(datasetteManager); + } + return Promise.resolve(window.__DATASETTE_EDIT_TOOLS__); + } + if (!editToolsPromise) { + const url = + window.datasetteEditToolsUrl || + datasettePath("/-/static/edit-tools.js"); + editToolsPromise = import(url).then(() => { + if (!window.__DATASETTE_EDIT_TOOLS__) { + throw new Error("edit-tools.js did not register its API"); + } + if (window.__DATASETTE_EDIT_TOOLS__.install) { + window.__DATASETTE_EDIT_TOOLS__.install(datasetteManager); + } + return window.__DATASETTE_EDIT_TOOLS__; + }); + } + return editToolsPromise; + }, + + insertDialog: async (database, table, row, message, options) => { + const editTools = await datasetteManager.loadEditTools(); + return editTools.insertDialog( + datasetteManager, + database, + table, + row, + message, + options, + ); + }, + registerPlugin: (name, pluginMetadata) => { if (datasetteManager.plugins.has(name)) { console.warn(`Warning -> plugin ${name} was redefined`); @@ -230,9 +309,7 @@ const datasetteManager = { }; const initializeDatasette = () => { - // Hide the global behind __ prefix. Ideally they should be listening for the - // DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window. - + window.datasette = datasetteManager; window.__DATASETTE__ = datasetteManager; const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, { @@ -240,6 +317,43 @@ const initializeDatasette = () => { }); document.dispatchEvent(initDatasetteEvent); + + document.addEventListener("click", function (event) { + const button = event.target.closest("[data-insert-dialog]"); + if (!button) { + return; + } + event.preventDefault(); + let row; + try { + row = parseInsertDialogRow(button.dataset.row || "{}"); + } catch (error) { + console.error(error); + return; + } + const reloadOnInsert = button.hasAttribute("data-insert-dialog-reload"); + datasetteManager + .insertDialog( + button.dataset.database, + button.dataset.table, + row, + button.dataset.message || "", + { flashMessage: reloadOnInsert }, + ) + .then((result) => { + if ( + reloadOnInsert && + result && + result.ok && + result.status === "inserted" + ) { + window.location.reload(); + } + }) + .catch((error) => { + console.error(error); + }); + }); }; /** diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index bafd6394..04f06ad0 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -3,6 +3,14 @@ var rowDeleteDialogState = null; var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; +function datasetteManagerPath(manager, path) { + if (manager && manager.path) { + return manager.path(path); + } + var baseUrl = window.datasetteBaseUrl || "/"; + return baseUrl.replace(/\/?$/, "/") + String(path || "").replace(/^\/+/, ""); +} + function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); if (status) { @@ -124,9 +132,10 @@ function insertedRowStatusMessage(rowId, rowLabel) { return message + "."; } -function tableBaseUrl() { +function tableBaseUrl(state) { var tableUrl = - window._datasetteTableData && window._datasetteTableData.tableUrl; + (state && state.currentTableUrl) || + (window._datasetteTableData && window._datasetteTableData.tableUrl); var url = new URL(tableUrl || location.href, location.href); url.hash = ""; url.search = ""; @@ -156,7 +165,10 @@ function rowElementForActionButton(button) { ); } -function foreignKeyAutocompleteUrl(column) { +function foreignKeyAutocompleteUrl(column, state) { + if (state && state.currentForeignKeys && state.currentForeignKeys[column]) { + return state.currentForeignKeys[column]; + } return tableForeignKeys()[column] || null; } @@ -304,13 +316,19 @@ async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) { } } -function tableInsertUrl() { - var data = tableInsertData(); +function tableInsertUrl(data, options) { + data = data || tableInsertData(); + options = options || {}; + var url; if (data && data.path) { - return new URL(data.path, location.href).toString(); + url = new URL(data.path, location.href); + } else { + url = tableBaseUrl(); + url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert"; + } + if (options.flashMessage) { + url.searchParams.set("_message", "1"); } - var url = tableBaseUrl(); - url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert"; return url.toString(); } @@ -363,11 +381,11 @@ function rowFragmentUrl(row) { return rowFragmentUrlById(rowId); } -function rowFragmentUrlById(rowId) { +function rowFragmentUrlById(rowId, state) { if (!rowId) { return ""; } - var url = tableBaseUrl(); + var url = tableBaseUrl(state); url.search = new URL(location.href).search; url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment"; url.searchParams.delete("_next"); @@ -673,18 +691,20 @@ function defaultExpressionForContext(expression) { } function columnFormControlContext(column, isPk, columnType, options) { + options = options || {}; var pageData = tablePageData(); var defaultExpression = defaultExpressionForContext( options.defaultExpression, ); return { mode: options.mode || "edit", - database: pageData.database || null, + database: options.database || pageData.database || null, table: + options.table || pageData.table || (tableInsertData() && tableInsertData().table_name) || null, - tableUrl: pageData.tableUrl || null, + tableUrl: options.tableUrl || pageData.tableUrl || null, column: column, columnType: columnTypeForContext(columnType), sqliteType: options.sqliteType || null, @@ -1471,6 +1491,40 @@ function rowPathFromRowData(row, primaryKeys) { return bits.join(","); } +function rowUrlById(rowId, state) { + if (!rowId) { + return null; + } + var url = tableBaseUrl(state); + url.pathname = url.pathname.replace(/\/$/, "") + "/" + rowId; + return url.toString(); +} + +function insertDialogResult(state, row, rowId) { + var result = { + ok: true, + status: "inserted", + database: state.currentDatabase || null, + table: state.currentTable || null, + row: row || null, + }; + if (rowId) { + result.row_id = tildeDecode(rowId); + result.row_path = rowId; + result.row_url = rowUrlById(rowId, state); + } + return result; +} + +function resolveInsertDialog(state, result) { + if (!state.currentInsertDialogResolve) { + return; + } + var resolve = state.currentInsertDialogResolve; + state.currentInsertDialogResolve = null; + resolve(result); +} + function addInsertedRowToPage(rowElement) { var importedRow = document.importNode(rowElement, true); var firstRow = document.querySelector("[data-row]"); @@ -1537,15 +1591,30 @@ async function saveRowEditDialog(state) { } if (state.mode === "insert") { - var insertData = tableInsertData() || {}; + var insertData = state.currentInsertData || tableInsertData() || {}; var insertedRowData = data && data.rows && data.rows.length ? data.rows[0] : null; var insertedRowId = rowPathFromRowData( insertedRowData, insertData.primary_keys || [], ); + var result = insertDialogResult(state, insertedRowData, insertedRowId); state.shouldRestoreFocus = false; + if (!state.refreshAfterInsert) { + resolveInsertDialog(state, result); + state.dialog.close(); + var status = showRowMutationStatus( + state.manager, + insertedRowId + ? insertedRowStatusMessage(tildeDecode(insertedRowId), null) + : "Inserted row.", + false, + ); + status.focus(); + return; + } if (!insertedRowId) { + resolveInsertDialog(state, result); state.dialog.close(); var missingIdStatus = showRowMutationStatus( state.manager, @@ -1557,11 +1626,12 @@ async function saveRowEditDialog(state) { } state.currentRowId = insertedRowId; - state.currentFragmentUrl = rowFragmentUrlById(insertedRowId); + state.currentFragmentUrl = rowFragmentUrlById(insertedRowId, state); var insertedRow = null; try { insertedRow = await fetchUpdatedRowElement(state); } catch (_error) { + resolveInsertDialog(state, result); state.dialog.close(); var refreshFailedStatus = showRowMutationStatus( state.manager, @@ -1577,6 +1647,7 @@ async function saveRowEditDialog(state) { rowTitleLabel(insertedRow), ); var addedRow = addInsertedRowToPage(insertedRow); + resolveInsertDialog(state, result); state.dialog.close(); showRowMutationStatus(state.manager, insertedStatusMessage, false); if (addedRow) { @@ -1586,6 +1657,7 @@ async function saveRowEditDialog(state) { insertedFocusTarget.focus(); } } else { + resolveInsertDialog(state, result); state.dialog.close(); var filteredStatus = showRowMutationStatus( state.manager, @@ -1662,7 +1734,7 @@ function renderRowEditFields(state, data) { columnTypes[column], index, { - autocompleteUrl: foreignKeyAutocompleteUrl(column), + autocompleteUrl: foreignKeyAutocompleteUrl(column, state), dialog: state.dialog, form: state.form, manager: state.manager, @@ -1680,20 +1752,28 @@ function renderRowEditFields(state, data) { } } -function renderRowInsertFields(state, data) { +function renderRowInsertFields(state, data, suggestedRow) { var columns = data.columns || []; + suggestedRow = suggestedRow || {}; destroyRowEditFields(state); columns.forEach(function (column, index) { + var hasSuggestedValue = Object.prototype.hasOwnProperty.call( + suggestedRow, + column.name, + ); + var value = hasSuggestedValue ? suggestedRow[column.name] : ""; + var useSqliteDefault = column.default !== null && !hasSuggestedValue; state.fields.appendChild( createRowEditField( column.name, - "", + value, !!column.is_pk, column.column_type, index, { - autocompleteUrl: foreignKeyAutocompleteUrl(column.name), + autocompleteUrl: foreignKeyAutocompleteUrl(column.name, state), + database: state.currentDatabase, dialog: state.dialog, form: state.form, defaultExpression: column.default, @@ -1702,8 +1782,12 @@ function renderRowInsertFields(state, data) { notnull: column.notnull, primaryKeyReadonly: false, sqliteType: column.sqlite_type, - useSqliteDefault: column.default !== null, - valueKind: column.value_kind, + table: state.currentTable, + tableUrl: state.currentTableUrl, + useSqliteDefault: useSqliteDefault, + valueKind: hasSuggestedValue + ? rowEditValueKind(value) + : column.value_kind, }, ), ); @@ -1751,6 +1835,18 @@ function setRowDialogTitle(title, text, codeText, labelText) { } } +function setRowEditDialogSummary(state, message) { + if (message) { + state.summary.hidden = false; + state.summary.textContent = message; + state.dialog.setAttribute("aria-describedby", state.summary.id); + } else { + state.summary.hidden = true; + state.summary.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); + } +} + function ensureRowEditDialog(manager) { if (rowEditDialogState) { return rowEditDialogState; @@ -1794,10 +1890,17 @@ function ensureRowEditDialog(manager) { currentRow: null, currentRowId: null, currentPkPath: null, + currentDatabase: null, + currentTable: null, + currentTableUrl: null, + currentForeignKeys: null, + currentInsertData: null, + currentInsertDialogResolve: null, currentInsertUrl: null, currentUpdateUrl: null, currentFragmentUrl: null, mode: "edit", + refreshAfterInsert: false, loadId: 0, manager: manager, isLoading: false, @@ -1840,6 +1943,14 @@ function ensureRowEditDialog(manager) { dialog.addEventListener("close", function () { var state = rowEditDialogState; + if (state.currentInsertDialogResolve) { + resolveInsertDialog(state, { + ok: false, + status: "cancelled", + database: state.currentDatabase || null, + table: state.currentTable || null, + }); + } state.loadId += 1; state.isClosePending = false; clearRowEditDialogError(state); @@ -1847,6 +1958,18 @@ function ensureRowEditDialog(manager) { destroyRowEditFields(state); setRowEditDialogLoading(state, false); setRowEditDialogSaving(state, false); + state.currentRow = null; + state.currentRowId = null; + state.currentPkPath = null; + state.currentDatabase = null; + state.currentTable = null; + state.currentTableUrl = null; + state.currentForeignKeys = null; + state.currentInsertData = null; + state.currentInsertUrl = null; + state.currentUpdateUrl = null; + state.currentFragmentUrl = null; + state.refreshAfterInsert = false; if ( state.shouldRestoreFocus && state.currentButton && @@ -1875,9 +1998,16 @@ async function openRowEditDialog(button, manager) { state.currentRow = row; state.currentRowId = row.getAttribute("data-row") || ""; state.currentPkPath = rowDisplayLabel(row); + state.currentDatabase = tablePageData().database || null; + state.currentTable = tablePageData().table || null; + state.currentTableUrl = tablePageData().tableUrl || null; + state.currentForeignKeys = tableForeignKeys(); + state.currentInsertData = null; + state.currentInsertDialogResolve = null; state.currentInsertUrl = null; state.currentUpdateUrl = rowUpdateUrl(row); state.currentFragmentUrl = rowFragmentUrl(row); + state.refreshAfterInsert = false; if (state.currentUpdateUrl) { state.form.action = new URL( state.currentUpdateUrl, @@ -1894,15 +2024,13 @@ async function openRowEditDialog(button, manager) { clearRowEditDialogError(state); setRowEditDialogLoading(state, true); destroyRowEditFields(state); - state.dialog.removeAttribute("aria-describedby"); setRowDialogTitle( state.title, "Edit row", state.currentPkPath || "this row", rowTitleLabel(row), ); - state.summary.hidden = true; - state.summary.textContent = ""; + setRowEditDialogSummary(state, ""); if (!state.dialog.open) { state.dialog.showModal(); @@ -1934,26 +2062,102 @@ async function openRowEditDialog(button, manager) { } } -function openRowInsertDialog(button, manager) { - var insertData = tableInsertData(); - if (!insertData) { +function validateSuggestedInsertRow(insertData, row) { + row = row || {}; + var columns = (insertData && insertData.columns) || []; + var columnNames = {}; + columns.forEach(function (column) { + columnNames[column.name] = true; + }); + var unknownColumns = Object.keys(row).filter(function (column) { + return !columnNames[column]; + }); + if (unknownColumns.length) { + throw new Error("Unknown column: " + unknownColumns.join(", ")); + } +} + +function insertDialogMetadataUrl(manager, database, table) { + return datasetteManagerPath( + manager, + "/" + tildeEncode(database) + "/" + tildeEncode(table) + "/-/insert", + ); +} + +async function fetchInsertDialogMetadata(manager, database, table) { + var response = await fetch( + insertDialogMetadataUrl(manager, database, table), + { + headers: { + Accept: "application/json", + }, + }, + ); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + throw rowMutationRequestError(response, data); + } + if (!data || !data.insert_row) { + throw new Error("Insert dialog metadata was not returned"); + } + return data; +} + +function hasForeignKeyAutocomplete(foreignKeys) { + return foreignKeys && Object.keys(foreignKeys).length > 0; +} + +async function ensureAutocompleteLoaded(manager, foreignKeys) { + if (!hasForeignKeyAutocomplete(foreignKeys)) { return; } - var state = ensureRowEditDialog(manager); - if (!state) { + if (window.customElements && customElements.get("datasette-autocomplete")) { return; } + if (manager && manager.loadAutocomplete) { + await manager.loadAutocomplete(); + } +} + +function openRowInsertDialogWithData(options) { + var insertData = options.insertData; + if (!insertData) { + return false; + } + var state = ensureRowEditDialog(options.manager); + if (!state) { + return false; + } + if (state.dialog.open) { + return false; + } - state.manager = manager; + var pageData = tablePageData(); + state.manager = options.manager; state.mode = "insert"; - state.currentButton = button; + state.currentButton = options.button || null; state.currentRow = null; state.currentRowId = null; state.currentPkPath = null; - state.currentInsertUrl = tableInsertUrl(); + state.currentDatabase = options.database || pageData.database || null; + state.currentTable = + options.table || insertData.table_name || pageData.table || null; + state.currentTableUrl = options.tableUrl || pageData.tableUrl || null; + state.currentForeignKeys = options.foreignKeys || tableForeignKeys(); + state.currentInsertData = insertData; + state.currentInsertDialogResolve = options.resolve || null; + state.currentInsertUrl = tableInsertUrl(insertData, { + flashMessage: options.flashMessage, + }); state.currentUpdateUrl = null; state.currentFragmentUrl = null; - state.shouldRestoreFocus = true; + state.refreshAfterInsert = !!options.refreshAfterInsert; + state.shouldRestoreFocus = !!options.button; state.hasLoaded = false; state.loadId += 1; @@ -1969,20 +2173,70 @@ function openRowInsertDialog(button, manager) { clearRowEditDialogError(state); setRowEditDialogLoading(state, false); destroyRowEditFields(state); - state.dialog.removeAttribute("aria-describedby"); setRowDialogTitle( state.title, - insertData.table_name - ? "Insert row into " + insertData.table_name - : "Insert row", + insertData.table_name ? "Insert row into" : "Insert row", + insertData.table_name, ); - state.summary.hidden = true; - state.summary.textContent = ""; + setRowEditDialogSummary(state, options.message || ""); if (!state.dialog.open) { state.dialog.showModal(); } - renderRowInsertFields(state, insertData); + renderRowInsertFields(state, insertData, options.suggestedRow || {}); + return true; +} + +function openRowInsertDialog(button, manager) { + openRowInsertDialogWithData({ + button: button, + manager: manager, + insertData: tableInsertData(), + database: tablePageData().database, + table: tablePageData().table, + tableUrl: tablePageData().tableUrl, + foreignKeys: tableForeignKeys(), + refreshAfterInsert: true, + }); +} + +async function insertDialog(manager, database, table, row, message, options) { + options = options || {}; + if (typeof database !== "string" || !database) { + throw new Error("database must be a string"); + } + if (typeof table !== "string" || !table) { + throw new Error("table must be a string"); + } + if (!row) { + row = {}; + } + if (typeof row !== "object" || Array.isArray(row)) { + throw new Error("row must be an object"); + } + var metadata = await fetchInsertDialogMetadata(manager, database, table); + var insertData = metadata.insert_row; + var foreignKeys = metadata.foreign_keys || {}; + validateSuggestedInsertRow(insertData, row); + await ensureAutocompleteLoaded(manager, foreignKeys); + return new Promise(function (resolve, reject) { + var opened = openRowInsertDialogWithData({ + manager: manager, + insertData: insertData, + database: metadata.table && metadata.table.database, + table: metadata.table && metadata.table.name, + tableUrl: metadata.table && metadata.table.url, + foreignKeys: foreignKeys, + suggestedRow: row, + message: message || "", + resolve: resolve, + flashMessage: !!options.flashMessage, + refreshAfterInsert: false, + }); + if (!opened) { + reject(new Error("Could not open the insert dialog")); + } + }); } function initRowEditActions(manager) { @@ -2013,11 +2267,27 @@ function initRowInsertActions(manager) { }); } -document.addEventListener("datasette_init", function (evt) { - const { detail: manager } = evt; +function installEditTools(manager) { + if (!manager || manager.__editToolsInstalled) { + return; + } + manager.__editToolsInstalled = true; registerBuiltinColumnFieldPlugins(manager); initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); +} + +window.__DATASETTE_EDIT_TOOLS__ = { + install: installEditTools, + insertDialog: insertDialog, +}; + +if (window.__DATASETTE__) { + installEditTools(window.__DATASETTE__); +} + +document.addEventListener("datasette_init", function (evt) { + installEditTools(evt.detail); }); diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 82ab48dd..9e00580a 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -7,7 +7,12 @@ {% for url in extra_css_urls %} {% endfor %} - + {% for url in extra_js_urls %} diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 5e483d34..3c573684 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -42,12 +42,24 @@ {% if foreign_key_tables %}

Links from other tables

-
    + diff --git a/datasette/views/row.py b/datasette/views/row.py index 4d61eb91..de0b438e 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -193,7 +193,7 @@ class RowView(DataView): "private": private, "columns": reordered_columns, "foreign_key_tables": await self.foreign_key_tables( - database, table, pk_values + database, table, pk_values, actor=request.actor, include_insert=True ), "database_color": db.color, "display_columns": display_columns, @@ -266,7 +266,9 @@ class RowView(DataView): ), ) - async def foreign_key_tables(self, database, table, pk_values): + async def foreign_key_tables( + self, database, table, pk_values, actor=None, include_insert=False + ): if len(pk_values) != 1: return [] db = self.ds.databases[database] @@ -309,7 +311,24 @@ class RowView(DataView): key, ",".join(pk_values), ) - foreign_key_tables.append({**fk, **{"count": count, "link": link}}) + item = {**fk, **{"count": count, "link": link}} + if include_insert: + can_insert = db.is_mutable and await self.ds.allowed( + action="insert-row", + resource=TableResource(database=database, table=fk["other_table"]), + actor=actor, + ) + item["can_insert"] = can_insert + if can_insert: + item["insert_row"] = {fk["other_column"]: pk_values[0]} + item["insert_message"] = ( + "Insert a row in {} with {} set to {}.".format( + fk["other_table"], + fk["other_column"], + pk_values[0], + ) + ) + foreign_key_tables.append(item) return foreign_key_tables diff --git a/datasette/views/table.py b/datasette/views/table.py index 9a3fbbc0..e1e1918a 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -113,6 +113,35 @@ def row_label_from_label_column(row, label_column): return str(value) +ROW_FLASH_LABEL_MAX_LENGTH = 80 + + +def _truncated_row_flash_label(label): + label = " ".join(str(label).split()) + if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH: + return label + return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026" + + +async def _inserted_row_flash_message(db, table_name, pks, row): + pk_label = path_from_row_pks(row, pks, pks == ["rowid"], False) + label_column = await db.label_column_for_table(table_name) + label = row_label_from_label_column(row, label_column) + if label: + label = _truncated_row_flash_label(label) + if label and label != pk_label: + return "Inserted row {} ({})".format(pk_label, label) + return "Inserted row {}".format(pk_label) + + +def _inserted_rows_flash_message(table_name, num_rows): + return "Inserted {:,} row{} in {}".format( + num_rows, + "" if num_rows == 1 else "s", + table_name, + ) + + 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 @@ -978,6 +1007,16 @@ class TableInsertView(BaseView): ) ) + if request.args.get("_message") and not upsert: + returned_rows = result.get("rows") or [] + if len(returned_rows) == 1: + message = await _inserted_row_flash_message( + db, table_name, pks, returned_rows[0] + ) + else: + message = _inserted_rows_flash_message(table_name, num_rows) + self.ds.add_message(request, message, self.ds.INFO) + return Response.json(result, status=200 if upsert else 201) diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index c4283cac..a581929a 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -40,18 +40,88 @@ The ``datasetteManager`` object ``VERSION`` - string The version of Datasette +``path(path)`` - function + Returns a Datasette path, taking any configured :ref:`base URL prefix ` into account. + ``plugins`` - ``Map()`` A Map of currently loaded plugin names to plugin implementations ``registerPlugin(name, implementation)`` Call this to register a plugin, passing its name and implementation +``loadEditTools()`` - function + Loads Datasette's row insert/edit JavaScript on demand, returning a promise that resolves when those tools are ready. Calling this more than once is safe; subsequent calls reuse the already-loaded tools. + +``loadAutocomplete()`` - function + Loads Datasette's foreign-key autocomplete custom element on demand, returning a promise that resolves when it is ready. Calling this more than once is safe; subsequent calls reuse the already-loaded element. + +``insertDialog(database, table, row, message)`` - function + Opens Datasette's row insert dialog for ``database`` and ``table``, pre-filled with suggested values from ``row``. This returns a promise, described in :ref:`javascript_datasette_insert_dialog`. + ``makeColumnField(context)`` Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs. ``selectors`` - object An object providing named aliases to useful CSS selectors, :ref:`listed below ` +The same object is available as ``window.datasette`` for code that needs to call these APIs outside of the ``datasette_init`` event handler. + +.. _javascript_datasette_insert_dialog: + +insertDialog(database, table, row, message) +------------------------------------------- + +``await datasette.insertDialog(database, table, row, message)`` opens Datasette's row insert dialog for the specified table. + +The ``row`` argument must be an object. Its keys are column names and its values are the suggested values that should be pre-filled in the form. The row is not inserted immediately: the user can review and edit the values before clicking **Insert row**, or cancel the dialog. + +``message`` is optional. If provided, it is shown at the top of the insert dialog to explain why the row is being suggested. + +This example suggests a new ``repos`` row for a license: + +.. code-block:: javascript + + const result = await datasette.insertDialog( + "content", + "repos", + { license: "apache-2.0" }, + "Create a repo using the Apache 2.0 license." + ); + + if (result.ok) { + console.log("Inserted row:", result.row); + } else if (result.status === "cancelled") { + console.log("The user cancelled the insert."); + } + +The promise resolves to an object. If the user inserts the row it resolves to: + +.. code-block:: javascript + + { + ok: true, + status: "inserted", + database: "content", + table: "repos", + row: { id: 1, name: "datasette", license: "apache-2.0" }, + row_id: "1", + row_path: "1", + row_url: "/content/repos/1" + } + +If the user cancels the dialog it resolves to: + +.. code-block:: javascript + + { + ok: false, + status: "cancelled", + database: "content", + table: "repos" + } + +The method fetches the insert dialog metadata from ``GET /{database}/{table}/-/insert``. If the actor does not have ``insert-row`` permission for that table, or if ``row`` contains keys that are not available in the insert form, the promise rejects. + .. _javascript_plugin_objects: JavaScript plugin objects diff --git a/docs/json_api.rst b/docs/json_api.rst index 7a20c44b..1d8efa6c 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -1714,6 +1714,8 @@ If successful, this will return a ``201`` status code and the newly inserted row ] } +Add ``?_message=1`` to the URL to set a Datasette flash message describing the inserted row. The JSON response body is unchanged, but the response will include a ``ds_messages`` cookie which Datasette will display on the next page the user visits. + To insert multiple rows at a time, use the same API method but send a list of dictionaries as the ``"rows"`` key: :: diff --git a/tests/test_api.py b/tests/test_api.py index f57d0206..2e1b5da4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -700,7 +700,7 @@ def test_config_force_https_urls(): ("/fixtures/-/query.json?sql=select+blah", 400), # Write APIs ("/fixtures/-/create", 405), - ("/fixtures/facetable/-/insert", 405), + ("/fixtures/facetable/-/insert", 403), ("/fixtures/facetable/-/drop", 405), ], ) diff --git a/tests/test_api_write.py b/tests/test_api_write.py index b7ceb6b2..72d0e7ac 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1595,7 +1595,6 @@ async def test_create_table_error_rows_twice_with_duplicates(ds_write): ( "/data/-/create", "/data/docs/-/drop", - "/data/docs/-/insert", ), ) async def test_method_not_allowed(ds_write, path): diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a8c5aa4b..69eb1fed 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -90,20 +90,29 @@ def write_playwright_database(db_path): conn = sqlite3.connect(db_path) try: conn.executescript(""" + create table licenses ( + key text primary key, + name text + ); + insert into licenses (key, name) values + ('mit', 'MIT License'), + ('apache-2.0', 'Apache License 2.0'); create table projects ( id integer primary key, title text not null, metadata text, logo text, notes text, + license text references licenses(key), score integer default 5 ); - insert into projects (title, metadata, logo, notes, score) values + insert into projects (title, metadata, logo, notes, license, score) values ( 'Build Datasette', '{"ok": true}', 'asset-original', 'Initial notes', + 'mit', 5 ); """) @@ -347,6 +356,75 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server): assert data["score"] == 5 +@pytest.mark.playwright +def test_insert_dialog_javascript_api_returns_inserted_row(page, datasette_server): + page.goto(datasette_server) + page.wait_for_function("window.datasette && window.datasette.insertDialog") + assert not page.locator('script[src*="edit-tools.js"]').count() + + page.evaluate(""" + () => { + window.insertDialogResult = window.datasette.insertDialog( + "data", + "projects", + { + title: "Suggested from JavaScript", + metadata: '{"suggested": true}', + notes: "Created through insertDialog()", + license: "apache-2.0", + score: 7 + }, + "Review this suggested project before inserting it." + ); + } + """) + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + assert "Insert row into projects" in dialog.locator("#row-edit-title").inner_text() + assert ( + dialog.locator("#row-edit-summary").inner_text() + == "Review this suggested project before inserting it." + ) + assert dialog.locator('input[name="title"]').input_value() == ( + "Suggested from JavaScript" + ) + assert dialog.locator('textarea[name="metadata"]').input_value() == ( + '{"suggested": true}' + ) + assert dialog.locator('textarea[name="notes"]').input_value() == ( + "Created through insertDialog()" + ) + assert page.evaluate("() => !!customElements.get('datasette-autocomplete')") + assert ( + dialog.locator('datasette-autocomplete input[name="license"]').input_value() + == "apache-2.0" + ) + assert dialog.locator('input[name="score"]').input_value() == "7" + + dialog.locator(".row-edit-save").click() + result = page.evaluate("() => window.insertDialogResult") + assert result["ok"] is True + assert result["status"] == "inserted" + assert result["database"] == "data" + assert result["table"] == "projects" + assert result["row_id"] == "2" + assert result["row_path"] == "2" + assert result["row_url"].endswith("/data/projects/2") + assert result["row"]["title"] == "Suggested from JavaScript" + assert result["row"]["metadata"] == '{"suggested": true}' + assert result["row"]["notes"] == "Created through insertDialog()" + assert result["row"]["license"] == "apache-2.0" + assert result["row"]["score"] == 7 + + data = project_row(datasette_server, 2) + assert data["title"] == "Suggested from JavaScript" + assert data["metadata"] == '{"suggested": true}' + assert data["notes"] == "Created through insertDialog()" + assert data["license"] == "apache-2.0" + assert data["score"] == 7 + + @pytest.mark.playwright def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): page.goto(f"{datasette_server}data/projects") diff --git a/tests/test_table_html.py b/tests/test_table_html.py index e90a6eb9..ee5afb84 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1242,6 +1242,76 @@ async def test_table_data_includes_foreign_key_autocomplete_urls(): ds.close() +@pytest.mark.asyncio +async def test_row_foreign_key_tables_include_insert_dialog_button(): + import json + + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "repos": { + "permissions": { + "insert-row": {"id": "root"}, + }, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_row_fk_insert_dialog_button"), + name="data", + ) + await db.execute_write_script(""" + create table licenses ( + key text primary key, + name text + ); + create table repos ( + id integer primary key, + name text, + license text references licenses(key) + ); + insert into licenses (key, name) + values ('apache-2.0', 'Apache License 2.0'); + insert into repos (name, license) + values ('datasette', 'apache-2.0'); + """) + + response = await ds.client.get( + "/data/licenses/apache-2~2E0", actor={"id": "root"} + ) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + button = soup.select_one('button[data-insert-dialog][data-table="repos"]') + assert button is not None + assert button.text.strip() == "Insert" + assert button["class"] == ["core", "row-foreign-key-insert"] + assert button.has_attr("data-insert-dialog-reload") + assert button["data-database"] == "data" + assert json.loads(button["data-row"]) == {"license": "apache-2.0"} + assert ( + button["data-message"] + == "Insert a row in repos with license set to apache-2.0." + ) + assert not any( + "edit-tools.js" in (script.get("src") or "") + for script in soup.find_all("script") + ) + + response = await ds.client.get("/data/licenses/apache-2~2E0") + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + assert soup.select_one("button[data-insert-dialog]") is None + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_fragment_endpoint(ds_client): response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1") @@ -1384,6 +1454,48 @@ async def test_row_delete_redirect_to_table_sets_message(): ds.close() +@pytest.mark.asyncio +async def test_table_insert_sets_message(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "insert-row": {"id": "root"}, + }, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_message"), name="data" + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + response = await ds.client.post( + "/data/items/-/insert?_message=1", + actor={"id": "root"}, + json={"row": {"name": "One"}}, + ) + assert response.status_code == 201 + assert response.json() == { + "ok": True, + "rows": [{"id": 1, "name": "One"}], + } + assert ds.unsign(response.cookies["ds_messages"], "messages") == [ + ["Inserted row 1 (One)", ds.INFO] + ] + finally: + ds.close() + + @pytest.mark.asyncio async def test_row_update_sets_message(): ds = Datasette( From 693f4ed601569671c890b4faa1820445a814d37b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 20 Jun 2026 12:23:52 -0700 Subject: [PATCH 4/4] 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")