mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 09:14:34 +02:00
Add insert row UI to table pages
Add a permission-gated Insert row button to mutable table pages and expose the metadata needed by the client-side UI, including the insert API path, table name, primary keys, editable columns, defaults, nullability, and column type information. Reuse the existing row edit modal for inserts. Insert submissions now use the JSON API with return=true, derive the new row's tilde-encoded row path from the returned primary key values, fetch the matching table fragment, and insert the rendered row into the current table. Successful inserts and updates now show mutation status messages above the table. Support SQLite defaults in insert forms by showing default expressions as non-editable values with Set value / Use default controls. Keep those controls aligned and stable so toggling between default and custom values does not shift the modal layout. Refine the edit modal at the same time: send only changed fields on update, skip the update API entirely when nothing changed, clear stale mutation status for no-op saves, and simplify modal headings so insert/edit context is shown in the bold title instead of duplicated summary text. Add tests for the insert button and metadata, including omitted integer primary keys, default values, table names, and compound primary keys.
This commit is contained in:
parent
e50d176722
commit
5bf4cf8860
5 changed files with 760 additions and 58 deletions
|
|
@ -1209,6 +1209,21 @@ dialog.set-column-type-dialog::backdrop {
|
|||
background: rgba(208,2,27,0.12);
|
||||
}
|
||||
|
||||
.table-row-toolbar {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
button.table-insert-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
button.table-insert-row svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
|
|
@ -1381,6 +1396,17 @@ dialog.row-edit-dialog::backdrop {
|
|||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title code {
|
||||
display: inline;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
|
|
@ -1401,17 +1427,6 @@ dialog.row-edit-dialog::backdrop {
|
|||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-edit-id {
|
||||
display: inline;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
border-left: 4px solid #b91c1c;
|
||||
border-radius: 4px;
|
||||
|
|
@ -1483,11 +1498,80 @@ textarea.row-edit-input {
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-default {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 7px 8px 7px 10px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-default[hidden],
|
||||
.row-edit-custom-value[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-default-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-default-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.row-edit-custom-value {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 45px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.row-edit-default-button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.row-edit-default-button:hover,
|
||||
.row-edit-default-button:focus {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.row-edit-default-button:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-edit-field-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.row-edit-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
|
|
@ -1757,6 +1841,7 @@ textarea.row-edit-input {
|
|||
width: 140px;
|
||||
}
|
||||
button.choose-columns-mobile,
|
||||
button.table-insert-row,
|
||||
button.column-actions-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1793,6 +1878,15 @@ textarea.row-edit-input {
|
|||
button.choose-columns-mobile {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.table-row-toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
button.table-insert-row {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
svg.dropdown-menu-icon {
|
||||
|
|
|
|||
|
|
@ -389,6 +389,16 @@ function showRowMutationStatus(manager, message, isError) {
|
|||
return status;
|
||||
}
|
||||
|
||||
function hideRowMutationStatus() {
|
||||
var status = document.querySelector(".row-mutation-status");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.hidden = true;
|
||||
status.classList.remove("row-mutation-status-error");
|
||||
status.textContent = "";
|
||||
}
|
||||
|
||||
function setRowDeleteDialogBusy(state, isBusy) {
|
||||
state.isBusy = isBusy;
|
||||
state.confirmButton.disabled = isBusy;
|
||||
|
|
@ -436,6 +446,27 @@ function tildeDecode(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function tildeEncode(value) {
|
||||
var bytes = new TextEncoder().encode(String(value));
|
||||
var encoded = "";
|
||||
bytes.forEach(function (byte) {
|
||||
var isSafe =
|
||||
(byte >= 65 && byte <= 90) ||
|
||||
(byte >= 97 && byte <= 122) ||
|
||||
(byte >= 48 && byte <= 57) ||
|
||||
byte === 95 ||
|
||||
byte === 45;
|
||||
if (isSafe) {
|
||||
encoded += String.fromCharCode(byte);
|
||||
} else if (byte === 32) {
|
||||
encoded += "+";
|
||||
} else {
|
||||
encoded += "~" + byte.toString(16).toUpperCase().padStart(2, "0");
|
||||
}
|
||||
});
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function rowDisplayLabel(row) {
|
||||
return tildeDecode(row.getAttribute("data-row") || "");
|
||||
}
|
||||
|
|
@ -449,6 +480,20 @@ function tableBaseUrl() {
|
|||
return url;
|
||||
}
|
||||
|
||||
function tableInsertData() {
|
||||
return window._datasetteTableData && window._datasetteTableData.insertRow;
|
||||
}
|
||||
|
||||
function tableInsertUrl() {
|
||||
var data = tableInsertData();
|
||||
if (data && data.path) {
|
||||
return new URL(data.path, location.href).toString();
|
||||
}
|
||||
var url = tableBaseUrl();
|
||||
url.pathname = url.pathname.replace(/\/$/, "") + "/-/insert";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function rowResourceUrl(row) {
|
||||
var rowId = row.getAttribute("data-row");
|
||||
if (!rowId) {
|
||||
|
|
@ -489,6 +534,10 @@ function rowUpdateUrl(row) {
|
|||
|
||||
function rowFragmentUrl(row) {
|
||||
var rowId = row.getAttribute("data-row");
|
||||
return rowFragmentUrlById(rowId);
|
||||
}
|
||||
|
||||
function rowFragmentUrlById(rowId) {
|
||||
if (!rowId) {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -748,9 +797,14 @@ function rowEditValueType(value) {
|
|||
return "string";
|
||||
}
|
||||
|
||||
function createRowEditField(column, value, isPk, columnType, index) {
|
||||
function createRowEditField(column, value, isPk, columnType, index, options) {
|
||||
options = options || {};
|
||||
var field = document.createElement("div");
|
||||
field.className = "row-edit-field";
|
||||
var hasDefault =
|
||||
options.hasDefault ||
|
||||
(options.defaultValue !== null && typeof options.defaultValue !== "undefined");
|
||||
var useDefaultInitially = hasDefault && options.useDefaultInitially;
|
||||
|
||||
var fieldId = "row-edit-field-" + index;
|
||||
var metaId = "row-edit-field-meta-" + index;
|
||||
|
|
@ -771,8 +825,16 @@ function createRowEditField(column, value, isPk, columnType, index) {
|
|||
control.value = valueToEditText(value);
|
||||
control.setAttribute("aria-describedby", metaId);
|
||||
control.dataset.originalValue = valueToEditText(value);
|
||||
control.dataset.originalValueType = rowEditValueType(value);
|
||||
control.dataset.originalValueType =
|
||||
options.valueType || rowEditValueType(value);
|
||||
control.dataset.primaryKey = isPk ? "1" : "0";
|
||||
if (useDefaultInitially) {
|
||||
control.dataset.useDefault = "1";
|
||||
control.disabled = true;
|
||||
}
|
||||
if (options.omitIfBlank) {
|
||||
control.dataset.omitIfBlank = "1";
|
||||
}
|
||||
|
||||
if (control.nodeName === "TEXTAREA") {
|
||||
control.rows = Math.min(8, Math.max(3, control.value.split("\n").length));
|
||||
|
|
@ -780,7 +842,7 @@ function createRowEditField(column, value, isPk, columnType, index) {
|
|||
control.type = "text";
|
||||
}
|
||||
|
||||
if (isPk) {
|
||||
if (isPk && options.primaryKeyReadonly !== false) {
|
||||
control.readOnly = true;
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +853,12 @@ function createRowEditField(column, value, isPk, columnType, index) {
|
|||
if (isPk) {
|
||||
metaParts.push("Primary key");
|
||||
}
|
||||
if (options.notnull) {
|
||||
metaParts.push("Required");
|
||||
}
|
||||
if (hasDefault && !useDefaultInitially) {
|
||||
metaParts.push("Default: " + options.defaultValue);
|
||||
}
|
||||
if (value === null) {
|
||||
metaParts.push("Current value: NULL");
|
||||
control.placeholder = "NULL";
|
||||
|
|
@ -800,7 +868,62 @@ function createRowEditField(column, value, isPk, columnType, index) {
|
|||
}
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
|
||||
controlWrap.appendChild(control);
|
||||
if (useDefaultInitially) {
|
||||
var defaultBlock = document.createElement("div");
|
||||
defaultBlock.className = "row-edit-default";
|
||||
defaultBlock.setAttribute("aria-describedby", metaId);
|
||||
|
||||
var defaultText = document.createElement("span");
|
||||
defaultText.className = "row-edit-default-text";
|
||||
defaultText.appendChild(document.createTextNode("default "));
|
||||
var defaultCode = document.createElement("code");
|
||||
defaultCode.className = "row-edit-default-code";
|
||||
defaultCode.textContent = options.defaultValue;
|
||||
defaultText.appendChild(defaultCode);
|
||||
|
||||
var setValueButton = document.createElement("button");
|
||||
setValueButton.type = "button";
|
||||
setValueButton.className =
|
||||
"row-edit-default-button row-edit-default-set-value";
|
||||
setValueButton.textContent = "Set value";
|
||||
setValueButton.setAttribute("aria-label", "Set value for " + column);
|
||||
|
||||
var customWrap = document.createElement("div");
|
||||
customWrap.className = "row-edit-custom-value";
|
||||
customWrap.hidden = true;
|
||||
|
||||
var useDefaultButton = document.createElement("button");
|
||||
useDefaultButton.type = "button";
|
||||
useDefaultButton.className = "row-edit-default-button";
|
||||
useDefaultButton.textContent = "Use default";
|
||||
useDefaultButton.setAttribute("aria-label", "Use default for " + column);
|
||||
|
||||
setValueButton.addEventListener("click", function () {
|
||||
control.dataset.useDefault = "0";
|
||||
control.disabled = false;
|
||||
defaultBlock.hidden = true;
|
||||
customWrap.hidden = false;
|
||||
control.focus();
|
||||
});
|
||||
|
||||
useDefaultButton.addEventListener("click", function () {
|
||||
control.dataset.useDefault = "1";
|
||||
control.disabled = true;
|
||||
control.value = "";
|
||||
customWrap.hidden = true;
|
||||
defaultBlock.hidden = false;
|
||||
setValueButton.focus();
|
||||
});
|
||||
|
||||
defaultBlock.appendChild(defaultText);
|
||||
defaultBlock.appendChild(setValueButton);
|
||||
customWrap.appendChild(control);
|
||||
customWrap.appendChild(useDefaultButton);
|
||||
controlWrap.appendChild(defaultBlock);
|
||||
controlWrap.appendChild(customWrap);
|
||||
} else {
|
||||
controlWrap.appendChild(control);
|
||||
}
|
||||
if (meta.textContent) {
|
||||
controlWrap.appendChild(meta);
|
||||
}
|
||||
|
|
@ -823,7 +946,8 @@ function showRowEditDialogError(state, message) {
|
|||
function updateRowEditDialogButtons(state) {
|
||||
state.saveButton.disabled = state.isLoading || state.isSaving || !state.hasLoaded;
|
||||
state.cancelButton.disabled = state.isSaving;
|
||||
state.saveButton.textContent = state.isSaving ? "Saving..." : "Save";
|
||||
var saveLabel = state.mode === "insert" ? "Insert row" : "Save";
|
||||
state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel;
|
||||
state.form.setAttribute(
|
||||
"aria-busy",
|
||||
state.isLoading || state.isSaving ? "true" : "false",
|
||||
|
|
@ -843,8 +967,15 @@ function setRowEditDialogSaving(state, isSaving) {
|
|||
|
||||
function valueFromRowEditControl(control) {
|
||||
var value = control.value;
|
||||
return valueFromRowEditText(
|
||||
control.name,
|
||||
value,
|
||||
control.dataset.originalValueType || "string",
|
||||
);
|
||||
}
|
||||
|
||||
function valueFromRowEditText(name, value, originalValueType) {
|
||||
var trimmed = value.trim();
|
||||
var originalValueType = control.dataset.originalValueType || "string";
|
||||
|
||||
if (originalValueType === "null" && value === "") {
|
||||
return null;
|
||||
|
|
@ -855,7 +986,7 @@ function valueFromRowEditControl(control) {
|
|||
}
|
||||
var numberValue = Number(trimmed);
|
||||
if (Number.isNaN(numberValue)) {
|
||||
throw new Error(control.name + " must be a number");
|
||||
throw new Error(name + " must be a number");
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
|
|
@ -866,7 +997,7 @@ function valueFromRowEditControl(control) {
|
|||
if (/^(false|0|no)$/i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(control.name + " must be true or false");
|
||||
throw new Error(name + " must be true or false");
|
||||
}
|
||||
if (originalValueType === "json") {
|
||||
if (trimmed === "") {
|
||||
|
|
@ -875,21 +1006,60 @@ function valueFromRowEditControl(control) {
|
|||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
throw new Error(control.name + " must be valid JSON");
|
||||
throw new Error(name + " must be valid JSON");
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectRowEditUpdate(state) {
|
||||
var update = {};
|
||||
function originalValueFromRowEditControl(control) {
|
||||
return valueFromRowEditText(
|
||||
control.name,
|
||||
control.dataset.originalValue || "",
|
||||
control.dataset.originalValueType || "string",
|
||||
);
|
||||
}
|
||||
|
||||
function rowEditValuesMatch(left, right) {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
left &&
|
||||
right &&
|
||||
typeof left === "object" &&
|
||||
typeof right === "object"
|
||||
) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectRowFormValues(state) {
|
||||
var values = {};
|
||||
state.fields.querySelectorAll(".row-edit-input").forEach(function (control) {
|
||||
if (control.readOnly || control.dataset.primaryKey === "1") {
|
||||
if (
|
||||
state.mode === "edit" &&
|
||||
(control.readOnly || control.dataset.primaryKey === "1")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
update[control.name] = valueFromRowEditControl(control);
|
||||
if (control.dataset.useDefault === "1") {
|
||||
return;
|
||||
}
|
||||
if (control.dataset.omitIfBlank === "1" && control.value === "") {
|
||||
return;
|
||||
}
|
||||
var value = valueFromRowEditControl(control);
|
||||
if (
|
||||
state.mode === "edit" &&
|
||||
rowEditValuesMatch(value, originalValueFromRowEditControl(control))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
values[control.name] = value;
|
||||
});
|
||||
return update;
|
||||
return values;
|
||||
}
|
||||
|
||||
function findDataRowElement(root, rowId) {
|
||||
|
|
@ -919,6 +1089,41 @@ async function fetchUpdatedRowElement(state) {
|
|||
return findDataRowElement(doc, state.currentRowId);
|
||||
}
|
||||
|
||||
function rowPathFromRowData(row, primaryKeys) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
var keys = primaryKeys && primaryKeys.length ? primaryKeys : ["rowid"];
|
||||
var bits = [];
|
||||
for (var i = 0; i < keys.length; i += 1) {
|
||||
var key = keys[i];
|
||||
if (typeof row[key] === "undefined") {
|
||||
return null;
|
||||
}
|
||||
bits.push(tildeEncode(row[key]));
|
||||
}
|
||||
return bits.join(",");
|
||||
}
|
||||
|
||||
function addInsertedRowToPage(rowElement) {
|
||||
var importedRow = document.importNode(rowElement, true);
|
||||
var firstRow = document.querySelector("[data-row]");
|
||||
if (firstRow && firstRow.parentNode) {
|
||||
firstRow.parentNode.insertBefore(importedRow, firstRow);
|
||||
} else {
|
||||
var tbody = document.querySelector("table.rows-and-columns tbody");
|
||||
if (!tbody) {
|
||||
return null;
|
||||
}
|
||||
tbody.appendChild(importedRow);
|
||||
}
|
||||
var zeroResults = document.querySelector(".zero-results");
|
||||
if (zeroResults) {
|
||||
zeroResults.remove();
|
||||
}
|
||||
return importedRow;
|
||||
}
|
||||
|
||||
async function saveRowEditDialog(state) {
|
||||
if (state.isLoading || state.isSaving || !state.hasLoaded) {
|
||||
return;
|
||||
|
|
@ -927,19 +1132,32 @@ async function saveRowEditDialog(state) {
|
|||
setRowEditDialogSaving(state, true);
|
||||
|
||||
try {
|
||||
if (!state.currentUpdateUrl) {
|
||||
throw new Error("Could not find the row update URL");
|
||||
var url = state.mode === "insert" ? state.currentInsertUrl : state.currentUpdateUrl;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
state.mode === "insert"
|
||||
? "Could not find the row insert URL"
|
||||
: "Could not find the row update URL",
|
||||
);
|
||||
}
|
||||
var response = await fetch(state.currentUpdateUrl, {
|
||||
var formValues = collectRowFormValues(state);
|
||||
if (state.mode === "edit" && !Object.keys(formValues).length) {
|
||||
state.shouldRestoreFocus = true;
|
||||
hideRowMutationStatus();
|
||||
state.dialog.close();
|
||||
return;
|
||||
}
|
||||
var payload =
|
||||
state.mode === "insert"
|
||||
? { row: formValues, return: true }
|
||||
: { update: formValues, return: true };
|
||||
var response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update: collectRowEditUpdate(state),
|
||||
return: true,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
var data = null;
|
||||
try {
|
||||
|
|
@ -951,11 +1169,75 @@ async function saveRowEditDialog(state) {
|
|||
throw rowMutationRequestError(response, data);
|
||||
}
|
||||
|
||||
if (state.mode === "insert") {
|
||||
var insertData = tableInsertData() || {};
|
||||
var insertedRowData = data && data.rows && data.rows.length ? data.rows[0] : null;
|
||||
var insertedRowId = rowPathFromRowData(
|
||||
insertedRowData,
|
||||
insertData.primaryKeys || [],
|
||||
);
|
||||
state.shouldRestoreFocus = false;
|
||||
if (!insertedRowId) {
|
||||
state.dialog.close();
|
||||
var missingIdStatus = showRowMutationStatus(
|
||||
state.manager,
|
||||
"Inserted row. Refresh the page to see it.",
|
||||
false,
|
||||
);
|
||||
missingIdStatus.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentRowId = insertedRowId;
|
||||
state.currentFragmentUrl = rowFragmentUrlById(insertedRowId);
|
||||
var insertedStatusMessage =
|
||||
"Inserted row " + tildeDecode(insertedRowId) + ".";
|
||||
var insertedRow = null;
|
||||
try {
|
||||
insertedRow = await fetchUpdatedRowElement(state);
|
||||
} catch (_error) {
|
||||
state.dialog.close();
|
||||
var refreshFailedStatus = showRowMutationStatus(
|
||||
state.manager,
|
||||
"Inserted row, but could not refresh the table row. Refresh the page to see it.",
|
||||
true,
|
||||
);
|
||||
refreshFailedStatus.focus();
|
||||
return;
|
||||
}
|
||||
if (insertedRow) {
|
||||
var addedRow = addInsertedRowToPage(insertedRow);
|
||||
state.dialog.close();
|
||||
showRowMutationStatus(state.manager, insertedStatusMessage, false);
|
||||
if (addedRow) {
|
||||
var insertedFocusTarget =
|
||||
addedRow.querySelector('button[data-row-action="edit"]') || addedRow;
|
||||
insertedFocusTarget.focus();
|
||||
}
|
||||
} else {
|
||||
state.dialog.close();
|
||||
var filteredStatus = showRowMutationStatus(
|
||||
state.manager,
|
||||
"Inserted row. It does not match the current filters.",
|
||||
false,
|
||||
);
|
||||
filteredStatus.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var updatedRow = await fetchUpdatedRowElement(state);
|
||||
var focusTarget = null;
|
||||
if (updatedRow && state.currentRow && document.contains(state.currentRow)) {
|
||||
var importedRow = document.importNode(updatedRow, true);
|
||||
state.currentRow.replaceWith(importedRow);
|
||||
showRowMutationStatus(
|
||||
state.manager,
|
||||
state.currentPkPath
|
||||
? "Updated row " + state.currentPkPath + "."
|
||||
: "Updated row.",
|
||||
false,
|
||||
);
|
||||
focusTarget =
|
||||
importedRow.querySelector('button[data-row-action="edit"]') || importedRow;
|
||||
} else if (state.currentRow && document.contains(state.currentRow)) {
|
||||
|
|
@ -965,7 +1247,11 @@ async function saveRowEditDialog(state) {
|
|||
state.currentRow.remove();
|
||||
showRowMutationStatus(
|
||||
state.manager,
|
||||
"Saved row. It no longer matches the current filters.",
|
||||
state.currentPkPath
|
||||
? "Updated row " +
|
||||
state.currentPkPath +
|
||||
". It no longer matches the current filters."
|
||||
: "Updated row. It no longer matches the current filters.",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
|
@ -996,6 +1282,9 @@ function renderRowEditFields(state, data) {
|
|||
primaryKeys.indexOf(column) !== -1,
|
||||
columnTypes[column],
|
||||
index,
|
||||
{
|
||||
primaryKeyReadonly: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -1007,6 +1296,57 @@ function renderRowEditFields(state, data) {
|
|||
(firstEditable || firstField || state.cancelButton).focus();
|
||||
}
|
||||
|
||||
function renderRowInsertFields(state, data) {
|
||||
var columns = data.columns || [];
|
||||
|
||||
state.fields.innerHTML = "";
|
||||
columns.forEach(function (column, index) {
|
||||
state.fields.appendChild(
|
||||
createRowEditField(
|
||||
column.name,
|
||||
"",
|
||||
!!column.is_pk,
|
||||
column.column_type,
|
||||
index,
|
||||
{
|
||||
defaultValue: column.default,
|
||||
hasDefault: column.has_default,
|
||||
notnull: column.notnull,
|
||||
primaryKeyReadonly: false,
|
||||
useDefaultInitially: column.has_default,
|
||||
valueType: column.value_type,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (!columns.length) {
|
||||
var emptyMessage = document.createElement("p");
|
||||
emptyMessage.className = "row-edit-empty";
|
||||
emptyMessage.textContent = "This row will use the table defaults.";
|
||||
state.fields.appendChild(emptyMessage);
|
||||
}
|
||||
|
||||
state.hasLoaded = true;
|
||||
updateRowEditDialogButtons(state);
|
||||
var firstControl = state.fields.querySelector(
|
||||
".row-edit-default-set-value, .row-edit-input:not(:disabled)",
|
||||
);
|
||||
(firstControl || state.saveButton).focus();
|
||||
}
|
||||
|
||||
function setRowEditDialogTitle(state, text, codeText) {
|
||||
state.title.textContent = "";
|
||||
state.title.appendChild(document.createTextNode(text));
|
||||
if (!codeText) {
|
||||
return;
|
||||
}
|
||||
state.title.appendChild(document.createTextNode(" "));
|
||||
var code = document.createElement("code");
|
||||
code.textContent = codeText;
|
||||
state.title.appendChild(code);
|
||||
}
|
||||
|
||||
function ensureRowEditDialog(manager) {
|
||||
if (rowEditDialogState) {
|
||||
return rowEditDialogState;
|
||||
|
|
@ -1019,13 +1359,12 @@ function ensureRowEditDialog(manager) {
|
|||
dialog.id = ROW_EDIT_DIALOG_ID;
|
||||
dialog.className = "row-edit-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "row-edit-title");
|
||||
dialog.setAttribute("aria-describedby", "row-edit-summary");
|
||||
dialog.innerHTML = `
|
||||
<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">Editing row <span class="row-edit-id"></span></p>
|
||||
<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>
|
||||
|
|
@ -1040,7 +1379,8 @@ function ensureRowEditDialog(manager) {
|
|||
rowEditDialogState = {
|
||||
dialog: dialog,
|
||||
form: dialog.querySelector(".row-edit-form"),
|
||||
rowId: dialog.querySelector(".row-edit-id"),
|
||||
title: dialog.querySelector(".modal-title"),
|
||||
summary: dialog.querySelector(".row-edit-summary"),
|
||||
loading: dialog.querySelector(".row-edit-loading"),
|
||||
error: dialog.querySelector(".row-edit-error"),
|
||||
fields: dialog.querySelector(".row-edit-fields"),
|
||||
|
|
@ -1050,8 +1390,10 @@ function ensureRowEditDialog(manager) {
|
|||
currentRow: null,
|
||||
currentRowId: null,
|
||||
currentPkPath: null,
|
||||
currentInsertUrl: null,
|
||||
currentUpdateUrl: null,
|
||||
currentFragmentUrl: null,
|
||||
mode: "edit",
|
||||
loadId: 0,
|
||||
manager: manager,
|
||||
isLoading: false,
|
||||
|
|
@ -1130,10 +1472,12 @@ async function openRowEditDialog(button, manager) {
|
|||
}
|
||||
|
||||
state.manager = manager;
|
||||
state.mode = "edit";
|
||||
state.currentButton = button;
|
||||
state.currentRow = row;
|
||||
state.currentRowId = row.getAttribute("data-row") || "";
|
||||
state.currentPkPath = rowDisplayLabel(row);
|
||||
state.currentInsertUrl = null;
|
||||
state.currentUpdateUrl = rowUpdateUrl(row);
|
||||
state.currentFragmentUrl = rowFragmentUrl(row);
|
||||
if (state.currentUpdateUrl) {
|
||||
|
|
@ -1149,7 +1493,10 @@ async function openRowEditDialog(button, manager) {
|
|||
clearRowEditDialogError(state);
|
||||
setRowEditDialogLoading(state, true);
|
||||
state.fields.innerHTML = "";
|
||||
state.rowId.textContent = state.currentPkPath || "this row";
|
||||
state.dialog.removeAttribute("aria-describedby");
|
||||
setRowEditDialogTitle(state, "Edit row", state.currentPkPath || "this row");
|
||||
state.summary.hidden = true;
|
||||
state.summary.textContent = "";
|
||||
|
||||
if (!state.dialog.open) {
|
||||
state.dialog.showModal();
|
||||
|
|
@ -1181,6 +1528,52 @@ async function openRowEditDialog(button, manager) {
|
|||
}
|
||||
}
|
||||
|
||||
function openRowInsertDialog(button, manager) {
|
||||
var insertData = tableInsertData();
|
||||
if (!insertData) {
|
||||
return;
|
||||
}
|
||||
var state = ensureRowEditDialog(manager);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.manager = manager;
|
||||
state.mode = "insert";
|
||||
state.currentButton = button;
|
||||
state.currentRow = null;
|
||||
state.currentRowId = null;
|
||||
state.currentPkPath = null;
|
||||
state.currentInsertUrl = tableInsertUrl();
|
||||
state.currentUpdateUrl = null;
|
||||
state.currentFragmentUrl = null;
|
||||
state.shouldRestoreFocus = true;
|
||||
state.hasLoaded = false;
|
||||
state.loadId += 1;
|
||||
|
||||
if (state.currentInsertUrl) {
|
||||
state.form.action = new URL(state.currentInsertUrl, location.href).toString();
|
||||
} else {
|
||||
state.form.removeAttribute("action");
|
||||
}
|
||||
|
||||
clearRowEditDialogError(state);
|
||||
setRowEditDialogLoading(state, false);
|
||||
state.fields.innerHTML = "";
|
||||
state.dialog.removeAttribute("aria-describedby");
|
||||
setRowEditDialogTitle(
|
||||
state,
|
||||
insertData.tableName ? "Insert row into " + insertData.tableName : "Insert row",
|
||||
);
|
||||
state.summary.hidden = true;
|
||||
state.summary.textContent = "";
|
||||
|
||||
if (!state.dialog.open) {
|
||||
state.dialog.showModal();
|
||||
}
|
||||
renderRowInsertFields(state, insertData);
|
||||
}
|
||||
|
||||
function initRowEditActions(manager) {
|
||||
if (!window.fetch || !window.HTMLDialogElement) {
|
||||
return;
|
||||
|
|
@ -1195,6 +1588,20 @@ function initRowEditActions(manager) {
|
|||
});
|
||||
}
|
||||
|
||||
function initRowInsertActions(manager) {
|
||||
if (!window.fetch || !window.HTMLDialogElement || !tableInsertData()) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener("click", function (ev) {
|
||||
var button = ev.target.closest('button[data-table-action="insert-row"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
openRowInsertDialog(button, manager);
|
||||
});
|
||||
}
|
||||
|
||||
function canChooseColumns() {
|
||||
return !!(
|
||||
document.querySelector("column-chooser") && window._columnChooserData
|
||||
|
|
@ -1590,6 +1997,7 @@ document.addEventListener("datasette_init", function (evt) {
|
|||
|
||||
// Main table
|
||||
initDatasetteTable(manager);
|
||||
initRowInsertActions(manager);
|
||||
initRowEditActions(manager);
|
||||
initRowDeleteActions(manager);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% if table_insert_ui %}
|
||||
<script>window._datasetteTableData = {{ {"tableUrl": urls.table(database, table), "insertRow": table_insert_ui}|tojson }};</script>
|
||||
{% else %}
|
||||
<script>window._datasetteTableData = {{ {"tableUrl": urls.table(database, table)}|tojson }};</script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
|
|
@ -159,6 +163,19 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
|||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if table_insert_ui %}
|
||||
<div class="table-row-toolbar">
|
||||
<button type="button" class="core table-insert-row" data-table-action="insert-row">
|
||||
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
||||
<path d="M8 12h8"></path>
|
||||
<path d="M12 8v8"></path>
|
||||
</svg>
|
||||
<span>Insert row</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if next_url %}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import urllib.parse
|
|||
|
||||
import markupsafe
|
||||
|
||||
from datasette.column_types import SQLiteType
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.plugins import pm
|
||||
from datasette.events import (
|
||||
|
|
@ -223,6 +224,68 @@ async def _validate_column_types(datasette, database_name, table_name, rows):
|
|||
return errors
|
||||
|
||||
|
||||
def _column_value_type_for_insert_form(column_detail, column_type):
|
||||
if column_type is not None and column_type.name == "json":
|
||||
return "json"
|
||||
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
||||
if sqlite_type in (SQLiteType.INTEGER, SQLiteType.REAL):
|
||||
return "number"
|
||||
return "string"
|
||||
|
||||
|
||||
async def _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
):
|
||||
if is_view or not db.is_mutable:
|
||||
return None
|
||||
|
||||
if not await datasette.allowed(
|
||||
action="insert-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return None
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
columns = []
|
||||
column_details = await db.table_column_details(table_name)
|
||||
for column in column_details:
|
||||
if column.hidden:
|
||||
continue
|
||||
is_pk = column.name in pks
|
||||
is_auto_pk = (
|
||||
is_pk
|
||||
and len(pks) == 1
|
||||
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
|
||||
)
|
||||
if is_auto_pk:
|
||||
continue
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"type": column.type,
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"value_type": _column_value_type_for_insert_form(column, column_type),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"primaryKeys": pks,
|
||||
}
|
||||
|
||||
|
||||
async def display_columns_and_rows(
|
||||
datasette,
|
||||
database_name,
|
||||
|
|
@ -1753,6 +1816,9 @@ async def table_view_data(
|
|||
sort = "rowid"
|
||||
data["sort"] = sort
|
||||
data["sort_desc"] = sort_desc
|
||||
data["table_insert_ui"] = await _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
|
||||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,21 @@ import urllib.parse
|
|||
from .utils import inner_html
|
||||
|
||||
|
||||
def table_data_from_soup(soup):
|
||||
import json
|
||||
import re
|
||||
|
||||
table_script = [
|
||||
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
|
||||
][0]
|
||||
match = re.search(
|
||||
r"window\._datasetteTableData\s*=\s*({.*?});",
|
||||
table_script.string,
|
||||
re.DOTALL,
|
||||
)
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_definition_sql",
|
||||
|
|
@ -864,24 +879,12 @@ async def test_row_delete_action_data_attributes():
|
|||
response = await ds.client.get("/data/items", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
import json
|
||||
import re
|
||||
|
||||
table_script = [
|
||||
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
|
||||
][0]
|
||||
match = re.search(
|
||||
r"window\._datasetteTableData\s*=\s*({.*?});",
|
||||
table_script.string,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert json.loads(match.group(1)) == {"tableUrl": "/data/items"}
|
||||
assert table_data_from_soup(soup) == {"tableUrl": "/data/items"}
|
||||
assert soup.select_one('button[data-table-action="insert-row"]') is None
|
||||
|
||||
row = soup.select_one("table.rows-and-columns tbody tr")
|
||||
assert row["data-row"] == "1"
|
||||
assert {
|
||||
key for key in row.attrs if key.startswith("data-row")
|
||||
} == {"data-row"}
|
||||
assert {key for key in row.attrs if key.startswith("data-row")} == {"data-row"}
|
||||
|
||||
edit_button = row.select_one(
|
||||
'button.row-inline-action-edit[data-row-action="edit"]'
|
||||
|
|
@ -902,6 +905,124 @@ async def test_row_delete_action_data_attributes():
|
|||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_insert_action_button_and_data():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"insert-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_insert_action"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (
|
||||
id integer primary key,
|
||||
name text not null,
|
||||
score integer default 5,
|
||||
created text default (datetime('now')),
|
||||
body text
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/items", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
|
||||
button = soup.select_one(
|
||||
'button.table-insert-row[data-table-action="insert-row"]'
|
||||
)
|
||||
assert button is not None
|
||||
assert button.text.strip() == "Insert row"
|
||||
assert button.find("svg") is not None
|
||||
assert button.find_parent("div", class_="table-row-toolbar") is not None
|
||||
|
||||
insert_data = table_data_from_soup(soup)["insertRow"]
|
||||
assert insert_data["path"] == "/data/items/-/insert"
|
||||
assert insert_data["tableName"] == "items"
|
||||
assert insert_data["primaryKeys"] == ["id"]
|
||||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"name",
|
||||
"score",
|
||||
"created",
|
||||
"body",
|
||||
]
|
||||
name, score, created, body = insert_data["columns"]
|
||||
assert name["notnull"] == 1
|
||||
assert name["value_type"] == "string"
|
||||
assert not name["has_default"]
|
||||
assert score["default"] == "5"
|
||||
assert score["has_default"]
|
||||
assert score["value_type"] == "number"
|
||||
assert created["default"] == "datetime('now')"
|
||||
assert created["has_default"]
|
||||
assert body["value_type"] == "string"
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_insert_action_includes_compound_primary_keys():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"memberships": {
|
||||
"permissions": {
|
||||
"insert-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_insert_compound_pk"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table memberships (
|
||||
account text,
|
||||
username text,
|
||||
role text,
|
||||
primary key (account, username)
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/memberships", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[
|
||||
"insertRow"
|
||||
]
|
||||
assert insert_data["tableName"] == "memberships"
|
||||
assert insert_data["primaryKeys"] == ["account", "username"]
|
||||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"account",
|
||||
"username",
|
||||
"role",
|
||||
]
|
||||
assert [column["is_pk"] for column in insert_data["columns"]] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_endpoint(ds_client):
|
||||
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
|
||||
|
|
@ -912,9 +1033,7 @@ async def test_table_fragment_endpoint(ds_client):
|
|||
rows = soup.select("[data-row]")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["data-row"] == "1"
|
||||
assert {
|
||||
key for key in rows[0].attrs if key.startswith("data-row")
|
||||
} == {"data-row"}
|
||||
assert {key for key in rows[0].attrs if key.startswith("data-row")} == {"data-row"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -979,9 +1098,7 @@ async def test_table_fragment_uses_render_cell_hook():
|
|||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
db = ds.add_memory_database("data")
|
||||
await db.execute_write(
|
||||
"create table items (id integer primary key, name text)"
|
||||
)
|
||||
await db.execute_write("create table items (id integer primary key, name text)")
|
||||
await db.execute_write("insert into items values (1, 'Alice')")
|
||||
ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin")
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue