mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 17:24:35 +02:00
2023 lines
56 KiB
JavaScript
2023 lines
56 KiB
JavaScript
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 = `
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="row-delete-title">Delete row</span>
|
|
</div>
|
|
<p class="row-delete-message" id="row-delete-message">Delete row <span class="row-delete-id"></span>?</p>
|
|
<p class="row-delete-error" role="alert" hidden></p>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-ghost row-delete-cancel">Cancel</button>
|
|
<button type="button" class="btn btn-primary row-delete-confirm">Delete row</button>
|
|
</div>
|
|
`;
|
|
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 scheduleCloseRowEditDialogIfConfirmed(state) {
|
|
// Fix for an issue in Safari where hitting Esc would show
|
|
// the confirm() prompt asking if state should be discarded
|
|
// but the Esc key press would then cancel that dialog too.
|
|
// Wait for keyup, then move the confirm() to a fresh timer tick.
|
|
if (!state || state.isSaving || state.isClosePending) {
|
|
return false;
|
|
}
|
|
if (!rowEditDialogHasChanges(state)) {
|
|
state.shouldRestoreFocus = true;
|
|
state.dialog.close();
|
|
return true;
|
|
}
|
|
state.isClosePending = true;
|
|
var closeAfterKeyup = function () {
|
|
if (!state.isClosePending) {
|
|
return;
|
|
}
|
|
state.isClosePending = false;
|
|
closeRowEditDialogIfConfirmed(state);
|
|
};
|
|
var onKeyup = function (ev) {
|
|
if (ev.key !== "Escape") {
|
|
return;
|
|
}
|
|
document.removeEventListener("keyup", onKeyup, true);
|
|
setTimeout(closeAfterKeyup, 0);
|
|
};
|
|
document.addEventListener("keyup", onKeyup, true);
|
|
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 = `
|
|
<div class="modal-header">
|
|
<span class="modal-title" id="row-edit-title">Edit row</span>
|
|
</div>
|
|
<form class="row-edit-form" method="post">
|
|
<p class="row-edit-summary" id="row-edit-summary" hidden></p>
|
|
<p class="row-edit-loading" role="status" aria-live="polite">Loading row...</p>
|
|
<p class="row-edit-error" role="alert" tabindex="-1" hidden></p>
|
|
<div class="row-edit-fields"></div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-ghost row-edit-cancel">Cancel</button>
|
|
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
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,
|
|
isClosePending: 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();
|
|
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
|
});
|
|
|
|
dialog.addEventListener("cancel", function (ev) {
|
|
ev.preventDefault();
|
|
scheduleCloseRowEditDialogIfConfirmed(rowEditDialogState);
|
|
});
|
|
|
|
dialog.addEventListener("close", function () {
|
|
var state = rowEditDialogState;
|
|
state.loadId += 1;
|
|
state.isClosePending = false;
|
|
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);
|
|
});
|