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: