From 5bf4cf8860bd44a8e2729a7f73705d7120ae0cc6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 13 Jun 2026 21:34:45 -0700 Subject: [PATCH] Add insert row UI to table pages Add a permission-gated Insert row button to mutable table pages and expose the metadata needed by the client-side UI, including the insert API path, table name, primary keys, editable columns, defaults, nullability, and column type information. Reuse the existing row edit modal for inserts. Insert submissions now use the JSON API with return=true, derive the new row's tilde-encoded row path from the returned primary key values, fetch the matching table fragment, and insert the rendered row into the current table. Successful inserts and updates now show mutation status messages above the table. Support SQLite defaults in insert forms by showing default expressions as non-editable values with Set value / Use default controls. Keep those controls aligned and stable so toggling between default and custom values does not shift the modal layout. Refine the edit modal at the same time: send only changed fields on update, skip the update API entirely when nothing changed, clear stale mutation status for no-op saves, and simplify modal headings so insert/edit context is shown in the bold title instead of duplicated summary text. Add tests for the insert button and metadata, including omitted integer primary keys, default values, table names, and compound primary keys. --- datasette/static/app.css | 116 ++++++++- datasette/static/table.js | 460 +++++++++++++++++++++++++++++++-- datasette/templates/table.html | 17 ++ datasette/views/table.py | 66 +++++ tests/test_table_html.py | 159 ++++++++++-- 5 files changed, 760 insertions(+), 58 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index aca39e58..9226580b 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1209,6 +1209,21 @@ dialog.set-column-type-dialog::backdrop { background: rgba(208,2,27,0.12); } +.table-row-toolbar { + margin: 0 0 0.75rem; +} + +button.table-insert-row { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +button.table-insert-row svg { + display: block; + flex-shrink: 0; +} + dialog.row-delete-dialog { --ink: #0f0f0f; --paper: #f5f3ef; @@ -1381,6 +1396,17 @@ dialog.row-edit-dialog::backdrop { color: var(--ink); } +.row-edit-dialog .modal-title code { + 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-form { display: flex; flex: 1 1 auto; @@ -1401,17 +1427,6 @@ dialog.row-edit-dialog::backdrop { 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 { border-left: 4px solid #b91c1c; border-radius: 4px; @@ -1483,11 +1498,80 @@ textarea.row-edit-input { background: var(--paper); } +.row-edit-default { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 7px 8px 7px 10px; + background: var(--paper); + color: var(--ink); +} + +.row-edit-default[hidden], +.row-edit-custom-value[hidden] { + display: none; +} + +.row-edit-default-text { + min-width: 0; + overflow-wrap: anywhere; +} + +.row-edit-default-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.row-edit-custom-value { + display: grid; + grid-template-columns: minmax(0, 1fr) 7.25rem; + gap: 8px; + align-items: center; + min-height: 45px; + padding-right: 8px; +} + +.row-edit-default-button { + appearance: none; + border: 1px solid var(--rule); + border-radius: 4px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + line-height: 1.2; + padding: 6px 8px; + white-space: nowrap; + width: 100%; + align-self: center; +} + +.row-edit-default-button:hover, +.row-edit-default-button:focus { + background: #f8fafc; +} + +.row-edit-default-button:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + .row-edit-field-meta { color: var(--muted); font-size: 0.78rem; } +.row-edit-empty { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + .row-edit-dialog .modal-footer { padding: 14px 20px; border-top: 1px solid var(--rule); @@ -1757,6 +1841,7 @@ textarea.row-edit-input { width: 140px; } button.choose-columns-mobile, + button.table-insert-row, button.column-actions-mobile { display: inline-flex; align-items: center; @@ -1793,6 +1878,15 @@ textarea.row-edit-input { button.choose-columns-mobile { margin-right: 0.5rem; } + + .table-row-toolbar { + margin-bottom: 0.75rem; + } + + button.table-insert-row { + width: 100%; + margin-bottom: 0; + } } svg.dropdown-menu-icon { diff --git a/datasette/static/table.js b/datasette/static/table.js index 1ae2f30d..c8109c62 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -389,6 +389,16 @@ function showRowMutationStatus(manager, message, isError) { return status; } +function hideRowMutationStatus() { + var status = document.querySelector(".row-mutation-status"); + if (!status) { + return; + } + status.hidden = true; + status.classList.remove("row-mutation-status-error"); + status.textContent = ""; +} + function setRowDeleteDialogBusy(state, isBusy) { state.isBusy = isBusy; state.confirmButton.disabled = isBusy; @@ -436,6 +446,27 @@ function tildeDecode(value) { } } +function tildeEncode(value) { + var bytes = new TextEncoder().encode(String(value)); + var encoded = ""; + bytes.forEach(function (byte) { + var isSafe = + (byte >= 65 && byte <= 90) || + (byte >= 97 && byte <= 122) || + (byte >= 48 && byte <= 57) || + byte === 95 || + byte === 45; + if (isSafe) { + encoded += String.fromCharCode(byte); + } else if (byte === 32) { + encoded += "+"; + } else { + encoded += "~" + byte.toString(16).toUpperCase().padStart(2, "0"); + } + }); + return encoded; +} + function rowDisplayLabel(row) { return tildeDecode(row.getAttribute("data-row") || ""); } @@ -449,6 +480,20 @@ function tableBaseUrl() { return url; } +function tableInsertData() { + return window._datasetteTableData && window._datasetteTableData.insertRow; +} + +function tableInsertUrl() { + var data = tableInsertData(); + if (data && data.path) { + return new URL(data.path, location.href).toString(); + } + var url = tableBaseUrl(); + url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert"; + return url.toString(); +} + function rowResourceUrl(row) { var rowId = row.getAttribute("data-row"); if (!rowId) { @@ -489,6 +534,10 @@ function rowUpdateUrl(row) { function rowFragmentUrl(row) { var rowId = row.getAttribute("data-row"); + return rowFragmentUrlById(rowId); +} + +function rowFragmentUrlById(rowId) { if (!rowId) { return ""; } @@ -748,9 +797,14 @@ function rowEditValueType(value) { return "string"; } -function createRowEditField(column, value, isPk, columnType, index) { +function createRowEditField(column, value, isPk, columnType, index, options) { + options = options || {}; var field = document.createElement("div"); field.className = "row-edit-field"; + var hasDefault = + options.hasDefault || + (options.defaultValue !== null && typeof options.defaultValue !== "undefined"); + var useDefaultInitially = hasDefault && options.useDefaultInitially; var fieldId = "row-edit-field-" + index; var metaId = "row-edit-field-meta-" + index; @@ -771,8 +825,16 @@ function createRowEditField(column, value, isPk, columnType, index) { control.value = valueToEditText(value); control.setAttribute("aria-describedby", metaId); control.dataset.originalValue = valueToEditText(value); - control.dataset.originalValueType = rowEditValueType(value); + control.dataset.originalValueType = + options.valueType || rowEditValueType(value); control.dataset.primaryKey = isPk ? "1" : "0"; + if (useDefaultInitially) { + control.dataset.useDefault = "1"; + control.disabled = true; + } + if (options.omitIfBlank) { + control.dataset.omitIfBlank = "1"; + } if (control.nodeName === "TEXTAREA") { control.rows = Math.min(8, Math.max(3, control.value.split("\n").length)); @@ -780,7 +842,7 @@ function createRowEditField(column, value, isPk, columnType, index) { control.type = "text"; } - if (isPk) { + if (isPk && options.primaryKeyReadonly !== false) { control.readOnly = true; } @@ -791,6 +853,12 @@ function createRowEditField(column, value, isPk, columnType, index) { if (isPk) { metaParts.push("Primary key"); } + if (options.notnull) { + metaParts.push("Required"); + } + if (hasDefault && !useDefaultInitially) { + metaParts.push("Default: " + options.defaultValue); + } if (value === null) { metaParts.push("Current value: NULL"); control.placeholder = "NULL"; @@ -800,7 +868,62 @@ function createRowEditField(column, value, isPk, columnType, index) { } meta.textContent = metaParts.join(" ยท "); - controlWrap.appendChild(control); + if (useDefaultInitially) { + var defaultBlock = document.createElement("div"); + defaultBlock.className = "row-edit-default"; + defaultBlock.setAttribute("aria-describedby", metaId); + + var defaultText = document.createElement("span"); + defaultText.className = "row-edit-default-text"; + defaultText.appendChild(document.createTextNode("default ")); + var defaultCode = document.createElement("code"); + defaultCode.className = "row-edit-default-code"; + defaultCode.textContent = options.defaultValue; + defaultText.appendChild(defaultCode); + + var setValueButton = document.createElement("button"); + setValueButton.type = "button"; + setValueButton.className = + "row-edit-default-button row-edit-default-set-value"; + setValueButton.textContent = "Set value"; + setValueButton.setAttribute("aria-label", "Set value for " + column); + + var customWrap = document.createElement("div"); + customWrap.className = "row-edit-custom-value"; + customWrap.hidden = true; + + var useDefaultButton = document.createElement("button"); + useDefaultButton.type = "button"; + useDefaultButton.className = "row-edit-default-button"; + useDefaultButton.textContent = "Use default"; + useDefaultButton.setAttribute("aria-label", "Use default for " + column); + + setValueButton.addEventListener("click", function () { + control.dataset.useDefault = "0"; + control.disabled = false; + defaultBlock.hidden = true; + customWrap.hidden = false; + control.focus(); + }); + + useDefaultButton.addEventListener("click", function () { + control.dataset.useDefault = "1"; + control.disabled = true; + control.value = ""; + customWrap.hidden = true; + defaultBlock.hidden = false; + setValueButton.focus(); + }); + + defaultBlock.appendChild(defaultText); + defaultBlock.appendChild(setValueButton); + customWrap.appendChild(control); + customWrap.appendChild(useDefaultButton); + controlWrap.appendChild(defaultBlock); + controlWrap.appendChild(customWrap); + } else { + controlWrap.appendChild(control); + } if (meta.textContent) { controlWrap.appendChild(meta); } @@ -823,7 +946,8 @@ function showRowEditDialogError(state, message) { function updateRowEditDialogButtons(state) { state.saveButton.disabled = state.isLoading || state.isSaving || !state.hasLoaded; state.cancelButton.disabled = state.isSaving; - state.saveButton.textContent = state.isSaving ? "Saving..." : "Save"; + var saveLabel = state.mode === "insert" ? "Insert row" : "Save"; + state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel; state.form.setAttribute( "aria-busy", state.isLoading || state.isSaving ? "true" : "false", @@ -843,8 +967,15 @@ function setRowEditDialogSaving(state, isSaving) { function valueFromRowEditControl(control) { var value = control.value; + return valueFromRowEditText( + control.name, + value, + control.dataset.originalValueType || "string", + ); +} + +function valueFromRowEditText(name, value, originalValueType) { var trimmed = value.trim(); - var originalValueType = control.dataset.originalValueType || "string"; if (originalValueType === "null" && value === "") { return null; @@ -855,7 +986,7 @@ function valueFromRowEditControl(control) { } var numberValue = Number(trimmed); if (Number.isNaN(numberValue)) { - throw new Error(control.name + " must be a number"); + throw new Error(name + " must be a number"); } return numberValue; } @@ -866,7 +997,7 @@ function valueFromRowEditControl(control) { if (/^(false|0|no)$/i.test(trimmed)) { return false; } - throw new Error(control.name + " must be true or false"); + throw new Error(name + " must be true or false"); } if (originalValueType === "json") { if (trimmed === "") { @@ -875,21 +1006,60 @@ function valueFromRowEditControl(control) { try { return JSON.parse(value); } catch (_error) { - throw new Error(control.name + " must be valid JSON"); + throw new Error(name + " must be valid JSON"); } } return value; } -function collectRowEditUpdate(state) { - var update = {}; +function originalValueFromRowEditControl(control) { + return valueFromRowEditText( + control.name, + control.dataset.originalValue || "", + control.dataset.originalValueType || "string", + ); +} + +function rowEditValuesMatch(left, right) { + if (left === right) { + return true; + } + if ( + left && + right && + typeof left === "object" && + typeof right === "object" + ) { + return JSON.stringify(left) === JSON.stringify(right); + } + return false; +} + +function collectRowFormValues(state) { + var values = {}; state.fields.querySelectorAll(".row-edit-input").forEach(function (control) { - if (control.readOnly || control.dataset.primaryKey === "1") { + if ( + state.mode === "edit" && + (control.readOnly || control.dataset.primaryKey === "1") + ) { return; } - update[control.name] = valueFromRowEditControl(control); + if (control.dataset.useDefault === "1") { + return; + } + if (control.dataset.omitIfBlank === "1" && control.value === "") { + return; + } + var value = valueFromRowEditControl(control); + if ( + state.mode === "edit" && + rowEditValuesMatch(value, originalValueFromRowEditControl(control)) + ) { + return; + } + values[control.name] = value; }); - return update; + return values; } function findDataRowElement(root, rowId) { @@ -919,6 +1089,41 @@ async function fetchUpdatedRowElement(state) { return findDataRowElement(doc, state.currentRowId); } +function rowPathFromRowData(row, primaryKeys) { + if (!row) { + return null; + } + var keys = primaryKeys && primaryKeys.length ? primaryKeys : ["rowid"]; + var bits = []; + for (var i = 0; i < keys.length; i += 1) { + var key = keys[i]; + if (typeof row[key] === "undefined") { + return null; + } + bits.push(tildeEncode(row[key])); + } + return bits.join(","); +} + +function addInsertedRowToPage(rowElement) { + var importedRow = document.importNode(rowElement, true); + var firstRow = document.querySelector("[data-row]"); + if (firstRow && firstRow.parentNode) { + firstRow.parentNode.insertBefore(importedRow, firstRow); + } else { + var tbody = document.querySelector("table.rows-and-columns tbody"); + if (!tbody) { + return null; + } + tbody.appendChild(importedRow); + } + var zeroResults = document.querySelector(".zero-results"); + if (zeroResults) { + zeroResults.remove(); + } + return importedRow; +} + async function saveRowEditDialog(state) { if (state.isLoading || state.isSaving || !state.hasLoaded) { return; @@ -927,19 +1132,32 @@ async function saveRowEditDialog(state) { setRowEditDialogSaving(state, true); try { - if (!state.currentUpdateUrl) { - throw new Error("Could not find the row update URL"); + var url = state.mode === "insert" ? state.currentInsertUrl : state.currentUpdateUrl; + if (!url) { + throw new Error( + state.mode === "insert" + ? "Could not find the row insert URL" + : "Could not find the row update URL", + ); } - var response = await fetch(state.currentUpdateUrl, { + var formValues = collectRowFormValues(state); + if (state.mode === "edit" && !Object.keys(formValues).length) { + state.shouldRestoreFocus = true; + hideRowMutationStatus(); + state.dialog.close(); + return; + } + var payload = + state.mode === "insert" + ? { row: formValues, return: true } + : { update: formValues, return: true }; + var response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify({ - update: collectRowEditUpdate(state), - return: true, - }), + body: JSON.stringify(payload), }); var data = null; try { @@ -951,11 +1169,75 @@ async function saveRowEditDialog(state) { throw rowMutationRequestError(response, data); } + if (state.mode === "insert") { + var insertData = tableInsertData() || {}; + var insertedRowData = data && data.rows && data.rows.length ? data.rows[0] : null; + var insertedRowId = rowPathFromRowData( + insertedRowData, + insertData.primaryKeys || [], + ); + state.shouldRestoreFocus = false; + if (!insertedRowId) { + state.dialog.close(); + var missingIdStatus = showRowMutationStatus( + state.manager, + "Inserted row. Refresh the page to see it.", + false, + ); + missingIdStatus.focus(); + return; + } + + state.currentRowId = insertedRowId; + state.currentFragmentUrl = rowFragmentUrlById(insertedRowId); + var insertedStatusMessage = + "Inserted row " + tildeDecode(insertedRowId) + "."; + var insertedRow = null; + try { + insertedRow = await fetchUpdatedRowElement(state); + } catch (_error) { + state.dialog.close(); + var refreshFailedStatus = showRowMutationStatus( + state.manager, + "Inserted row, but could not refresh the table row. Refresh the page to see it.", + true, + ); + refreshFailedStatus.focus(); + return; + } + if (insertedRow) { + var addedRow = addInsertedRowToPage(insertedRow); + state.dialog.close(); + showRowMutationStatus(state.manager, insertedStatusMessage, false); + if (addedRow) { + var insertedFocusTarget = + addedRow.querySelector('button[data-row-action="edit"]') || addedRow; + insertedFocusTarget.focus(); + } + } else { + state.dialog.close(); + var filteredStatus = showRowMutationStatus( + state.manager, + "Inserted row. It does not match the current filters.", + false, + ); + filteredStatus.focus(); + } + return; + } + var updatedRow = await fetchUpdatedRowElement(state); var focusTarget = null; if (updatedRow && state.currentRow && document.contains(state.currentRow)) { var importedRow = document.importNode(updatedRow, true); state.currentRow.replaceWith(importedRow); + showRowMutationStatus( + state.manager, + state.currentPkPath + ? "Updated row " + state.currentPkPath + "." + : "Updated row.", + false, + ); focusTarget = importedRow.querySelector('button[data-row-action="edit"]') || importedRow; } else if (state.currentRow && document.contains(state.currentRow)) { @@ -965,7 +1247,11 @@ async function saveRowEditDialog(state) { state.currentRow.remove(); showRowMutationStatus( state.manager, - "Saved row. It no longer matches the current filters.", + state.currentPkPath + ? "Updated row " + + state.currentPkPath + + ". It no longer matches the current filters." + : "Updated row. It no longer matches the current filters.", false, ); } @@ -996,6 +1282,9 @@ function renderRowEditFields(state, data) { primaryKeys.indexOf(column) !== -1, columnTypes[column], index, + { + primaryKeyReadonly: true, + }, ), ); }); @@ -1007,6 +1296,57 @@ function renderRowEditFields(state, data) { (firstEditable || firstField || state.cancelButton).focus(); } +function renderRowInsertFields(state, data) { + var columns = data.columns || []; + + state.fields.innerHTML = ""; + columns.forEach(function (column, index) { + state.fields.appendChild( + createRowEditField( + column.name, + "", + !!column.is_pk, + column.column_type, + index, + { + defaultValue: column.default, + hasDefault: column.has_default, + notnull: column.notnull, + primaryKeyReadonly: false, + useDefaultInitially: column.has_default, + valueType: column.value_type, + }, + ), + ); + }); + + if (!columns.length) { + var emptyMessage = document.createElement("p"); + emptyMessage.className = "row-edit-empty"; + emptyMessage.textContent = "This row will use the table defaults."; + state.fields.appendChild(emptyMessage); + } + + state.hasLoaded = true; + updateRowEditDialogButtons(state); + var firstControl = state.fields.querySelector( + ".row-edit-default-set-value, .row-edit-input:not(:disabled)", + ); + (firstControl || state.saveButton).focus(); +} + +function setRowEditDialogTitle(state, text, codeText) { + state.title.textContent = ""; + state.title.appendChild(document.createTextNode(text)); + if (!codeText) { + return; + } + state.title.appendChild(document.createTextNode(" ")); + var code = document.createElement("code"); + code.textContent = codeText; + state.title.appendChild(code); +} + function ensureRowEditDialog(manager) { if (rowEditDialogState) { return rowEditDialogState; @@ -1019,13 +1359,12 @@ function ensureRowEditDialog(manager) { 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...

@@ -1040,7 +1379,8 @@ function ensureRowEditDialog(manager) { rowEditDialogState = { dialog: dialog, form: dialog.querySelector(".row-edit-form"), - rowId: dialog.querySelector(".row-edit-id"), + title: dialog.querySelector(".modal-title"), + summary: dialog.querySelector(".row-edit-summary"), loading: dialog.querySelector(".row-edit-loading"), error: dialog.querySelector(".row-edit-error"), fields: dialog.querySelector(".row-edit-fields"), @@ -1050,8 +1390,10 @@ function ensureRowEditDialog(manager) { currentRow: null, currentRowId: null, currentPkPath: null, + currentInsertUrl: null, currentUpdateUrl: null, currentFragmentUrl: null, + mode: "edit", loadId: 0, manager: manager, isLoading: false, @@ -1130,10 +1472,12 @@ async function openRowEditDialog(button, manager) { } state.manager = manager; + state.mode = "edit"; state.currentButton = button; state.currentRow = row; state.currentRowId = row.getAttribute("data-row") || ""; state.currentPkPath = rowDisplayLabel(row); + state.currentInsertUrl = null; state.currentUpdateUrl = rowUpdateUrl(row); state.currentFragmentUrl = rowFragmentUrl(row); if (state.currentUpdateUrl) { @@ -1149,7 +1493,10 @@ async function openRowEditDialog(button, manager) { clearRowEditDialogError(state); setRowEditDialogLoading(state, true); state.fields.innerHTML = ""; - state.rowId.textContent = state.currentPkPath || "this row"; + state.dialog.removeAttribute("aria-describedby"); + setRowEditDialogTitle(state, "Edit row", state.currentPkPath || "this row"); + state.summary.hidden = true; + state.summary.textContent = ""; if (!state.dialog.open) { state.dialog.showModal(); @@ -1181,6 +1528,52 @@ async function openRowEditDialog(button, manager) { } } +function openRowInsertDialog(button, manager) { + var insertData = tableInsertData(); + if (!insertData) { + return; + } + var state = ensureRowEditDialog(manager); + if (!state) { + return; + } + + state.manager = manager; + state.mode = "insert"; + state.currentButton = button; + state.currentRow = null; + state.currentRowId = null; + state.currentPkPath = null; + state.currentInsertUrl = tableInsertUrl(); + state.currentUpdateUrl = null; + state.currentFragmentUrl = null; + state.shouldRestoreFocus = true; + state.hasLoaded = false; + state.loadId += 1; + + if (state.currentInsertUrl) { + state.form.action = new URL(state.currentInsertUrl, location.href).toString(); + } else { + state.form.removeAttribute("action"); + } + + clearRowEditDialogError(state); + setRowEditDialogLoading(state, false); + state.fields.innerHTML = ""; + state.dialog.removeAttribute("aria-describedby"); + setRowEditDialogTitle( + state, + insertData.tableName ? "Insert row into " + insertData.tableName : "Insert row", + ); + state.summary.hidden = true; + state.summary.textContent = ""; + + if (!state.dialog.open) { + state.dialog.showModal(); + } + renderRowInsertFields(state, insertData); +} + function initRowEditActions(manager) { if (!window.fetch || !window.HTMLDialogElement) { return; @@ -1195,6 +1588,20 @@ function initRowEditActions(manager) { }); } +function initRowInsertActions(manager) { + if (!window.fetch || !window.HTMLDialogElement || !tableInsertData()) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-table-action="insert-row"]'); + if (!button) { + return; + } + ev.preventDefault(); + openRowInsertDialog(button, manager); + }); +} + function canChooseColumns() { return !!( document.querySelector("column-chooser") && window._columnChooserData @@ -1590,6 +1997,7 @@ document.addEventListener("datasette_init", function (evt) { // Main table initDatasetteTable(manager); + initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 026599cc..5a4e7a68 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -4,7 +4,11 @@ {% block extra_head %} {{- super() -}} +{% if table_insert_ui %} + +{% else %} +{% endif %} @@ -159,6 +163,19 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }}; {% endif %} +{% if table_insert_ui %} +
+ +
+{% endif %} + {% include custom_table_templates %} {% if next_url %} diff --git a/datasette/views/table.py b/datasette/views/table.py index 33357165..c58e39f3 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -6,6 +6,7 @@ import urllib.parse import markupsafe +from datasette.column_types import SQLiteType from datasette.extras import extra_names_from_request from datasette.plugins import pm from datasette.events import ( @@ -223,6 +224,68 @@ async def _validate_column_types(datasette, database_name, table_name, rows): return errors +def _column_value_type_for_insert_form(column_detail, column_type): + if column_type is not None and column_type.name == "json": + return "json" + sqlite_type = SQLiteType.from_declared_type(column_detail.type) + if sqlite_type in (SQLiteType.INTEGER, SQLiteType.REAL): + return "number" + return "string" + + +async def _table_insert_ui( + datasette, request, db, database_name, table_name, is_view, pks +): + if is_view or not db.is_mutable: + return None + + if not await datasette.allowed( + action="insert-row", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return None + + column_types_map = await datasette.get_column_types(database_name, table_name) + columns = [] + column_details = await db.table_column_details(table_name) + for column in column_details: + if column.hidden: + continue + is_pk = column.name in pks + is_auto_pk = ( + is_pk + and len(pks) == 1 + and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER + ) + if is_auto_pk: + continue + column_type = column_types_map.get(column.name) + columns.append( + { + "name": column.name, + "type": column.type, + "notnull": column.notnull, + "default": column.default_value, + "has_default": column.default_value is not None, + "is_pk": is_pk, + "value_type": _column_value_type_for_insert_form(column, column_type), + "column_type": ( + {"type": column_type.name, "config": column_type.config} + if column_type is not None + else None + ), + } + ) + + return { + "path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)), + "tableName": table_name, + "columns": columns, + "primaryKeys": pks, + } + + async def display_columns_and_rows( datasette, database_name, @@ -1753,6 +1816,9 @@ async def table_view_data( sort = "rowid" data["sort"] = sort data["sort_desc"] = sort_desc + data["table_insert_ui"] = await _table_insert_ui( + datasette, request, db, database_name, table_name, is_view, pks + ) return data, rows[:page_size], columns, expanded_columns, sql, next_url diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 4e24f129..71d4b852 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -8,6 +8,21 @@ import urllib.parse from .utils import inner_html +def table_data_from_soup(soup): + import json + import re + + table_script = [ + s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "") + ][0] + match = re.search( + r"window\._datasetteTableData\s*=\s*({.*?});", + table_script.string, + re.DOTALL, + ) + return json.loads(match.group(1)) + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected_definition_sql", @@ -864,24 +879,12 @@ async def test_row_delete_action_data_attributes(): response = await ds.client.get("/data/items", actor={"id": "root"}) assert response.status_code == 200 soup = Soup(response.text, "html.parser") - import json - import re - - table_script = [ - s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "") - ][0] - match = re.search( - r"window\._datasetteTableData\s*=\s*({.*?});", - table_script.string, - re.DOTALL, - ) - assert json.loads(match.group(1)) == {"tableUrl": "/data/items"} + assert table_data_from_soup(soup) == {"tableUrl": "/data/items"} + assert soup.select_one('button[data-table-action="insert-row"]') is None row = soup.select_one("table.rows-and-columns tbody tr") assert row["data-row"] == "1" - assert { - key for key in row.attrs if key.startswith("data-row") - } == {"data-row"} + assert {key for key in row.attrs if key.startswith("data-row")} == {"data-row"} edit_button = row.select_one( 'button.row-inline-action-edit[data-row-action="edit"]' @@ -902,6 +905,124 @@ async def test_row_delete_action_data_attributes(): ds.close() +@pytest.mark.asyncio +async def test_table_insert_action_button_and_data(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "insert-row": {"id": "root"}, + }, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_action"), name="data" + ) + await db.execute_write_script(""" + create table items ( + id integer primary key, + name text not null, + score integer default 5, + created text default (datetime('now')), + body text + ); + """) + response = await ds.client.get("/data/items", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + + button = soup.select_one( + 'button.table-insert-row[data-table-action="insert-row"]' + ) + assert button is not None + assert button.text.strip() == "Insert row" + assert button.find("svg") is not None + assert button.find_parent("div", class_="table-row-toolbar") is not None + + 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 [column["name"] for column in insert_data["columns"]] == [ + "name", + "score", + "created", + "body", + ] + name, score, created, body = insert_data["columns"] + assert name["notnull"] == 1 + assert name["value_type"] == "string" + assert not name["has_default"] + assert score["default"] == "5" + assert score["has_default"] + assert score["value_type"] == "number" + assert created["default"] == "datetime('now')" + assert created["has_default"] + assert body["value_type"] == "string" + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_table_insert_action_includes_compound_primary_keys(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "memberships": { + "permissions": { + "insert-row": {"id": "root"}, + }, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_compound_pk"), name="data" + ) + await db.execute_write_script(""" + create table memberships ( + account text, + username text, + role text, + primary key (account, username) + ); + """) + response = await ds.client.get("/data/memberships", actor={"id": "root"}) + assert response.status_code == 200 + insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[ + "insertRow" + ] + assert insert_data["tableName"] == "memberships" + assert insert_data["primaryKeys"] == ["account", "username"] + assert [column["name"] for column in insert_data["columns"]] == [ + "account", + "username", + "role", + ] + assert [column["is_pk"] for column in insert_data["columns"]] == [ + True, + True, + False, + ] + 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") @@ -912,9 +1033,7 @@ async def test_table_fragment_endpoint(ds_client): rows = soup.select("[data-row]") assert len(rows) == 1 assert rows[0]["data-row"] == "1" - assert { - key for key in rows[0].attrs if key.startswith("data-row") - } == {"data-row"} + assert {key for key in rows[0].attrs if key.startswith("data-row")} == {"data-row"} @pytest.mark.asyncio @@ -979,9 +1098,7 @@ async def test_table_fragment_uses_render_cell_hook(): ds = Datasette(memory=True) await ds.invoke_startup() db = ds.add_memory_database("data") - await db.execute_write( - "create table items (id integer primary key, name text)" - ) + await db.execute_write("create table items (id integer primary key, name text)") await db.execute_write("insert into items values (1, 'Alice')") ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin") try: