From 82c95a1a135d4a2c1b667031e141cb7f88193bc4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 14 Jun 2026 15:59:08 -0700 Subject: [PATCH] Refactor edit/delete tools to work on row pages too Refs https://github.com/simonw/datasette/pull/2781#issuecomment-4703303274 Refs #2780 --- datasette/app.py | 1 + datasette/static/edit-tools.js | 1972 ++++++++++++++++++++++++++++ datasette/static/table.js | 1938 --------------------------- datasette/templates/row.html | 7 + datasette/templates/table.html | 1 + datasette/views/row.py | 120 +- tests/test_datasette_manager_js.py | 16 +- tests/test_table_html.py | 154 +++ 8 files changed, 2261 insertions(+), 1948 deletions(-) create mode 100644 datasette/static/edit-tools.js diff --git a/datasette/app.py b/datasette/app.py index 9b3cf51c..545a65c8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2320,6 +2320,7 @@ class Datasette: and "ds_actor" in request.cookies and request.actor, "app_css_hash": self.app_css_hash(), + "edit_tools_js_hash": self.static_hash("edit-tools.js"), "table_js_hash": self.static_hash("table.js"), "zip": zip, "body_scripts": body_scripts, diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js new file mode 100644 index 00000000..2af7b35b --- /dev/null +++ b/datasette/static/edit-tools.js @@ -0,0 +1,1972 @@ +var ROW_DELETE_DIALOG_ID = "row-delete-dialog"; +var rowDeleteDialogState = null; +var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; +var rowEditDialogState = null; + +function ensureRowMutationStatus(manager) { + var status = document.querySelector(".row-mutation-status"); + if (status) { + return 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"); + + var tableWrapper = document.querySelector(manager.selectors.tableWrapper); + if (tableWrapper && tableWrapper.parentNode) { + tableWrapper.parentNode.insertBefore(status, tableWrapper); + } else { + document.body.appendChild(status); + } + return status; +} + +function showRowMutationStatus(manager, message, isError) { + var status = ensureRowMutationStatus(manager); + status.hidden = false; + status.classList.toggle("row-mutation-status-error", !!isError); + status.textContent = message; + 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; + state.cancelButton.disabled = isBusy; + state.confirmButton.textContent = isBusy ? "Deleting..." : "Delete row"; +} + +function clearRowDeleteDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; +} + +function showRowDeleteDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; +} + +function rowMutationRequestError(response, data) { + if (data && data.errors) { + return new Error(data.errors.join(" ")); + } + if (data && data.error) { + return new Error(data.error); + } + if (data && data.title) { + return new Error(data.title); + } + return new Error("Request failed with HTTP " + response.status); +} + +function tildeDecode(value) { + if (!value) { + return ""; + } + var placeholder = "__datasette_percent_placeholder__"; + try { + return decodeURIComponent( + value + .replace(/%/g, placeholder) + .replace(/~/g, "%") + .replace(/\+/g, " "), + ).replace(new RegExp(placeholder, "g"), "%"); + } catch (_error) { + return 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") || ""); +} + +function rowTitleLabel(row) { + return row.getAttribute("data-row-label") || ""; +} + +function insertedRowStatusMessage(rowId, rowLabel) { + var message = "Inserted row " + rowId; + if (rowLabel && rowLabel !== rowId) { + message += " (" + rowLabel + ")"; + } + return message + "."; +} + +function tableBaseUrl() { + var tableUrl = + window._datasetteTableData && window._datasetteTableData.tableUrl; + var url = new URL(tableUrl || location.href, location.href); + url.hash = ""; + url.search = ""; + return url; +} + +function tablePageData() { + return window._datasetteTableData || {}; +} + +function tableInsertData() { + return tablePageData().insertRow; +} + +function tableForeignKeys() { + return tablePageData().foreignKeys || {}; +} + +function isRowPage() { + return document.body && document.body.classList.contains("row"); +} + +function rowElementForActionButton(button) { + return ( + button.closest("[data-row]") || + (button.getAttribute("data-row") ? button : null) + ); +} + +function foreignKeyAutocompleteUrl(column) { + return tableForeignKeys()[column] || null; +} + +function autocompleteRowPk(row) { + var pks = (row && row.pks) || {}; + var keys = Object.keys(pks); + if (keys.length !== 1) { + return null; + } + return pks[keys[0]]; +} + +function foreignKeyRowUrl(autocompleteUrl, pk) { + var url = new URL(autocompleteUrl, location.href); + if (!/\/-\/autocomplete\/?$/.test(url.pathname)) { + return null; + } + url.pathname = + url.pathname.replace(/\/-\/autocomplete\/?$/, "") + "/" + tildeEncode(pk); + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function foreignKeyLabelText(row) { + var pk = autocompleteRowPk(row); + var label = row && row.label; + if ( + label !== null && + typeof label !== "undefined" && + String(label) !== String(pk) + ) { + return String(label); + } + return "View row"; +} + +function rowEditMetaTextWithoutCurrentValue(meta) { + return (meta.dataset.baseMeta || "") + .split(" · ") + .filter(function (part) { + return part !== "Current value: NULL"; + }) + .join(" · "); +} + +function updateRowEditForeignKeySeparator(meta) { + var separator = meta.querySelector(".row-edit-fk-separator"); + if (!separator) { + return; + } + var baseMeta = meta.querySelector(".row-edit-base-meta"); + var hasBaseMeta = !!(baseMeta && baseMeta.textContent); + separator.textContent = hasBaseMeta ? " · " : ""; + separator.hidden = !hasBaseMeta; +} + +function updateRowEditFieldMetaHidden(meta) { + var baseMeta = meta.querySelector(".row-edit-base-meta"); + var hasBaseMeta = !!(baseMeta && baseMeta.textContent); + var foreignKeyLinkWrap = meta.querySelector(".row-edit-fk-link-wrap"); + var hasForeignKeyLink = foreignKeyLinkWrap && !foreignKeyLinkWrap.hidden; + meta.hidden = + meta.dataset.reserveSpace !== "1" && !hasBaseMeta && !hasForeignKeyLink; +} + +function setRowEditBaseMetaText(meta, text) { + var baseMeta = meta.querySelector(".row-edit-base-meta"); + if (!baseMeta) { + return; + } + baseMeta.textContent = text || ""; + updateRowEditForeignKeySeparator(meta); + updateRowEditFieldMetaHidden(meta); +} + +function setForeignKeyMetaLink(meta, autocompleteUrl, row) { + var wrap = meta.querySelector(".row-edit-fk-link-wrap"); + if (!wrap) { + return; + } + var pkSpan = wrap.querySelector(".row-edit-fk-pk"); + var link = wrap.querySelector("a"); + var pk = autocompleteRowPk(row); + var url = + pk === null || typeof pk === "undefined" + ? null + : foreignKeyRowUrl(autocompleteUrl, pk); + if (!url) { + wrap.hidden = true; + pkSpan.textContent = ""; + link.removeAttribute("href"); + link.textContent = ""; + link.removeAttribute("aria-label"); + setRowEditBaseMetaText(meta, meta.dataset.baseMeta || ""); + updateRowEditFieldMetaHidden(meta); + return; + } + setRowEditBaseMetaText(meta, rowEditMetaTextWithoutCurrentValue(meta)); + var pkText = String(pk); + var linkText = foreignKeyLabelText(row); + pkSpan.textContent = pkText; + link.href = url; + link.textContent = linkText; + link.setAttribute( + "aria-label", + "Open referenced row " + pkText + " " + linkText + " in a new tab", + ); + wrap.hidden = false; + updateRowEditFieldMetaHidden(meta); +} + +async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) { + var value = control.value.trim(); + if (!value) { + setForeignKeyMetaLink(meta, autocompleteUrl, null); + return; + } + + var url = new URL(autocompleteUrl, location.href); + url.searchParams.set("q", value); + try { + var response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + var data = await response.json(); + if (control.value.trim() !== value) { + return; + } + var rows = (data && data.rows) || []; + var row = rows.find(function (candidate) { + var pk = autocompleteRowPk(candidate); + return pk !== null && typeof pk !== "undefined" && String(pk) === value; + }); + setForeignKeyMetaLink(meta, autocompleteUrl, row || null); + } catch (_error) { + if (control.value.trim() === value) { + setForeignKeyMetaLink(meta, autocompleteUrl, null); + } + } +} + +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) { + return null; + } + var url = tableBaseUrl(); + url.pathname = url.pathname.replace(/\/$/, "") + "/" + rowId; + return url; +} + +function rowJsonUrl(row) { + var url = rowResourceUrl(row); + if (!url) { + return ""; + } + url.pathname = url.pathname + ".json"; + url.searchParams.set("_extra", "columns,column_types"); + return url.toString(); +} + +function rowDeleteUrl(row) { + var url = rowResourceUrl(row); + if (!url) { + return ""; + } + url.pathname = url.pathname.replace(/\/$/, "") + "/-/delete"; + if (isRowPage()) { + url.searchParams.set("_redirect_to_table", "1"); + } + return url.toString(); +} + +function rowUpdateUrl(row) { + var url = rowResourceUrl(row); + if (!url) { + return ""; + } + url.pathname = url.pathname.replace(/\/$/, "") + "/-/update"; + if (isRowPage()) { + url.searchParams.set("_message", "1"); + } + return url.toString(); +} + +function rowFragmentUrl(row) { + var rowId = row.getAttribute("data-row"); + return rowFragmentUrlById(rowId); +} + +function rowFragmentUrlById(rowId) { + if (!rowId) { + return ""; + } + var url = tableBaseUrl(); + url.search = new URL(location.href).search; + url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment"; + url.searchParams.delete("_next"); + url.searchParams.set("_row", rowId); + url.searchParams.set("_nocount", "1"); + url.searchParams.set("_nofacet", "1"); + url.searchParams.set("_nosuggest", "1"); + return url.toString(); +} + +function nextRowActionFocusTarget(row, action) { + var selector = 'button[data-row-action="' + action + '"]:not([disabled])'; + var sibling = row.nextElementSibling; + while (sibling) { + var nextButton = sibling.querySelector(selector); + if (nextButton) { + return nextButton; + } + sibling = sibling.nextElementSibling; + } + + sibling = row.previousElementSibling; + while (sibling) { + var previousButton = sibling.querySelector(selector); + if (previousButton) { + return previousButton; + } + sibling = sibling.previousElementSibling; + } + + return null; +} + +function nextRowDeleteFocusTarget(row, manager) { + return nextRowActionFocusTarget(row, "delete") || ensureRowMutationStatus(manager); +} + +function ensureRowDeleteDialog(manager) { + if (rowDeleteDialogState) { + return rowDeleteDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = ROW_DELETE_DIALOG_ID; + dialog.className = "row-delete-dialog"; + dialog.setAttribute("aria-labelledby", "row-delete-title"); + dialog.setAttribute("aria-describedby", "row-delete-message"); + dialog.innerHTML = ` + +

Delete row ?

+ + + `; + document.body.appendChild(dialog); + + rowDeleteDialogState = { + dialog: dialog, + title: dialog.querySelector(".modal-title"), + message: dialog.querySelector(".row-delete-message"), + rowId: dialog.querySelector(".row-delete-id"), + error: dialog.querySelector(".row-delete-error"), + cancelButton: dialog.querySelector(".row-delete-cancel"), + confirmButton: dialog.querySelector(".row-delete-confirm"), + currentRow: null, + currentDeleteUrl: null, + currentPkPath: null, + manager: manager, + isBusy: false, + shouldRestoreFocus: true, + }; + + rowDeleteDialogState.cancelButton.addEventListener("click", function () { + if (!rowDeleteDialogState.isBusy) { + rowDeleteDialogState.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog && !rowDeleteDialogState.isBusy) { + rowDeleteDialogState.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if ( + ev.key === "Enter" && + document.activeElement === rowDeleteDialogState.confirmButton + ) { + ev.preventDefault(); + if (!rowDeleteDialogState.isBusy) { + rowDeleteDialogState.confirmButton.click(); + } + return; + } + if (ev.key !== "Escape") { + return; + } + if (rowDeleteDialogState.isBusy) { + ev.preventDefault(); + return; + } + ev.preventDefault(); + rowDeleteDialogState.shouldRestoreFocus = true; + dialog.close(); + }); + + dialog.addEventListener("cancel", function (ev) { + if (rowDeleteDialogState.isBusy) { + ev.preventDefault(); + } else { + rowDeleteDialogState.shouldRestoreFocus = true; + } + }); + + dialog.addEventListener("close", function () { + var state = rowDeleteDialogState; + clearRowDeleteDialogError(state); + setRowDeleteDialogBusy(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + rowDeleteDialogState.confirmButton.addEventListener("click", async function () { + var state = rowDeleteDialogState; + clearRowDeleteDialogError(state); + setRowDeleteDialogBusy(state, true); + + try { + var response = await fetch(state.currentDeleteUrl, { + method: "POST", + 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.redirect) { + state.shouldRestoreFocus = false; + state.dialog.close(); + location.href = data.redirect; + return; + } + + var focusTarget = nextRowDeleteFocusTarget(state.currentRow, state.manager); + var statusMessage = state.currentPkPath + ? "Deleted row " + state.currentPkPath + "." + : "Deleted row."; + state.shouldRestoreFocus = false; + state.dialog.close(); + state.currentRow.remove(); + showRowMutationStatus(state.manager, statusMessage, false); + if (focusTarget && document.contains(focusTarget)) { + focusTarget.focus(); + } else { + ensureRowMutationStatus(state.manager).focus(); + } + } catch (error) { + setRowDeleteDialogBusy(state, false); + showRowDeleteDialogError(state, error.message || "Delete failed"); + } + }); + + return rowDeleteDialogState; +} + +function openRowDeleteDialog(button, manager) { + var row = rowElementForActionButton(button); + if (!row || !row.getAttribute("data-row")) { + return; + } + var state = ensureRowDeleteDialog(manager); + if (!state) { + return; + } + + state.manager = manager; + state.currentButton = button; + state.currentRow = row; + state.currentDeleteUrl = rowDeleteUrl(row); + state.currentPkPath = rowDisplayLabel(row); + state.shouldRestoreFocus = true; + + clearRowDeleteDialogError(state); + setRowDeleteDialogBusy(state, false); + setRowDialogTitle( + state.title, + "Delete row", + state.currentPkPath || "this row", + rowTitleLabel(row), + ); + state.rowId.textContent = state.currentPkPath || "this row"; + + if (!state.dialog.open) { + state.dialog.showModal(); + } + state.confirmButton.focus(); +} + +function initRowDeleteActions(manager) { + if (!window.fetch || !window.HTMLDialogElement) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-row-action="delete"]'); + if (!button) { + return; + } + ev.preventDefault(); + openRowDeleteDialog(button, manager); + }); +} + +function valueToEditText(value) { + if (value === null || typeof value === "undefined") { + return ""; + } + if (typeof value === "object") { + return JSON.stringify(value, null, 2); + } + return String(value); +} + +function shouldUseTextarea(value, columnType) { + if (columnType && columnType.type === "textarea") { + return true; + } + if (value && typeof value === "object") { + return true; + } + var text = valueToEditText(value); + return text.length > 80 || /[\r\n]/.test(text); +} + +function rowEditValueKind(value) { + if (value === null || typeof value === "undefined") { + return "null"; + } + if (typeof value === "number") { + return "number"; + } + if (typeof value === "boolean") { + return "boolean"; + } + return "string"; +} + +function rowEditControlElement(control, autocompleteUrl) { + if (!autocompleteUrl || control.nodeName !== "INPUT") { + return control; + } + var autocomplete = document.createElement("datasette-autocomplete"); + autocomplete.setAttribute("src", autocompleteUrl); + autocomplete.setAttribute("suggest-on-focus", ""); + autocomplete.appendChild(control); + return autocomplete; +} + +function columnTypeForContext(columnType) { + if (!columnType) { + return null; + } + return { + type: columnType.type, + config: columnType.config || {}, + }; +} + +function defaultExpressionForContext(expression) { + if (expression === null || typeof expression === "undefined") { + return null; + } + return expression; +} + +function columnFormControlContext(column, isPk, columnType, options) { + var pageData = tablePageData(); + var defaultExpression = defaultExpressionForContext(options.defaultExpression); + return { + mode: options.mode || "edit", + database: pageData.database || null, + table: pageData.table || (tableInsertData() && tableInsertData().tableName) || null, + tableUrl: pageData.tableUrl || null, + column: column, + columnType: columnTypeForContext(columnType), + sqliteType: options.sqliteType || null, + notNull: !!options.notnull, + isPk: !!isPk, + defaultExpression: defaultExpression, + form: options.form || null, + dialog: options.dialog || null, + }; +} + +function makeColumnField(manager, context) { + if (!manager || !manager.makeColumnField) { + return null; + } + return manager.makeColumnField(context); +} + +function createColumnFieldApi(options) { + var control = options.control; + var context = options.context; + var field = { + context: context, + id: options.id, + labelId: options.labelId, + descriptionId: options.descriptionId, + root: null, + form: options.form || null, + dialog: options.dialog || null, + input: control, + control: control, + meta: options.meta || null, + validationMessageElement: null, + getValue: function () { + return valueFromRowEditControl(control); + }, + setValue: function (value) { + if ( + value !== null && + typeof value !== "undefined" && + typeof value === "object" + ) { + throw new TypeError( + "field.setValue() accepts strings, numbers, booleans or null; serialize objects before setting the field value", + ); + } + field.stopUsingSqliteDefault(); + control.value = valueToEditText(value); + control.dataset.currentValueKind = rowEditValueKind(value); + }, + getInitialValue: function () { + return initialValueFromRowEditControl(control); + }, + hasChanged: function () { + return rowEditControlHasChanged(control); + }, + clearValue: function () { + field.setValue(null); + }, + isUsingSqliteDefault: function () { + return control.dataset.useSqliteDefault === "1"; + }, + useSqliteDefault: function () { + if ( + context.defaultExpression === null || + typeof context.defaultExpression === "undefined" + ) { + return; + } + control.dataset.useSqliteDefault = "1"; + control.disabled = true; + control.value = ""; + control.dataset.currentValueKind = "null"; + field.syncSqliteDefaultUi(); + }, + stopUsingSqliteDefault: function () { + if (control.dataset.useSqliteDefault !== "1") { + return; + } + control.dataset.useSqliteDefault = "0"; + control.disabled = false; + field.syncSqliteDefaultUi(); + }, + syncSqliteDefaultUi: function () {}, + markClean: function () { + markRowEditControlClean(control); + }, + setValidity: function (message) { + message = message || ""; + control.setCustomValidity(message); + if (message) { + control.setAttribute("aria-invalid", "true"); + } else { + control.removeAttribute("aria-invalid"); + } + var validationMessage = ensureColumnFieldValidationMessage(field); + if (validationMessage) { + validationMessage.textContent = message; + validationMessage.hidden = !message; + } + }, + clearValidity: function () { + field.setValidity(""); + }, + }; + field.markClean(); + return field; +} + +function ensureColumnFieldValidationMessage(field) { + if (field.validationMessageElement) { + return field.validationMessageElement; + } + if (!field.meta) { + return null; + } + var validationMessage = document.createElement("span"); + validationMessage.id = field.id + "-validation-error"; + validationMessage.className = "row-edit-field-validation-error"; + validationMessage.hidden = true; + validationMessage.setAttribute("role", "alert"); + field.meta.appendChild(validationMessage); + field.validationMessageElement = validationMessage; + return validationMessage; +} + +function renderColumnField(pluginControl, fieldApi) { + if (!pluginControl || !pluginControl.render) { + return null; + } + var pluginWrap = document.createElement("div"); + pluginWrap.className = "row-edit-plugin-control"; + pluginWrap.dataset.pluginName = pluginControl.pluginName || ""; + pluginWrap.dataset.column = fieldApi.context.column; + if (fieldApi.context.columnType && fieldApi.context.columnType.type) { + pluginWrap.dataset.columnType = fieldApi.context.columnType.type; + } + fieldApi.root = pluginWrap; + try { + var rendered = pluginControl.render(fieldApi); + if (rendered && rendered.nodeType) { + pluginWrap.appendChild(rendered); + } + } catch (error) { + console.error("Error rendering column form control", error); + return null; + } + pluginWrap._datasetteColumnField = pluginControl; + pluginWrap._datasetteColumnFormField = fieldApi; + return pluginWrap; +} + +function validateJsonColumnField(field) { + var value = field.input.value; + if (value.trim() === "") { + field.clearValidity(); + return true; + } + try { + JSON.parse(value); + field.clearValidity(); + return true; + } catch (error) { + field.setValidity( + "Invalid JSON" + (error && error.message ? ": " + error.message : ""), + ); + return false; + } +} + +function registerBuiltinColumnFieldPlugins(manager) { + if (!manager || !manager.registerPlugin) { + return; + } + manager.registerPlugin("datasette-json-column", { + version: "1.0", + makeColumnField: function (context) { + if (!context.columnType || context.columnType.type !== "json") { + return; + } + return { + useTextarea: true, + render: function (field) { + field.input.addEventListener("input", function () { + validateJsonColumnField(field); + }); + field.input.addEventListener("change", function () { + validateJsonColumnField(field); + }); + validateJsonColumnField(field); + return field.input; + }, + focus: function (field) { + field.input.focus(); + }, + }; + }, + }); +} + +function focusRowEditPluginControl(field) { + var pluginWrap = field.querySelector(".row-edit-plugin-control"); + if (!pluginWrap) { + return false; + } + var pluginControl = pluginWrap._datasetteColumnField; + var fieldApi = pluginWrap._datasetteColumnFormField; + if (pluginControl && pluginControl.focus) { + try { + pluginControl.focus(fieldApi); + return true; + } catch (error) { + console.error("Error focusing column form control", error); + } + } + return false; +} + +function focusFirstRowEditControl(state, options) { + options = options || {}; + var fields = state.fields.querySelectorAll(".row-edit-field"); + for (var i = 0; i < fields.length; i += 1) { + var field = fields[i]; + var control = field.querySelector(".row-edit-input"); + if (!control) { + continue; + } + if (options.skipReadonly && (control.readOnly || control.disabled)) { + continue; + } + if (focusRowEditPluginControl(field)) { + return true; + } + control.focus(); + return true; + } + return false; +} + +function destroyRowEditFields(state) { + if (!state || !state.fields) { + return; + } + state.fields + .querySelectorAll(".row-edit-plugin-control") + .forEach(function (pluginWrap) { + var pluginControl = pluginWrap._datasetteColumnField; + var fieldApi = pluginWrap._datasetteColumnFormField; + if (pluginControl && pluginControl.destroy) { + try { + pluginControl.destroy(fieldApi); + } catch (error) { + console.error("Error destroying column form control", error); + } + } + }); + state.fields.innerHTML = ""; +} + +function createRowEditField(column, value, isPk, columnType, index, options) { + options = options || {}; + var field = document.createElement("div"); + field.className = "row-edit-field"; + var defaultExpression = defaultExpressionForContext(options.defaultExpression); + var hasDefaultExpression = defaultExpression !== null; + var useSqliteDefault = hasDefaultExpression && options.useSqliteDefault; + + var fieldId = "row-edit-field-" + index; + var metaId = "row-edit-field-meta-" + index; + var labelId = "row-edit-field-label-" + index; + var label = document.createElement("label"); + label.className = "row-edit-label"; + label.id = labelId; + label.setAttribute("for", fieldId); + label.textContent = column; + + var controlWrap = document.createElement("div"); + controlWrap.className = "row-edit-control-wrap"; + + var context = columnFormControlContext( + column, + isPk, + columnType, + options, + ); + var pluginControl = makeColumnField(options.manager, context); + var useTextarea = + (pluginControl && pluginControl.useTextarea === true) || + shouldUseTextarea(value, columnType); + var control = useTextarea + ? document.createElement("textarea") + : document.createElement("input"); + control.className = "row-edit-input"; + control.id = fieldId; + control.name = column; + control.value = valueToEditText(value); + control.setAttribute("aria-describedby", metaId); + control.dataset.initialValue = valueToEditText(value); + control.dataset.initialValueKind = + options.valueKind || rowEditValueKind(value); + control.dataset.primaryKey = isPk ? "1" : "0"; + control.dataset.currentValueKind = control.dataset.initialValueKind; + if (hasDefaultExpression) { + control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0"; + } + if (useSqliteDefault) { + 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)); + } else { + control.type = "text"; + } + + if (isPk && options.primaryKeyReadonly !== false) { + control.readOnly = true; + } + + var meta = document.createElement("span"); + meta.id = metaId; + meta.className = "row-edit-field-meta"; + if (options.autocompleteUrl) { + meta.classList.add("row-edit-field-meta-autocomplete"); + meta.dataset.reserveSpace = "1"; + } + var metaParts = []; + if (isPk) { + metaParts.push("Primary key"); + } + if (options.notnull) { + metaParts.push("Required"); + } + if (hasDefaultExpression && !useSqliteDefault) { + metaParts.push("SQLite default: " + defaultExpression); + } + if (value === null) { + metaParts.push("Current value: NULL"); + control.placeholder = "NULL"; + } + if (columnType && columnType.type) { + metaParts.push("Custom type: " + columnType.type); + } + meta.dataset.baseMeta = metaParts.join(" · "); + var baseMeta = document.createElement("span"); + baseMeta.className = "row-edit-base-meta"; + baseMeta.textContent = meta.dataset.baseMeta; + meta.appendChild(baseMeta); + if (options.autocompleteUrl) { + var foreignKeyLinkWrap = document.createElement("span"); + foreignKeyLinkWrap.className = "row-edit-fk-link-wrap"; + foreignKeyLinkWrap.hidden = true; + var foreignKeySeparator = document.createElement("span"); + foreignKeySeparator.className = "row-edit-fk-separator"; + foreignKeySeparator.textContent = meta.dataset.baseMeta ? " · " : ""; + foreignKeySeparator.hidden = !meta.dataset.baseMeta; + foreignKeyLinkWrap.appendChild(foreignKeySeparator); + var foreignKeyPk = document.createElement("span"); + foreignKeyPk.className = "row-edit-fk-pk"; + foreignKeyLinkWrap.appendChild(foreignKeyPk); + foreignKeyLinkWrap.appendChild(document.createTextNode(" ")); + var foreignKeyLink = document.createElement("a"); + foreignKeyLink.className = "row-edit-fk-link"; + foreignKeyLink.target = "_blank"; + foreignKeyLink.rel = "noopener noreferrer"; + foreignKeyLinkWrap.appendChild(foreignKeyLink); + meta.appendChild(foreignKeyLinkWrap); + updateRowEditFieldMetaHidden(meta); + } + var fieldApi = createColumnFieldApi({ + id: fieldId, + labelId: labelId, + descriptionId: metaId, + control: control, + meta: meta, + input: control, + form: options.form || null, + dialog: options.dialog || null, + context: context, + }); + field._datasetteColumnFormField = fieldApi; + var pluginControlElement = renderColumnField(pluginControl, fieldApi); + var controlElement = + pluginControlElement || rowEditControlElement(control, options.autocompleteUrl); + if (options.autocompleteUrl && !pluginControlElement) { + control.addEventListener("input", function () { + setForeignKeyMetaLink(meta, options.autocompleteUrl, null); + }); + control.addEventListener("change", function () { + resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); + }); + controlElement.addEventListener("datasette-autocomplete-select", function (ev) { + setForeignKeyMetaLink( + meta, + options.autocompleteUrl, + ev.detail && ev.detail.row, + ); + }); + resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); + } + + if (hasDefaultExpression) { + 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 = defaultExpression; + 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 useSqliteDefaultButton = document.createElement("button"); + useSqliteDefaultButton.type = "button"; + useSqliteDefaultButton.className = "row-edit-default-button"; + useSqliteDefaultButton.textContent = "Use default"; + useSqliteDefaultButton.setAttribute( + "aria-label", + "Use SQLite default for " + column, + ); + + setValueButton.addEventListener("click", function () { + fieldApi.stopUsingSqliteDefault(); + control.focus(); + }); + + useSqliteDefaultButton.addEventListener("click", function () { + fieldApi.useSqliteDefault(); + setValueButton.focus(); + }); + + defaultBlock.appendChild(defaultText); + defaultBlock.appendChild(setValueButton); + customWrap.appendChild(controlElement); + customWrap.appendChild(useSqliteDefaultButton); + controlWrap.appendChild(defaultBlock); + controlWrap.appendChild(customWrap); + fieldApi.syncSqliteDefaultUi = function () { + var usingDefault = fieldApi.isUsingSqliteDefault(); + defaultBlock.hidden = !usingDefault; + customWrap.hidden = usingDefault; + }; + fieldApi.syncSqliteDefaultUi(); + } else { + controlWrap.appendChild(controlElement); + } + if (meta.textContent || options.autocompleteUrl) { + controlWrap.appendChild(meta); + } + field.appendChild(label); + field.appendChild(controlWrap); + return field; +} + +function clearRowEditDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; +} + +function showRowEditDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.error.focus(); +} + +function updateRowEditDialogButtons(state) { + state.saveButton.disabled = state.isLoading || state.isSaving || !state.hasLoaded; + state.cancelButton.disabled = state.isSaving; + 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", + ); +} + +function setRowEditDialogLoading(state, isLoading) { + state.isLoading = isLoading; + state.loading.hidden = !isLoading; + updateRowEditDialogButtons(state); +} + +function setRowEditDialogSaving(state, isSaving) { + state.isSaving = isSaving; + updateRowEditDialogButtons(state); +} + +function valueFromRowEditControl(control) { + var value = control.value; + return valueFromRowEditText( + control.name, + value, + rowEditControlValueKind(control), + ); +} + +function valueFromRowEditText(name, value, initialValueKind) { + var trimmed = value.trim(); + + if (initialValueKind === "null" && value === "") { + return null; + } + if (initialValueKind === "number") { + if (trimmed === "") { + return null; + } + var numberValue = Number(trimmed); + if (Number.isNaN(numberValue)) { + throw new Error(name + " must be a number"); + } + return numberValue; + } + if (initialValueKind === "boolean") { + if (/^(true|1|yes)$/i.test(trimmed)) { + return true; + } + if (/^(false|0|no)$/i.test(trimmed)) { + return false; + } + throw new Error(name + " must be true or false"); + } + return value; +} + +function initialValueFromRowEditControl(control) { + return valueFromRowEditText( + control.name, + control.dataset.initialValue || "", + control.dataset.initialValueKind || "string", + ); +} + +function rowEditControlValueKind(control) { + return ( + control.dataset.currentValueKind || + control.dataset.initialValueKind || + "string" + ); +} + +function rowEditControlCleanValue(control) { + if (Object.prototype.hasOwnProperty.call(control.dataset, "cleanValue")) { + return control.dataset.cleanValue; + } + return control.dataset.initialValue || ""; +} + +function rowEditControlCleanValueKind(control) { + return ( + control.dataset.cleanValueKind || + control.dataset.initialValueKind || + "string" + ); +} + +function rowEditControlCleanUsesSqliteDefault(control) { + if ( + Object.prototype.hasOwnProperty.call( + control.dataset, + "cleanUseSqliteDefault", + ) + ) { + return control.dataset.cleanUseSqliteDefault === "1"; + } + return false; +} + +function markRowEditControlClean(control) { + control.dataset.cleanValue = control.value; + control.dataset.cleanValueKind = rowEditControlValueKind(control); + control.dataset.cleanUseSqliteDefault = + control.dataset.useSqliteDefault === "1" ? "1" : "0"; +} + +function cleanValueFromRowEditControl(control) { + return valueFromRowEditText( + control.name, + rowEditControlCleanValue(control), + rowEditControlCleanValueKind(control), + ); +} + +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 rowEditControlHasChanged(control) { + var usingSqliteDefault = control.dataset.useSqliteDefault === "1"; + var cleanUsesSqliteDefault = rowEditControlCleanUsesSqliteDefault(control); + if (usingSqliteDefault || cleanUsesSqliteDefault) { + return usingSqliteDefault !== cleanUsesSqliteDefault; + } + if ( + control.value === rowEditControlCleanValue(control) && + rowEditControlValueKind(control) === rowEditControlCleanValueKind(control) + ) { + return false; + } + try { + return !rowEditValuesMatch( + valueFromRowEditControl(control), + cleanValueFromRowEditControl(control), + ); + } catch (_error) { + return true; + } +} + +function collectRowFormValues(state) { + var values = {}; + state.fields.querySelectorAll(".row-edit-input").forEach(function (control) { + if ( + state.mode === "edit" && + (control.readOnly || control.dataset.primaryKey === "1") + ) { + return; + } + if (control.dataset.useSqliteDefault === "1") { + return; + } + if (control.dataset.omitIfBlank === "1" && control.value === "") { + return; + } + if ( + state.mode === "edit" && + control.value === (control.dataset.initialValue || "") && + (control.dataset.currentValueKind || + control.dataset.initialValueKind || + "string") === (control.dataset.initialValueKind || "string") + ) { + return; + } + var value = valueFromRowEditControl(control); + if (state.mode === "edit") { + try { + if (rowEditValuesMatch(value, initialValueFromRowEditControl(control))) { + return; + } + } catch (_error) { + // If the original value cannot be parsed using the field's current + // type, treat the field as changed and submit the corrected value. + } + } + values[control.name] = value; + }); + return values; +} + +function rowEditDialogHasChanges(state) { + if (!state || !state.hasLoaded || state.isLoading) { + return false; + } + var fields = state.fields.querySelectorAll(".row-edit-field"); + for (var i = 0; i < fields.length; i += 1) { + var fieldApi = fields[i]._datasetteColumnFormField; + if (fieldApi && fieldApi.hasChanged && fieldApi.hasChanged()) { + return true; + } + } + return false; +} + +function confirmDiscardRowEditChanges(state) { + if (!rowEditDialogHasChanges(state)) { + return true; + } + var message = + state.mode === "insert" + ? "Discard this new row?" + : "Discard unsaved changes to this row?"; + return window.confirm(message); +} + +function closeRowEditDialogIfConfirmed(state) { + if (!state || state.isSaving) { + return false; + } + if (!confirmDiscardRowEditChanges(state)) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function findDataRowElement(root, rowId) { + var elements = root.querySelectorAll("[data-row]"); + for (var i = 0; i < elements.length; i += 1) { + if (elements[i].getAttribute("data-row") === rowId) { + return elements[i]; + } + } + return null; +} + +async function fetchUpdatedRowElement(state) { + if (!state.currentFragmentUrl || !state.currentRowId) { + return null; + } + var response = await fetch(state.currentFragmentUrl, { + headers: { + Accept: "text/html", + }, + }); + var html = await response.text(); + if (!response.ok) { + throw new Error("Could not refresh row: HTTP " + response.status); + } + var doc = new DOMParser().parseFromString(html, "text/html"); + 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; + } + clearRowEditDialogError(state); + setRowEditDialogSaving(state, true); + + try { + 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 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(payload), + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + 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 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 insertedStatusMessage = insertedRowStatusMessage( + tildeDecode(insertedRowId), + rowTitleLabel(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; + } + + if (isRowPage()) { + state.shouldRestoreFocus = false; + state.dialog.close(); + location.reload(); + 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)) { + focusTarget = + nextRowActionFocusTarget(state.currentRow, "edit") || + ensureRowMutationStatus(state.manager); + state.currentRow.remove(); + showRowMutationStatus( + state.manager, + state.currentPkPath + ? "Updated row " + + state.currentPkPath + + ". It no longer matches the current filters." + : "Updated row. It no longer matches the current filters.", + false, + ); + } + + state.shouldRestoreFocus = false; + state.dialog.close(); + if (focusTarget && document.contains(focusTarget)) { + focusTarget.focus(); + } + } catch (error) { + setRowEditDialogSaving(state, false); + showRowEditDialogError(state, error.message || "Could not save row"); + } +} + +function renderRowEditFields(state, data) { + var row = data.rows && data.rows.length ? data.rows[0] : null; + var columns = data.columns || (row ? Object.keys(row) : []); + var primaryKeys = data.primary_keys || []; + var columnTypes = data.column_types || {}; + + destroyRowEditFields(state); + columns.forEach(function (column, index) { + state.fields.appendChild( + createRowEditField( + column, + row ? row[column] : null, + primaryKeys.indexOf(column) !== -1, + columnTypes[column], + index, + { + autocompleteUrl: foreignKeyAutocompleteUrl(column), + dialog: state.dialog, + form: state.form, + manager: state.manager, + mode: state.mode, + primaryKeyReadonly: true, + }, + ), + ); + }); + + state.hasLoaded = true; + updateRowEditDialogButtons(state); + if (!focusFirstRowEditControl(state, { skipReadonly: true })) { + focusFirstRowEditControl(state) || state.cancelButton.focus(); + } +} + +function renderRowInsertFields(state, data) { + var columns = data.columns || []; + + destroyRowEditFields(state); + columns.forEach(function (column, index) { + state.fields.appendChild( + createRowEditField( + column.name, + "", + !!column.is_pk, + column.column_type, + index, + { + autocompleteUrl: foreignKeyAutocompleteUrl(column.name), + dialog: state.dialog, + form: state.form, + defaultExpression: column.default, + manager: state.manager, + mode: state.mode, + notnull: column.notnull, + primaryKeyReadonly: false, + sqliteType: column.sqlite_type, + useSqliteDefault: column.default !== null, + valueKind: column.value_kind, + }, + ), + ); + }); + + 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 firstDefaultButton = state.fields.querySelector(".row-edit-default-set-value"); + if (firstDefaultButton) { + firstDefaultButton.focus(); + } else { + focusFirstRowEditControl(state, { skipReadonly: true }) || + state.saveButton.focus(); + } +} + +function setRowDialogTitle(title, text, codeText, labelText) { + title.textContent = ""; + var action = document.createElement("span"); + action.className = "row-dialog-action"; + action.textContent = text; + title.appendChild(action); + if (!codeText) { + return; + } + title.appendChild(document.createTextNode(" ")); + var code = document.createElement("code"); + code.textContent = codeText; + title.appendChild(code); + if (labelText && labelText !== codeText) { + title.appendChild(document.createTextNode(" ")); + var label = document.createElement("span"); + label.className = "row-dialog-label"; + label.textContent = labelText; + title.appendChild(label); + } +} + +function ensureRowEditDialog(manager) { + if (rowEditDialogState) { + return rowEditDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = ROW_EDIT_DIALOG_ID; + dialog.className = "row-edit-dialog"; + dialog.setAttribute("aria-labelledby", "row-edit-title"); + dialog.innerHTML = ` + +
+ +

Loading row...

+ +
+ +
+ `; + document.body.appendChild(dialog); + + rowEditDialogState = { + dialog: dialog, + form: dialog.querySelector(".row-edit-form"), + 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"), + cancelButton: dialog.querySelector(".row-edit-cancel"), + saveButton: dialog.querySelector(".row-edit-save"), + currentButton: null, + currentRow: null, + currentRowId: null, + currentPkPath: null, + currentInsertUrl: null, + currentUpdateUrl: null, + currentFragmentUrl: null, + mode: "edit", + loadId: 0, + manager: manager, + isLoading: false, + isSaving: false, + hasLoaded: false, + shouldRestoreFocus: true, + }; + + rowEditDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + saveRowEditDialog(rowEditDialogState); + }); + + rowEditDialogState.cancelButton.addEventListener("click", function () { + if (!rowEditDialogState.isSaving) { + rowEditDialogState.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeRowEditDialogIfConfirmed(rowEditDialogState); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + closeRowEditDialogIfConfirmed(rowEditDialogState); + }); + + dialog.addEventListener("cancel", function (ev) { + if ( + rowEditDialogState.isSaving || + !confirmDiscardRowEditChanges(rowEditDialogState) + ) { + ev.preventDefault(); + } else { + rowEditDialogState.shouldRestoreFocus = true; + } + }); + + dialog.addEventListener("close", function () { + var state = rowEditDialogState; + state.loadId += 1; + clearRowEditDialogError(state); + state.hasLoaded = false; + destroyRowEditFields(state); + setRowEditDialogLoading(state, false); + setRowEditDialogSaving(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return rowEditDialogState; +} + +async function openRowEditDialog(button, manager) { + var row = rowElementForActionButton(button); + if (!row || !row.getAttribute("data-row")) { + return; + } + var state = ensureRowEditDialog(manager); + if (!state) { + return; + } + + 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) { + state.form.action = new URL(state.currentUpdateUrl, location.href).toString(); + } else { + state.form.removeAttribute("action"); + } + state.shouldRestoreFocus = true; + state.hasLoaded = false; + state.loadId += 1; + var loadId = state.loadId; + + 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 = ""; + + if (!state.dialog.open) { + state.dialog.showModal(); + } + state.cancelButton.focus(); + + try { + var response = await fetch(rowJsonUrl(row), { + headers: { + Accept: "application/json", + }, + }); + var data = await response.json(); + if (loadId !== state.loadId) { + return; + } + if (!response.ok || data.ok === false) { + throw rowMutationRequestError(response, data); + } + setRowEditDialogLoading(state, false); + renderRowEditFields(state, data); + } catch (error) { + if (loadId !== state.loadId) { + return; + } + setRowEditDialogLoading(state, false); + showRowEditDialogError(state, error.message || "Could not load row"); + state.cancelButton.focus(); + } +} + +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); + destroyRowEditFields(state); + state.dialog.removeAttribute("aria-describedby"); + setRowDialogTitle( + state.title, + 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; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-row-action="edit"]'); + if (!button) { + return; + } + ev.preventDefault(); + openRowEditDialog(button, 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); + }); +} + +document.addEventListener("datasette_init", function (evt) { + const { detail: manager } = evt; + + registerBuiltinColumnFieldPlugins(manager); + initRowInsertActions(manager); + initRowEditActions(manager); + initRowDeleteActions(manager); +}); diff --git a/datasette/static/table.js b/datasette/static/table.js index 291afc95..f160f3f3 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -12,11 +12,6 @@ var DROPDOWN_ICON_SVG = `= 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") || ""); -} - -function rowTitleLabel(row) { - return row.getAttribute("data-row-label") || ""; -} - -function insertedRowStatusMessage(rowId, rowLabel) { - var message = "Inserted row " + rowId; - if (rowLabel && rowLabel !== rowId) { - message += " (" + rowLabel + ")"; - } - return message + "."; -} - -function tableBaseUrl() { - var tableUrl = - window._datasetteTableData && window._datasetteTableData.tableUrl; - var url = new URL(tableUrl || location.href, location.href); - url.hash = ""; - url.search = ""; - return url; -} - -function tablePageData() { - return window._datasetteTableData || {}; -} - -function tableInsertData() { - return tablePageData().insertRow; -} - -function tableForeignKeys() { - return tablePageData().foreignKeys || {}; -} - -function foreignKeyAutocompleteUrl(column) { - return tableForeignKeys()[column] || null; -} - -function autocompleteRowPk(row) { - var pks = (row && row.pks) || {}; - var keys = Object.keys(pks); - if (keys.length !== 1) { - return null; - } - return pks[keys[0]]; -} - -function foreignKeyRowUrl(autocompleteUrl, pk) { - var url = new URL(autocompleteUrl, location.href); - if (!/\/-\/autocomplete\/?$/.test(url.pathname)) { - return null; - } - url.pathname = - url.pathname.replace(/\/-\/autocomplete\/?$/, "") + "/" + tildeEncode(pk); - url.search = ""; - url.hash = ""; - return url.toString(); -} - -function foreignKeyLabelText(row) { - var pk = autocompleteRowPk(row); - var label = row && row.label; - if ( - label !== null && - typeof label !== "undefined" && - String(label) !== String(pk) - ) { - return String(label); - } - return "View row"; -} - -function rowEditMetaTextWithoutCurrentValue(meta) { - return (meta.dataset.baseMeta || "") - .split(" · ") - .filter(function (part) { - return part !== "Current value: NULL"; - }) - .join(" · "); -} - -function updateRowEditForeignKeySeparator(meta) { - var separator = meta.querySelector(".row-edit-fk-separator"); - if (!separator) { - return; - } - var baseMeta = meta.querySelector(".row-edit-base-meta"); - var hasBaseMeta = !!(baseMeta && baseMeta.textContent); - separator.textContent = hasBaseMeta ? " · " : ""; - separator.hidden = !hasBaseMeta; -} - -function updateRowEditFieldMetaHidden(meta) { - var baseMeta = meta.querySelector(".row-edit-base-meta"); - var hasBaseMeta = !!(baseMeta && baseMeta.textContent); - var foreignKeyLinkWrap = meta.querySelector(".row-edit-fk-link-wrap"); - var hasForeignKeyLink = foreignKeyLinkWrap && !foreignKeyLinkWrap.hidden; - meta.hidden = - meta.dataset.reserveSpace !== "1" && !hasBaseMeta && !hasForeignKeyLink; -} - -function setRowEditBaseMetaText(meta, text) { - var baseMeta = meta.querySelector(".row-edit-base-meta"); - if (!baseMeta) { - return; - } - baseMeta.textContent = text || ""; - updateRowEditForeignKeySeparator(meta); - updateRowEditFieldMetaHidden(meta); -} - -function setForeignKeyMetaLink(meta, autocompleteUrl, row) { - var wrap = meta.querySelector(".row-edit-fk-link-wrap"); - if (!wrap) { - return; - } - var pkSpan = wrap.querySelector(".row-edit-fk-pk"); - var link = wrap.querySelector("a"); - var pk = autocompleteRowPk(row); - var url = - pk === null || typeof pk === "undefined" - ? null - : foreignKeyRowUrl(autocompleteUrl, pk); - if (!url) { - wrap.hidden = true; - pkSpan.textContent = ""; - link.removeAttribute("href"); - link.textContent = ""; - link.removeAttribute("aria-label"); - setRowEditBaseMetaText(meta, meta.dataset.baseMeta || ""); - updateRowEditFieldMetaHidden(meta); - return; - } - setRowEditBaseMetaText(meta, rowEditMetaTextWithoutCurrentValue(meta)); - var pkText = String(pk); - var linkText = foreignKeyLabelText(row); - pkSpan.textContent = pkText; - link.href = url; - link.textContent = linkText; - link.setAttribute( - "aria-label", - "Open referenced row " + pkText + " " + linkText + " in a new tab", - ); - wrap.hidden = false; - updateRowEditFieldMetaHidden(meta); -} - -async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) { - var value = control.value.trim(); - if (!value) { - setForeignKeyMetaLink(meta, autocompleteUrl, null); - return; - } - - var url = new URL(autocompleteUrl, location.href); - url.searchParams.set("q", value); - try { - var response = await fetch(url.toString(), { - headers: { - Accept: "application/json", - }, - }); - if (!response.ok) { - throw new Error("HTTP " + response.status); - } - var data = await response.json(); - if (control.value.trim() !== value) { - return; - } - var rows = (data && data.rows) || []; - var row = rows.find(function (candidate) { - var pk = autocompleteRowPk(candidate); - return pk !== null && typeof pk !== "undefined" && String(pk) === value; - }); - setForeignKeyMetaLink(meta, autocompleteUrl, row || null); - } catch (_error) { - if (control.value.trim() === value) { - setForeignKeyMetaLink(meta, autocompleteUrl, null); - } - } -} - -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) { - return null; - } - var url = tableBaseUrl(); - url.pathname = url.pathname.replace(/\/$/, "") + "/" + rowId; - return url; -} - -function rowJsonUrl(row) { - var url = rowResourceUrl(row); - if (!url) { - return ""; - } - url.pathname = url.pathname + ".json"; - url.searchParams.set("_extra", "columns,column_types"); - return url.toString(); -} - -function rowDeleteUrl(row) { - var url = rowResourceUrl(row); - if (!url) { - return ""; - } - url.pathname = url.pathname.replace(/\/$/, "") + "/-/delete"; - return url.toString(); -} - -function rowUpdateUrl(row) { - var url = rowResourceUrl(row); - if (!url) { - return ""; - } - url.pathname = url.pathname.replace(/\/$/, "") + "/-/update"; - return url.toString(); -} - -function rowFragmentUrl(row) { - var rowId = row.getAttribute("data-row"); - return rowFragmentUrlById(rowId); -} - -function rowFragmentUrlById(rowId) { - if (!rowId) { - return ""; - } - var url = tableBaseUrl(); - url.search = new URL(location.href).search; - url.pathname = url.pathname.replace(/\/$/, "") + "/-/fragment"; - url.searchParams.delete("_next"); - url.searchParams.set("_row", rowId); - url.searchParams.set("_nocount", "1"); - url.searchParams.set("_nofacet", "1"); - url.searchParams.set("_nosuggest", "1"); - return url.toString(); -} - -function nextRowActionFocusTarget(row, action) { - var selector = 'button[data-row-action="' + action + '"]:not([disabled])'; - var sibling = row.nextElementSibling; - while (sibling) { - var nextButton = sibling.querySelector(selector); - if (nextButton) { - return nextButton; - } - sibling = sibling.nextElementSibling; - } - - sibling = row.previousElementSibling; - while (sibling) { - var previousButton = sibling.querySelector(selector); - if (previousButton) { - return previousButton; - } - sibling = sibling.previousElementSibling; - } - - return null; -} - -function nextRowDeleteFocusTarget(row, manager) { - return nextRowActionFocusTarget(row, "delete") || ensureRowMutationStatus(manager); -} - -function ensureRowDeleteDialog(manager) { - if (rowDeleteDialogState) { - return rowDeleteDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = ROW_DELETE_DIALOG_ID; - dialog.className = "row-delete-dialog"; - dialog.setAttribute("aria-labelledby", "row-delete-title"); - dialog.setAttribute("aria-describedby", "row-delete-message"); - dialog.innerHTML = ` - -

Delete row ?

- - - `; - document.body.appendChild(dialog); - - rowDeleteDialogState = { - dialog: dialog, - title: dialog.querySelector(".modal-title"), - message: dialog.querySelector(".row-delete-message"), - rowId: dialog.querySelector(".row-delete-id"), - error: dialog.querySelector(".row-delete-error"), - cancelButton: dialog.querySelector(".row-delete-cancel"), - confirmButton: dialog.querySelector(".row-delete-confirm"), - currentRow: null, - currentDeleteUrl: null, - currentPkPath: null, - manager: manager, - isBusy: false, - shouldRestoreFocus: true, - }; - - rowDeleteDialogState.cancelButton.addEventListener("click", function () { - if (!rowDeleteDialogState.isBusy) { - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog && !rowDeleteDialogState.isBusy) { - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if ( - ev.key === "Enter" && - document.activeElement === rowDeleteDialogState.confirmButton - ) { - ev.preventDefault(); - if (!rowDeleteDialogState.isBusy) { - rowDeleteDialogState.confirmButton.click(); - } - return; - } - if (ev.key !== "Escape") { - return; - } - if (rowDeleteDialogState.isBusy) { - ev.preventDefault(); - return; - } - ev.preventDefault(); - rowDeleteDialogState.shouldRestoreFocus = true; - dialog.close(); - }); - - dialog.addEventListener("cancel", function (ev) { - if (rowDeleteDialogState.isBusy) { - ev.preventDefault(); - } else { - rowDeleteDialogState.shouldRestoreFocus = true; - } - }); - - dialog.addEventListener("close", function () { - var state = rowDeleteDialogState; - clearRowDeleteDialogError(state); - setRowDeleteDialogBusy(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } - }); - - rowDeleteDialogState.confirmButton.addEventListener("click", async function () { - var state = rowDeleteDialogState; - clearRowDeleteDialogError(state); - setRowDeleteDialogBusy(state, true); - - try { - var response = await fetch(state.currentDeleteUrl, { - method: "POST", - 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); - } - - var focusTarget = nextRowDeleteFocusTarget(state.currentRow, state.manager); - var statusMessage = state.currentPkPath - ? "Deleted row " + state.currentPkPath + "." - : "Deleted row."; - state.shouldRestoreFocus = false; - state.dialog.close(); - state.currentRow.remove(); - showRowMutationStatus(state.manager, statusMessage, false); - if (focusTarget && document.contains(focusTarget)) { - focusTarget.focus(); - } else { - ensureRowMutationStatus(state.manager).focus(); - } - } catch (error) { - setRowDeleteDialogBusy(state, false); - showRowDeleteDialogError(state, error.message || "Delete failed"); - } - }); - - return rowDeleteDialogState; -} - -function openRowDeleteDialog(button, manager) { - var row = button.closest("[data-row]"); - if (!row || !row.getAttribute("data-row")) { - return; - } - var state = ensureRowDeleteDialog(manager); - if (!state) { - return; - } - - state.manager = manager; - state.currentButton = button; - state.currentRow = row; - state.currentDeleteUrl = rowDeleteUrl(row); - state.currentPkPath = rowDisplayLabel(row); - state.shouldRestoreFocus = true; - - clearRowDeleteDialogError(state); - setRowDeleteDialogBusy(state, false); - setRowDialogTitle( - state.title, - "Delete row", - state.currentPkPath || "this row", - rowTitleLabel(row), - ); - state.rowId.textContent = state.currentPkPath || "this row"; - - if (!state.dialog.open) { - state.dialog.showModal(); - } - state.confirmButton.focus(); -} - -function initRowDeleteActions(manager) { - if (!window.fetch || !window.HTMLDialogElement) { - return; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest('button[data-row-action="delete"]'); - if (!button) { - return; - } - ev.preventDefault(); - openRowDeleteDialog(button, manager); - }); -} - -function valueToEditText(value) { - if (value === null || typeof value === "undefined") { - return ""; - } - if (typeof value === "object") { - return JSON.stringify(value, null, 2); - } - return String(value); -} - -function shouldUseTextarea(value, columnType) { - if (columnType && columnType.type === "textarea") { - return true; - } - if (value && typeof value === "object") { - return true; - } - var text = valueToEditText(value); - return text.length > 80 || /[\r\n]/.test(text); -} - -function rowEditValueKind(value) { - if (value === null || typeof value === "undefined") { - return "null"; - } - if (typeof value === "number") { - return "number"; - } - if (typeof value === "boolean") { - return "boolean"; - } - return "string"; -} - -function rowEditControlElement(control, autocompleteUrl) { - if (!autocompleteUrl || control.nodeName !== "INPUT") { - return control; - } - var autocomplete = document.createElement("datasette-autocomplete"); - autocomplete.setAttribute("src", autocompleteUrl); - autocomplete.setAttribute("suggest-on-focus", ""); - autocomplete.appendChild(control); - return autocomplete; -} - -function columnTypeForContext(columnType) { - if (!columnType) { - return null; - } - return { - type: columnType.type, - config: columnType.config || {}, - }; -} - -function defaultExpressionForContext(expression) { - if (expression === null || typeof expression === "undefined") { - return null; - } - return expression; -} - -function columnFormControlContext(column, isPk, columnType, options) { - var pageData = tablePageData(); - var defaultExpression = defaultExpressionForContext(options.defaultExpression); - return { - mode: options.mode || "edit", - database: pageData.database || null, - table: pageData.table || (tableInsertData() && tableInsertData().tableName) || null, - tableUrl: pageData.tableUrl || null, - column: column, - columnType: columnTypeForContext(columnType), - sqliteType: options.sqliteType || null, - notNull: !!options.notnull, - isPk: !!isPk, - defaultExpression: defaultExpression, - form: options.form || null, - dialog: options.dialog || null, - }; -} - -function makeColumnField(manager, context) { - if (!manager || !manager.makeColumnField) { - return null; - } - return manager.makeColumnField(context); -} - -function createColumnFieldApi(options) { - var control = options.control; - var context = options.context; - var field = { - context: context, - id: options.id, - labelId: options.labelId, - descriptionId: options.descriptionId, - root: null, - form: options.form || null, - dialog: options.dialog || null, - input: control, - control: control, - meta: options.meta || null, - validationMessageElement: null, - getValue: function () { - return valueFromRowEditControl(control); - }, - setValue: function (value) { - if ( - value !== null && - typeof value !== "undefined" && - typeof value === "object" - ) { - throw new TypeError( - "field.setValue() accepts strings, numbers, booleans or null; serialize objects before setting the field value", - ); - } - field.stopUsingSqliteDefault(); - control.value = valueToEditText(value); - control.dataset.currentValueKind = rowEditValueKind(value); - }, - getInitialValue: function () { - return initialValueFromRowEditControl(control); - }, - hasChanged: function () { - return rowEditControlHasChanged(control); - }, - clearValue: function () { - field.setValue(null); - }, - isUsingSqliteDefault: function () { - return control.dataset.useSqliteDefault === "1"; - }, - useSqliteDefault: function () { - if ( - context.defaultExpression === null || - typeof context.defaultExpression === "undefined" - ) { - return; - } - control.dataset.useSqliteDefault = "1"; - control.disabled = true; - control.value = ""; - control.dataset.currentValueKind = "null"; - field.syncSqliteDefaultUi(); - }, - stopUsingSqliteDefault: function () { - if (control.dataset.useSqliteDefault !== "1") { - return; - } - control.dataset.useSqliteDefault = "0"; - control.disabled = false; - field.syncSqliteDefaultUi(); - }, - syncSqliteDefaultUi: function () {}, - markClean: function () { - markRowEditControlClean(control); - }, - setValidity: function (message) { - message = message || ""; - control.setCustomValidity(message); - if (message) { - control.setAttribute("aria-invalid", "true"); - } else { - control.removeAttribute("aria-invalid"); - } - var validationMessage = ensureColumnFieldValidationMessage(field); - if (validationMessage) { - validationMessage.textContent = message; - validationMessage.hidden = !message; - } - }, - clearValidity: function () { - field.setValidity(""); - }, - }; - field.markClean(); - return field; -} - -function ensureColumnFieldValidationMessage(field) { - if (field.validationMessageElement) { - return field.validationMessageElement; - } - if (!field.meta) { - return null; - } - var validationMessage = document.createElement("span"); - validationMessage.id = field.id + "-validation-error"; - validationMessage.className = "row-edit-field-validation-error"; - validationMessage.hidden = true; - validationMessage.setAttribute("role", "alert"); - field.meta.appendChild(validationMessage); - field.validationMessageElement = validationMessage; - return validationMessage; -} - -function renderColumnField(pluginControl, fieldApi) { - if (!pluginControl || !pluginControl.render) { - return null; - } - var pluginWrap = document.createElement("div"); - pluginWrap.className = "row-edit-plugin-control"; - pluginWrap.dataset.pluginName = pluginControl.pluginName || ""; - pluginWrap.dataset.column = fieldApi.context.column; - if (fieldApi.context.columnType && fieldApi.context.columnType.type) { - pluginWrap.dataset.columnType = fieldApi.context.columnType.type; - } - fieldApi.root = pluginWrap; - try { - var rendered = pluginControl.render(fieldApi); - if (rendered && rendered.nodeType) { - pluginWrap.appendChild(rendered); - } - } catch (error) { - console.error("Error rendering column form control", error); - return null; - } - pluginWrap._datasetteColumnField = pluginControl; - pluginWrap._datasetteColumnFormField = fieldApi; - return pluginWrap; -} - -function validateJsonColumnField(field) { - var value = field.input.value; - if (value.trim() === "") { - field.clearValidity(); - return true; - } - try { - JSON.parse(value); - field.clearValidity(); - return true; - } catch (error) { - field.setValidity( - "Invalid JSON" + (error && error.message ? ": " + error.message : ""), - ); - return false; - } -} - -function registerBuiltinColumnFieldPlugins(manager) { - if (!manager || !manager.registerPlugin) { - return; - } - manager.registerPlugin("datasette-json-column", { - version: "1.0", - makeColumnField: function (context) { - if (!context.columnType || context.columnType.type !== "json") { - return; - } - return { - useTextarea: true, - render: function (field) { - field.input.addEventListener("input", function () { - validateJsonColumnField(field); - }); - field.input.addEventListener("change", function () { - validateJsonColumnField(field); - }); - validateJsonColumnField(field); - return field.input; - }, - focus: function (field) { - field.input.focus(); - }, - }; - }, - }); -} - -function focusRowEditPluginControl(field) { - var pluginWrap = field.querySelector(".row-edit-plugin-control"); - if (!pluginWrap) { - return false; - } - var pluginControl = pluginWrap._datasetteColumnField; - var fieldApi = pluginWrap._datasetteColumnFormField; - if (pluginControl && pluginControl.focus) { - try { - pluginControl.focus(fieldApi); - return true; - } catch (error) { - console.error("Error focusing column form control", error); - } - } - return false; -} - -function focusFirstRowEditControl(state, options) { - options = options || {}; - var fields = state.fields.querySelectorAll(".row-edit-field"); - for (var i = 0; i < fields.length; i += 1) { - var field = fields[i]; - var control = field.querySelector(".row-edit-input"); - if (!control) { - continue; - } - if (options.skipReadonly && (control.readOnly || control.disabled)) { - continue; - } - if (focusRowEditPluginControl(field)) { - return true; - } - control.focus(); - return true; - } - return false; -} - -function destroyRowEditFields(state) { - if (!state || !state.fields) { - return; - } - state.fields - .querySelectorAll(".row-edit-plugin-control") - .forEach(function (pluginWrap) { - var pluginControl = pluginWrap._datasetteColumnField; - var fieldApi = pluginWrap._datasetteColumnFormField; - if (pluginControl && pluginControl.destroy) { - try { - pluginControl.destroy(fieldApi); - } catch (error) { - console.error("Error destroying column form control", error); - } - } - }); - state.fields.innerHTML = ""; -} - -function createRowEditField(column, value, isPk, columnType, index, options) { - options = options || {}; - var field = document.createElement("div"); - field.className = "row-edit-field"; - var defaultExpression = defaultExpressionForContext(options.defaultExpression); - var hasDefaultExpression = defaultExpression !== null; - var useSqliteDefault = hasDefaultExpression && options.useSqliteDefault; - - var fieldId = "row-edit-field-" + index; - var metaId = "row-edit-field-meta-" + index; - var labelId = "row-edit-field-label-" + index; - var label = document.createElement("label"); - label.className = "row-edit-label"; - label.id = labelId; - label.setAttribute("for", fieldId); - label.textContent = column; - - var controlWrap = document.createElement("div"); - controlWrap.className = "row-edit-control-wrap"; - - var context = columnFormControlContext( - column, - isPk, - columnType, - options, - ); - var pluginControl = makeColumnField(options.manager, context); - var useTextarea = - (pluginControl && pluginControl.useTextarea === true) || - shouldUseTextarea(value, columnType); - var control = useTextarea - ? document.createElement("textarea") - : document.createElement("input"); - control.className = "row-edit-input"; - control.id = fieldId; - control.name = column; - control.value = valueToEditText(value); - control.setAttribute("aria-describedby", metaId); - control.dataset.initialValue = valueToEditText(value); - control.dataset.initialValueKind = - options.valueKind || rowEditValueKind(value); - control.dataset.primaryKey = isPk ? "1" : "0"; - control.dataset.currentValueKind = control.dataset.initialValueKind; - if (hasDefaultExpression) { - control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0"; - } - if (useSqliteDefault) { - 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)); - } else { - control.type = "text"; - } - - if (isPk && options.primaryKeyReadonly !== false) { - control.readOnly = true; - } - - var meta = document.createElement("span"); - meta.id = metaId; - meta.className = "row-edit-field-meta"; - if (options.autocompleteUrl) { - meta.classList.add("row-edit-field-meta-autocomplete"); - meta.dataset.reserveSpace = "1"; - } - var metaParts = []; - if (isPk) { - metaParts.push("Primary key"); - } - if (options.notnull) { - metaParts.push("Required"); - } - if (hasDefaultExpression && !useSqliteDefault) { - metaParts.push("SQLite default: " + defaultExpression); - } - if (value === null) { - metaParts.push("Current value: NULL"); - control.placeholder = "NULL"; - } - if (columnType && columnType.type) { - metaParts.push("Custom type: " + columnType.type); - } - meta.dataset.baseMeta = metaParts.join(" · "); - var baseMeta = document.createElement("span"); - baseMeta.className = "row-edit-base-meta"; - baseMeta.textContent = meta.dataset.baseMeta; - meta.appendChild(baseMeta); - if (options.autocompleteUrl) { - var foreignKeyLinkWrap = document.createElement("span"); - foreignKeyLinkWrap.className = "row-edit-fk-link-wrap"; - foreignKeyLinkWrap.hidden = true; - var foreignKeySeparator = document.createElement("span"); - foreignKeySeparator.className = "row-edit-fk-separator"; - foreignKeySeparator.textContent = meta.dataset.baseMeta ? " · " : ""; - foreignKeySeparator.hidden = !meta.dataset.baseMeta; - foreignKeyLinkWrap.appendChild(foreignKeySeparator); - var foreignKeyPk = document.createElement("span"); - foreignKeyPk.className = "row-edit-fk-pk"; - foreignKeyLinkWrap.appendChild(foreignKeyPk); - foreignKeyLinkWrap.appendChild(document.createTextNode(" ")); - var foreignKeyLink = document.createElement("a"); - foreignKeyLink.className = "row-edit-fk-link"; - foreignKeyLink.target = "_blank"; - foreignKeyLink.rel = "noopener noreferrer"; - foreignKeyLinkWrap.appendChild(foreignKeyLink); - meta.appendChild(foreignKeyLinkWrap); - updateRowEditFieldMetaHidden(meta); - } - var fieldApi = createColumnFieldApi({ - id: fieldId, - labelId: labelId, - descriptionId: metaId, - control: control, - meta: meta, - input: control, - form: options.form || null, - dialog: options.dialog || null, - context: context, - }); - field._datasetteColumnFormField = fieldApi; - var pluginControlElement = renderColumnField(pluginControl, fieldApi); - var controlElement = - pluginControlElement || rowEditControlElement(control, options.autocompleteUrl); - if (options.autocompleteUrl && !pluginControlElement) { - control.addEventListener("input", function () { - setForeignKeyMetaLink(meta, options.autocompleteUrl, null); - }); - control.addEventListener("change", function () { - resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); - }); - controlElement.addEventListener("datasette-autocomplete-select", function (ev) { - setForeignKeyMetaLink( - meta, - options.autocompleteUrl, - ev.detail && ev.detail.row, - ); - }); - resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta); - } - - if (hasDefaultExpression) { - 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 = defaultExpression; - 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 useSqliteDefaultButton = document.createElement("button"); - useSqliteDefaultButton.type = "button"; - useSqliteDefaultButton.className = "row-edit-default-button"; - useSqliteDefaultButton.textContent = "Use default"; - useSqliteDefaultButton.setAttribute( - "aria-label", - "Use SQLite default for " + column, - ); - - setValueButton.addEventListener("click", function () { - fieldApi.stopUsingSqliteDefault(); - control.focus(); - }); - - useSqliteDefaultButton.addEventListener("click", function () { - fieldApi.useSqliteDefault(); - setValueButton.focus(); - }); - - defaultBlock.appendChild(defaultText); - defaultBlock.appendChild(setValueButton); - customWrap.appendChild(controlElement); - customWrap.appendChild(useSqliteDefaultButton); - controlWrap.appendChild(defaultBlock); - controlWrap.appendChild(customWrap); - fieldApi.syncSqliteDefaultUi = function () { - var usingDefault = fieldApi.isUsingSqliteDefault(); - defaultBlock.hidden = !usingDefault; - customWrap.hidden = usingDefault; - }; - fieldApi.syncSqliteDefaultUi(); - } else { - controlWrap.appendChild(controlElement); - } - if (meta.textContent || options.autocompleteUrl) { - controlWrap.appendChild(meta); - } - field.appendChild(label); - field.appendChild(controlWrap); - return field; -} - -function clearRowEditDialogError(state) { - state.error.hidden = true; - state.error.textContent = ""; -} - -function showRowEditDialogError(state, message) { - state.error.hidden = false; - state.error.textContent = message; - state.error.focus(); -} - -function updateRowEditDialogButtons(state) { - state.saveButton.disabled = state.isLoading || state.isSaving || !state.hasLoaded; - state.cancelButton.disabled = state.isSaving; - 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", - ); -} - -function setRowEditDialogLoading(state, isLoading) { - state.isLoading = isLoading; - state.loading.hidden = !isLoading; - updateRowEditDialogButtons(state); -} - -function setRowEditDialogSaving(state, isSaving) { - state.isSaving = isSaving; - updateRowEditDialogButtons(state); -} - -function valueFromRowEditControl(control) { - var value = control.value; - return valueFromRowEditText( - control.name, - value, - rowEditControlValueKind(control), - ); -} - -function valueFromRowEditText(name, value, initialValueKind) { - var trimmed = value.trim(); - - if (initialValueKind === "null" && value === "") { - return null; - } - if (initialValueKind === "number") { - if (trimmed === "") { - return null; - } - var numberValue = Number(trimmed); - if (Number.isNaN(numberValue)) { - throw new Error(name + " must be a number"); - } - return numberValue; - } - if (initialValueKind === "boolean") { - if (/^(true|1|yes)$/i.test(trimmed)) { - return true; - } - if (/^(false|0|no)$/i.test(trimmed)) { - return false; - } - throw new Error(name + " must be true or false"); - } - return value; -} - -function initialValueFromRowEditControl(control) { - return valueFromRowEditText( - control.name, - control.dataset.initialValue || "", - control.dataset.initialValueKind || "string", - ); -} - -function rowEditControlValueKind(control) { - return ( - control.dataset.currentValueKind || - control.dataset.initialValueKind || - "string" - ); -} - -function rowEditControlCleanValue(control) { - if (Object.prototype.hasOwnProperty.call(control.dataset, "cleanValue")) { - return control.dataset.cleanValue; - } - return control.dataset.initialValue || ""; -} - -function rowEditControlCleanValueKind(control) { - return ( - control.dataset.cleanValueKind || - control.dataset.initialValueKind || - "string" - ); -} - -function rowEditControlCleanUsesSqliteDefault(control) { - if ( - Object.prototype.hasOwnProperty.call( - control.dataset, - "cleanUseSqliteDefault", - ) - ) { - return control.dataset.cleanUseSqliteDefault === "1"; - } - return false; -} - -function markRowEditControlClean(control) { - control.dataset.cleanValue = control.value; - control.dataset.cleanValueKind = rowEditControlValueKind(control); - control.dataset.cleanUseSqliteDefault = - control.dataset.useSqliteDefault === "1" ? "1" : "0"; -} - -function cleanValueFromRowEditControl(control) { - return valueFromRowEditText( - control.name, - rowEditControlCleanValue(control), - rowEditControlCleanValueKind(control), - ); -} - -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 rowEditControlHasChanged(control) { - var usingSqliteDefault = control.dataset.useSqliteDefault === "1"; - var cleanUsesSqliteDefault = rowEditControlCleanUsesSqliteDefault(control); - if (usingSqliteDefault || cleanUsesSqliteDefault) { - return usingSqliteDefault !== cleanUsesSqliteDefault; - } - if ( - control.value === rowEditControlCleanValue(control) && - rowEditControlValueKind(control) === rowEditControlCleanValueKind(control) - ) { - return false; - } - try { - return !rowEditValuesMatch( - valueFromRowEditControl(control), - cleanValueFromRowEditControl(control), - ); - } catch (_error) { - return true; - } -} - -function collectRowFormValues(state) { - var values = {}; - state.fields.querySelectorAll(".row-edit-input").forEach(function (control) { - if ( - state.mode === "edit" && - (control.readOnly || control.dataset.primaryKey === "1") - ) { - return; - } - if (control.dataset.useSqliteDefault === "1") { - return; - } - if (control.dataset.omitIfBlank === "1" && control.value === "") { - return; - } - if ( - state.mode === "edit" && - control.value === (control.dataset.initialValue || "") && - (control.dataset.currentValueKind || - control.dataset.initialValueKind || - "string") === (control.dataset.initialValueKind || "string") - ) { - return; - } - var value = valueFromRowEditControl(control); - if (state.mode === "edit") { - try { - if (rowEditValuesMatch(value, initialValueFromRowEditControl(control))) { - return; - } - } catch (_error) { - // If the original value cannot be parsed using the field's current - // type, treat the field as changed and submit the corrected value. - } - } - values[control.name] = value; - }); - return values; -} - -function rowEditDialogHasChanges(state) { - if (!state || !state.hasLoaded || state.isLoading) { - return false; - } - var fields = state.fields.querySelectorAll(".row-edit-field"); - for (var i = 0; i < fields.length; i += 1) { - var fieldApi = fields[i]._datasetteColumnFormField; - if (fieldApi && fieldApi.hasChanged && fieldApi.hasChanged()) { - return true; - } - } - return false; -} - -function confirmDiscardRowEditChanges(state) { - if (!rowEditDialogHasChanges(state)) { - return true; - } - var message = - state.mode === "insert" - ? "Discard this new row?" - : "Discard unsaved changes to this row?"; - return window.confirm(message); -} - -function closeRowEditDialogIfConfirmed(state) { - if (!state || state.isSaving) { - return false; - } - if (!confirmDiscardRowEditChanges(state)) { - return false; - } - state.shouldRestoreFocus = true; - state.dialog.close(); - return true; -} - -function findDataRowElement(root, rowId) { - var elements = root.querySelectorAll("[data-row]"); - for (var i = 0; i < elements.length; i += 1) { - if (elements[i].getAttribute("data-row") === rowId) { - return elements[i]; - } - } - return null; -} - -async function fetchUpdatedRowElement(state) { - if (!state.currentFragmentUrl || !state.currentRowId) { - return null; - } - var response = await fetch(state.currentFragmentUrl, { - headers: { - Accept: "text/html", - }, - }); - var html = await response.text(); - if (!response.ok) { - throw new Error("Could not refresh row: HTTP " + response.status); - } - var doc = new DOMParser().parseFromString(html, "text/html"); - 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; - } - clearRowEditDialogError(state); - setRowEditDialogSaving(state, true); - - try { - 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 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(payload), - }); - var data = null; - try { - data = await response.json(); - } catch (_error) { - data = null; - } - if (!response.ok || (data && data.ok === false)) { - 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 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 insertedStatusMessage = insertedRowStatusMessage( - tildeDecode(insertedRowId), - rowTitleLabel(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)) { - focusTarget = - nextRowActionFocusTarget(state.currentRow, "edit") || - ensureRowMutationStatus(state.manager); - state.currentRow.remove(); - showRowMutationStatus( - state.manager, - state.currentPkPath - ? "Updated row " + - state.currentPkPath + - ". It no longer matches the current filters." - : "Updated row. It no longer matches the current filters.", - false, - ); - } - - state.shouldRestoreFocus = false; - state.dialog.close(); - if (focusTarget && document.contains(focusTarget)) { - focusTarget.focus(); - } - } catch (error) { - setRowEditDialogSaving(state, false); - showRowEditDialogError(state, error.message || "Could not save row"); - } -} - -function renderRowEditFields(state, data) { - var row = data.rows && data.rows.length ? data.rows[0] : null; - var columns = data.columns || (row ? Object.keys(row) : []); - var primaryKeys = data.primary_keys || []; - var columnTypes = data.column_types || {}; - - destroyRowEditFields(state); - columns.forEach(function (column, index) { - state.fields.appendChild( - createRowEditField( - column, - row ? row[column] : null, - primaryKeys.indexOf(column) !== -1, - columnTypes[column], - index, - { - autocompleteUrl: foreignKeyAutocompleteUrl(column), - dialog: state.dialog, - form: state.form, - manager: state.manager, - mode: state.mode, - primaryKeyReadonly: true, - }, - ), - ); - }); - - state.hasLoaded = true; - updateRowEditDialogButtons(state); - if (!focusFirstRowEditControl(state, { skipReadonly: true })) { - focusFirstRowEditControl(state) || state.cancelButton.focus(); - } -} - -function renderRowInsertFields(state, data) { - var columns = data.columns || []; - - destroyRowEditFields(state); - columns.forEach(function (column, index) { - state.fields.appendChild( - createRowEditField( - column.name, - "", - !!column.is_pk, - column.column_type, - index, - { - autocompleteUrl: foreignKeyAutocompleteUrl(column.name), - dialog: state.dialog, - form: state.form, - defaultExpression: column.default, - manager: state.manager, - mode: state.mode, - notnull: column.notnull, - primaryKeyReadonly: false, - sqliteType: column.sqlite_type, - useSqliteDefault: column.default !== null, - valueKind: column.value_kind, - }, - ), - ); - }); - - 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 firstDefaultButton = state.fields.querySelector(".row-edit-default-set-value"); - if (firstDefaultButton) { - firstDefaultButton.focus(); - } else { - focusFirstRowEditControl(state, { skipReadonly: true }) || - state.saveButton.focus(); - } -} - -function setRowDialogTitle(title, text, codeText, labelText) { - title.textContent = ""; - var action = document.createElement("span"); - action.className = "row-dialog-action"; - action.textContent = text; - title.appendChild(action); - if (!codeText) { - return; - } - title.appendChild(document.createTextNode(" ")); - var code = document.createElement("code"); - code.textContent = codeText; - title.appendChild(code); - if (labelText && labelText !== codeText) { - title.appendChild(document.createTextNode(" ")); - var label = document.createElement("span"); - label.className = "row-dialog-label"; - label.textContent = labelText; - title.appendChild(label); - } -} - -function ensureRowEditDialog(manager) { - if (rowEditDialogState) { - return rowEditDialogState; - } - if (!window.HTMLDialogElement) { - return null; - } - - var dialog = document.createElement("dialog"); - dialog.id = ROW_EDIT_DIALOG_ID; - dialog.className = "row-edit-dialog"; - dialog.setAttribute("aria-labelledby", "row-edit-title"); - dialog.innerHTML = ` - -
- -

Loading row...

- -
- -
- `; - document.body.appendChild(dialog); - - rowEditDialogState = { - dialog: dialog, - form: dialog.querySelector(".row-edit-form"), - 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"), - cancelButton: dialog.querySelector(".row-edit-cancel"), - saveButton: dialog.querySelector(".row-edit-save"), - currentButton: null, - currentRow: null, - currentRowId: null, - currentPkPath: null, - currentInsertUrl: null, - currentUpdateUrl: null, - currentFragmentUrl: null, - mode: "edit", - loadId: 0, - manager: manager, - isLoading: false, - isSaving: false, - hasLoaded: false, - shouldRestoreFocus: true, - }; - - rowEditDialogState.form.addEventListener("submit", function (ev) { - ev.preventDefault(); - saveRowEditDialog(rowEditDialogState); - }); - - rowEditDialogState.cancelButton.addEventListener("click", function () { - if (!rowEditDialogState.isSaving) { - rowEditDialogState.shouldRestoreFocus = true; - dialog.close(); - } - }); - - dialog.addEventListener("click", function (ev) { - if (ev.target === dialog) { - closeRowEditDialogIfConfirmed(rowEditDialogState); - } - }); - - dialog.addEventListener("keydown", function (ev) { - if (ev.key !== "Escape") { - return; - } - ev.preventDefault(); - closeRowEditDialogIfConfirmed(rowEditDialogState); - }); - - dialog.addEventListener("cancel", function (ev) { - if ( - rowEditDialogState.isSaving || - !confirmDiscardRowEditChanges(rowEditDialogState) - ) { - ev.preventDefault(); - } else { - rowEditDialogState.shouldRestoreFocus = true; - } - }); - - dialog.addEventListener("close", function () { - var state = rowEditDialogState; - state.loadId += 1; - clearRowEditDialogError(state); - state.hasLoaded = false; - destroyRowEditFields(state); - setRowEditDialogLoading(state, false); - setRowEditDialogSaving(state, false); - if ( - state.shouldRestoreFocus && - state.currentButton && - document.contains(state.currentButton) - ) { - state.currentButton.focus(); - } - }); - - return rowEditDialogState; -} - -async function openRowEditDialog(button, manager) { - var row = button.closest("[data-row]"); - if (!row || !row.getAttribute("data-row")) { - return; - } - var state = ensureRowEditDialog(manager); - if (!state) { - return; - } - - 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) { - state.form.action = new URL(state.currentUpdateUrl, location.href).toString(); - } else { - state.form.removeAttribute("action"); - } - state.shouldRestoreFocus = true; - state.hasLoaded = false; - state.loadId += 1; - var loadId = state.loadId; - - 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 = ""; - - if (!state.dialog.open) { - state.dialog.showModal(); - } - state.cancelButton.focus(); - - try { - var response = await fetch(rowJsonUrl(row), { - headers: { - Accept: "application/json", - }, - }); - var data = await response.json(); - if (loadId !== state.loadId) { - return; - } - if (!response.ok || data.ok === false) { - throw rowMutationRequestError(response, data); - } - setRowEditDialogLoading(state, false); - renderRowEditFields(state, data); - } catch (error) { - if (loadId !== state.loadId) { - return; - } - setRowEditDialogLoading(state, false); - showRowEditDialogError(state, error.message || "Could not load row"); - state.cancelButton.focus(); - } -} - -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); - destroyRowEditFields(state); - state.dialog.removeAttribute("aria-describedby"); - setRowDialogTitle( - state.title, - 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; - } - document.addEventListener("click", function (ev) { - var button = ev.target.closest('button[data-row-action="edit"]'); - if (!button) { - return; - } - ev.preventDefault(); - openRowEditDialog(button, 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 @@ -2679,14 +745,10 @@ function openColumnChooser() { document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; - registerBuiltinColumnFieldPlugins(manager); initializeColumnActions(manager); // Main table initDatasetteTable(manager); - initRowInsertActions(manager); - initRowEditActions(manager); - initRowDeleteActions(manager); // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); diff --git a/datasette/templates/row.html b/datasette/templates/row.html index db43e71a..5e483d34 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -4,6 +4,13 @@ {% block extra_head %} {{- super() -}} +{% if row_mutation_ui %} + +{% if table_page_data.foreignKeys %} + +{% endif %} + +{% endif %}