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 = `
+
+
+ `;
+ 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 = `