mirror of
https://github.com/simonw/datasette.git
synced 2026-06-22 16:54:34 +02:00
Add in-place table row edit and delete UI
Use a compact data-row attribute on table row fragments and derive row API URLs in JavaScript from a page-level table URL. Add a /-/fragment endpoint so edited rows can be re-rendered with the active table template and render_cell hooks, then replaced in place after a successful save. Document the custom _table.html data-row contract and cover the fragment endpoint, base_url handling, and row markup with tests.
This commit is contained in:
parent
ad3456dc4a
commit
e50d176722
8 changed files with 571 additions and 71 deletions
|
|
@ -86,6 +86,7 @@ from .views.table import (
|
|||
TableUpsertView,
|
||||
TableSetColumnTypeView,
|
||||
TableDropView,
|
||||
TableFragmentView,
|
||||
table_view,
|
||||
)
|
||||
from .views.row import RowView, RowDeleteView, RowUpdateView
|
||||
|
|
@ -2614,6 +2615,10 @@ class Datasette:
|
|||
TableSetColumnTypeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||
)
|
||||
add_route(
|
||||
TableFragmentView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
||||
)
|
||||
add_route(
|
||||
TableDropView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
||||
|
|
|
|||
|
|
@ -1192,7 +1192,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
cursor: wait;
|
||||
}
|
||||
|
||||
.row-delete-status {
|
||||
.row-mutation-status {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 8px 10px;
|
||||
border-left: 4px solid #54AC8E;
|
||||
|
|
@ -1200,11 +1200,11 @@ dialog.set-column-type-dialog::backdrop {
|
|||
color: #222;
|
||||
}
|
||||
|
||||
.row-delete-status[hidden] {
|
||||
.row-mutation-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-delete-status-error {
|
||||
.row-mutation-status-error {
|
||||
border-left-color: #D0021B;
|
||||
background: rgba(208,2,27,0.12);
|
||||
}
|
||||
|
|
@ -1413,8 +1413,18 @@ dialog.row-edit-dialog::backdrop {
|
|||
}
|
||||
|
||||
.row-edit-error {
|
||||
color: #b91c1c;
|
||||
border-left: 4px solid #b91c1c;
|
||||
border-radius: 4px;
|
||||
background: #fff1f1;
|
||||
color: #7f1d1d;
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 24px 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.row-edit-error:focus {
|
||||
outline: 3px solid rgba(185, 28, 28, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.row-edit-fields {
|
||||
|
|
@ -1645,12 +1655,16 @@ textarea.row-edit-input {
|
|||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-error,
|
||||
.row-edit-fields {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
margin-left: 18px;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
|
|
|
|||
|
|
@ -359,14 +359,14 @@ function openSetColumnTypeDialog(th) {
|
|||
}
|
||||
}
|
||||
|
||||
function ensureRowDeleteStatus(manager) {
|
||||
var status = document.querySelector(".row-delete-status");
|
||||
function ensureRowMutationStatus(manager) {
|
||||
var status = document.querySelector(".row-mutation-status");
|
||||
if (status) {
|
||||
return status;
|
||||
}
|
||||
|
||||
status = document.createElement("p");
|
||||
status.className = "row-delete-status";
|
||||
status.className = "row-mutation-status";
|
||||
status.hidden = true;
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
|
|
@ -381,10 +381,10 @@ function ensureRowDeleteStatus(manager) {
|
|||
return status;
|
||||
}
|
||||
|
||||
function showRowDeleteStatus(manager, message, isError) {
|
||||
var status = ensureRowDeleteStatus(manager);
|
||||
function showRowMutationStatus(manager, message, isError) {
|
||||
var status = ensureRowMutationStatus(manager);
|
||||
status.hidden = false;
|
||||
status.classList.toggle("row-delete-status-error", !!isError);
|
||||
status.classList.toggle("row-mutation-status-error", !!isError);
|
||||
status.textContent = message;
|
||||
return status;
|
||||
}
|
||||
|
|
@ -406,7 +406,7 @@ function showRowDeleteDialogError(state, message) {
|
|||
state.error.textContent = message;
|
||||
}
|
||||
|
||||
function rowDeleteRequestError(response, data) {
|
||||
function rowMutationRequestError(response, data) {
|
||||
if (data && data.errors) {
|
||||
return new Error(data.errors.join(" "));
|
||||
}
|
||||
|
|
@ -416,15 +416,98 @@ function rowDeleteRequestError(response, data) {
|
|||
if (data && data.title) {
|
||||
return new Error(data.title);
|
||||
}
|
||||
return new Error("Delete failed with HTTP " + response.status);
|
||||
return new Error("Request failed with HTTP " + response.status);
|
||||
}
|
||||
|
||||
function nextRowDeleteFocusTarget(row, manager) {
|
||||
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 rowDisplayLabel(row) {
|
||||
return tildeDecode(row.getAttribute("data-row") || "");
|
||||
}
|
||||
|
||||
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 rowResourceUrl(row) {
|
||||
var rowId = row.getAttribute("data-row");
|
||||
if (!rowId) {
|
||||
return null;
|
||||
}
|
||||
var url = tableBaseUrl();
|
||||
url.pathname = url.pathname.replace(/\/$/, "") + "/" + rowId;
|
||||
return url;
|
||||
}
|
||||
|
||||
function rowJsonUrl(row) {
|
||||
var url = rowResourceUrl(row);
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname + ".json";
|
||||
url.searchParams.set("_extra", "columns,column_types");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function rowDeleteUrl(row) {
|
||||
var url = rowResourceUrl(row);
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "") + "/-/delete";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function rowUpdateUrl(row) {
|
||||
var url = rowResourceUrl(row);
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "") + "/-/update";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function rowFragmentUrl(row) {
|
||||
var rowId = row.getAttribute("data-row");
|
||||
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(
|
||||
'button[data-row-action="delete"]:not([disabled])',
|
||||
);
|
||||
var nextButton = sibling.querySelector(selector);
|
||||
if (nextButton) {
|
||||
return nextButton;
|
||||
}
|
||||
|
|
@ -433,16 +516,18 @@ function nextRowDeleteFocusTarget(row, manager) {
|
|||
|
||||
sibling = row.previousElementSibling;
|
||||
while (sibling) {
|
||||
var previousButton = sibling.querySelector(
|
||||
'button[data-row-action="delete"]:not([disabled])',
|
||||
);
|
||||
var previousButton = sibling.querySelector(selector);
|
||||
if (previousButton) {
|
||||
return previousButton;
|
||||
}
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
|
||||
return ensureRowDeleteStatus(manager);
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextRowDeleteFocusTarget(row, manager) {
|
||||
return nextRowActionFocusTarget(row, "delete") || ensureRowMutationStatus(manager);
|
||||
}
|
||||
|
||||
function ensureRowDeleteDialog(manager) {
|
||||
|
|
@ -563,7 +648,7 @@ function ensureRowDeleteDialog(manager) {
|
|||
data = null;
|
||||
}
|
||||
if (!response.ok || (data && data.ok === false)) {
|
||||
throw rowDeleteRequestError(response, data);
|
||||
throw rowMutationRequestError(response, data);
|
||||
}
|
||||
|
||||
var focusTarget = nextRowDeleteFocusTarget(state.currentRow, state.manager);
|
||||
|
|
@ -573,11 +658,11 @@ function ensureRowDeleteDialog(manager) {
|
|||
state.shouldRestoreFocus = false;
|
||||
state.dialog.close();
|
||||
state.currentRow.remove();
|
||||
showRowDeleteStatus(state.manager, statusMessage, false);
|
||||
showRowMutationStatus(state.manager, statusMessage, false);
|
||||
if (focusTarget && document.contains(focusTarget)) {
|
||||
focusTarget.focus();
|
||||
} else {
|
||||
ensureRowDeleteStatus(state.manager).focus();
|
||||
ensureRowMutationStatus(state.manager).focus();
|
||||
}
|
||||
} catch (error) {
|
||||
setRowDeleteDialogBusy(state, false);
|
||||
|
|
@ -589,8 +674,8 @@ function ensureRowDeleteDialog(manager) {
|
|||
}
|
||||
|
||||
function openRowDeleteDialog(button, manager) {
|
||||
var row = button.closest("tr[data-row-delete-url]");
|
||||
if (!row || !row.dataset.rowDeleteUrl) {
|
||||
var row = button.closest("[data-row]");
|
||||
if (!row || !row.getAttribute("data-row")) {
|
||||
return;
|
||||
}
|
||||
var state = ensureRowDeleteDialog(manager);
|
||||
|
|
@ -601,8 +686,8 @@ function openRowDeleteDialog(button, manager) {
|
|||
state.manager = manager;
|
||||
state.currentButton = button;
|
||||
state.currentRow = row;
|
||||
state.currentDeleteUrl = row.dataset.rowDeleteUrl;
|
||||
state.currentPkPath = row.dataset.rowPkPath || "";
|
||||
state.currentDeleteUrl = rowDeleteUrl(row);
|
||||
state.currentPkPath = rowDisplayLabel(row);
|
||||
state.shouldRestoreFocus = true;
|
||||
|
||||
clearRowDeleteDialogError(state);
|
||||
|
|
@ -629,13 +714,6 @@ function initRowDeleteActions(manager) {
|
|||
});
|
||||
}
|
||||
|
||||
function rowJsonUrl(row) {
|
||||
var url = new URL(row.dataset.rowUrl, location.href);
|
||||
url.pathname = url.pathname + ".json";
|
||||
url.searchParams.set("_extra", "columns,column_types");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function valueToEditText(value) {
|
||||
if (value === null || typeof value === "undefined") {
|
||||
return "";
|
||||
|
|
@ -654,6 +732,22 @@ function shouldUseTextarea(value) {
|
|||
return text.length > 80 || text.indexOf("\n") !== -1;
|
||||
}
|
||||
|
||||
function rowEditValueType(value) {
|
||||
if (value === null || typeof value === "undefined") {
|
||||
return "null";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return "number";
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return "boolean";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return "json";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
function createRowEditField(column, value, isPk, columnType, index) {
|
||||
var field = document.createElement("div");
|
||||
field.className = "row-edit-field";
|
||||
|
|
@ -677,6 +771,8 @@ 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.primaryKey = isPk ? "1" : "0";
|
||||
|
||||
if (control.nodeName === "TEXTAREA") {
|
||||
control.rows = Math.min(8, Math.max(3, control.value.split("\n").length));
|
||||
|
|
@ -721,11 +817,168 @@ function clearRowEditDialogError(state) {
|
|||
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;
|
||||
state.saveButton.textContent = state.isSaving ? "Saving..." : "Save";
|
||||
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;
|
||||
var trimmed = value.trim();
|
||||
var originalValueType = control.dataset.originalValueType || "string";
|
||||
|
||||
if (originalValueType === "null" && value === "") {
|
||||
return null;
|
||||
}
|
||||
if (originalValueType === "number") {
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
var numberValue = Number(trimmed);
|
||||
if (Number.isNaN(numberValue)) {
|
||||
throw new Error(control.name + " must be a number");
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
if (originalValueType === "boolean") {
|
||||
if (/^(true|1|yes)$/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(false|0|no)$/i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(control.name + " must be true or false");
|
||||
}
|
||||
if (originalValueType === "json") {
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
throw new Error(control.name + " must be valid JSON");
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectRowEditUpdate(state) {
|
||||
var update = {};
|
||||
state.fields.querySelectorAll(".row-edit-input").forEach(function (control) {
|
||||
if (control.readOnly || control.dataset.primaryKey === "1") {
|
||||
return;
|
||||
}
|
||||
update[control.name] = valueFromRowEditControl(control);
|
||||
});
|
||||
return update;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function saveRowEditDialog(state) {
|
||||
if (state.isLoading || state.isSaving || !state.hasLoaded) {
|
||||
return;
|
||||
}
|
||||
clearRowEditDialogError(state);
|
||||
setRowEditDialogSaving(state, true);
|
||||
|
||||
try {
|
||||
if (!state.currentUpdateUrl) {
|
||||
throw new Error("Could not find the row update URL");
|
||||
}
|
||||
var response = await fetch(state.currentUpdateUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update: collectRowEditUpdate(state),
|
||||
return: true,
|
||||
}),
|
||||
});
|
||||
var data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_error) {
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok || (data && data.ok === false)) {
|
||||
throw rowMutationRequestError(response, data);
|
||||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
"Saved 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) {
|
||||
|
|
@ -747,6 +1000,8 @@ function renderRowEditFields(state, data) {
|
|||
);
|
||||
});
|
||||
|
||||
state.hasLoaded = true;
|
||||
updateRowEditDialogButtons(state);
|
||||
var firstEditable = state.fields.querySelector(".row-edit-input:not([readonly])");
|
||||
var firstField = state.fields.querySelector(".row-edit-input");
|
||||
(firstEditable || firstField || state.cancelButton).focus();
|
||||
|
|
@ -769,14 +1024,14 @@ function ensureRowEditDialog(manager) {
|
|||
<div class="modal-header">
|
||||
<span class="modal-title" id="row-edit-title">Edit row</span>
|
||||
</div>
|
||||
<form class="row-edit-form">
|
||||
<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-loading">Loading row...</p>
|
||||
<p class="row-edit-error" role="alert" 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="button" class="btn btn-primary row-edit-save" disabled>Save</button>
|
||||
<button type="submit" class="btn btn-primary row-edit-save" disabled>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
|
@ -793,24 +1048,32 @@ function ensureRowEditDialog(manager) {
|
|||
saveButton: dialog.querySelector(".row-edit-save"),
|
||||
currentButton: null,
|
||||
currentRow: null,
|
||||
currentRowId: null,
|
||||
currentPkPath: null,
|
||||
currentUpdateUrl: null,
|
||||
currentFragmentUrl: null,
|
||||
loadId: 0,
|
||||
manager: manager,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
hasLoaded: false,
|
||||
shouldRestoreFocus: true,
|
||||
};
|
||||
|
||||
rowEditDialogState.form.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
saveRowEditDialog(rowEditDialogState);
|
||||
});
|
||||
|
||||
rowEditDialogState.cancelButton.addEventListener("click", function () {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
if (!rowEditDialogState.isSaving) {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("click", function (ev) {
|
||||
if (ev.target === dialog) {
|
||||
if (ev.target === dialog && !rowEditDialogState.isSaving) {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
}
|
||||
|
|
@ -820,20 +1083,30 @@ function ensureRowEditDialog(manager) {
|
|||
if (ev.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
if (rowEditDialogState.isSaving) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", function () {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
dialog.addEventListener("cancel", function (ev) {
|
||||
if (rowEditDialogState.isSaving) {
|
||||
ev.preventDefault();
|
||||
} else {
|
||||
rowEditDialogState.shouldRestoreFocus = true;
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", function () {
|
||||
var state = rowEditDialogState;
|
||||
state.loadId += 1;
|
||||
clearRowEditDialogError(state);
|
||||
state.hasLoaded = false;
|
||||
setRowEditDialogLoading(state, false);
|
||||
setRowEditDialogSaving(state, false);
|
||||
if (
|
||||
state.shouldRestoreFocus &&
|
||||
state.currentButton &&
|
||||
|
|
@ -847,8 +1120,8 @@ function ensureRowEditDialog(manager) {
|
|||
}
|
||||
|
||||
async function openRowEditDialog(button, manager) {
|
||||
var row = button.closest("tr[data-row-url]");
|
||||
if (!row || !row.dataset.rowUrl) {
|
||||
var row = button.closest("[data-row]");
|
||||
if (!row || !row.getAttribute("data-row")) {
|
||||
return;
|
||||
}
|
||||
var state = ensureRowEditDialog(manager);
|
||||
|
|
@ -859,8 +1132,17 @@ async function openRowEditDialog(button, manager) {
|
|||
state.manager = manager;
|
||||
state.currentButton = button;
|
||||
state.currentRow = row;
|
||||
state.currentPkPath = row.dataset.rowPkPath || "";
|
||||
state.currentRowId = row.getAttribute("data-row") || "";
|
||||
state.currentPkPath = rowDisplayLabel(row);
|
||||
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;
|
||||
|
||||
|
|
@ -885,7 +1167,7 @@ async function openRowEditDialog(button, manager) {
|
|||
return;
|
||||
}
|
||||
if (!response.ok || data.ok === false) {
|
||||
throw rowDeleteRequestError(response, data);
|
||||
throw rowMutationRequestError(response, data);
|
||||
}
|
||||
setRowEditDialogLoading(state, false);
|
||||
renderRowEditFields(state, data);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr{% if row.pk_path is not none %} data-row-pk-path="{{ row.pk_path }}" data-row-path="{{ row.row_path }}" data-row-url="{{ row.row_url }}" data-row-delete-url="{{ row.delete_url }}" data-row-update-url="{{ row.update_url }}"{% endif %}>
|
||||
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% endif %}>
|
||||
{% for cell in row %}
|
||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<script>window._datasetteTableData = {{ {"tableUrl": urls.table(database, table)}|tojson }};</script>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import itertools
|
||||
import json
|
||||
import urllib
|
||||
import urllib.parse
|
||||
|
||||
import markupsafe
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ from datasette.utils import (
|
|||
InvalidSql,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
|
||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
|
||||
from datasette.filters import Filters
|
||||
import sqlite_utils
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
|
|
@ -64,16 +65,10 @@ class Row:
|
|||
cells,
|
||||
pk_path=None,
|
||||
row_path=None,
|
||||
row_url=None,
|
||||
delete_url=None,
|
||||
update_url=None,
|
||||
):
|
||||
self.cells = cells
|
||||
self.pk_path = pk_path
|
||||
self.row_path = row_path
|
||||
self.row_url = row_url
|
||||
self.delete_url = delete_url
|
||||
self.update_url = update_url
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.cells)
|
||||
|
|
@ -110,6 +105,66 @@ async def run_sequential(*args):
|
|||
return results
|
||||
|
||||
|
||||
def _exact_filter_key(column):
|
||||
if column.startswith("_"):
|
||||
return f"{column}__exact"
|
||||
return column
|
||||
|
||||
|
||||
def _request_with_query_string(request, query_string):
|
||||
scope = dict(request.scope)
|
||||
scope["query_string"] = query_string.encode("latin-1")
|
||||
return Request(scope, request.receive)
|
||||
|
||||
|
||||
async def _fragment_request_for_row(request, resolved):
|
||||
row_path = request.args.get("_row")
|
||||
if not row_path:
|
||||
return request
|
||||
if resolved.is_view:
|
||||
raise BadRequest("_row is not supported for views")
|
||||
|
||||
pks = await resolved.db.primary_keys(resolved.table)
|
||||
row_pks = pks or ["rowid"]
|
||||
pk_values = urlsafe_components(row_path)
|
||||
if len(pk_values) != len(row_pks):
|
||||
raise BadRequest("_row does not match the primary key for this table")
|
||||
|
||||
row_pk_filter_keys = {
|
||||
key
|
||||
for pk in row_pks
|
||||
for key in {
|
||||
_exact_filter_key(pk),
|
||||
f"{pk}__exact",
|
||||
}
|
||||
}
|
||||
args = [
|
||||
(key, value)
|
||||
for key, value in urllib.parse.parse_qsl(
|
||||
request.query_string, keep_blank_values=True
|
||||
)
|
||||
if key
|
||||
not in {
|
||||
"_row",
|
||||
"_next",
|
||||
"_nocount",
|
||||
"_nofacet",
|
||||
"_nosuggest",
|
||||
}.union(row_pk_filter_keys)
|
||||
]
|
||||
args.extend(
|
||||
[(_exact_filter_key(pk), value) for pk, value in zip(row_pks, pk_values)]
|
||||
)
|
||||
args.extend(
|
||||
[
|
||||
("_nocount", "1"),
|
||||
("_nofacet", "1"),
|
||||
("_nosuggest", "1"),
|
||||
]
|
||||
)
|
||||
return _request_with_query_string(request, urllib.parse.urlencode(args))
|
||||
|
||||
|
||||
def _redirect(datasette, request, path, forward_querystring=True, remove_args=None):
|
||||
if request.query_string and "?" not in path and forward_querystring:
|
||||
path = f"{path}?{request.query_string}"
|
||||
|
|
@ -255,12 +310,6 @@ async def display_columns_and_rows(
|
|||
pk_path = path_from_row_pks(row, pks, not pks, False)
|
||||
row_path = path_from_row_pks(row, pks, not pks)
|
||||
table_path = datasette.urls.table(database_name, table_name)
|
||||
row_url = "{table_path}/{row_path}".format(
|
||||
table_path=table_path,
|
||||
row_path=row_path,
|
||||
)
|
||||
delete_url = "{row_url}/-/delete".format(row_url=row_url)
|
||||
update_url = "{row_url}/-/update".format(row_url=row_url)
|
||||
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
|
||||
table_path=table_path,
|
||||
flat_pks=str(markupsafe.escape(pk_path)),
|
||||
|
|
@ -432,9 +481,6 @@ async def display_columns_and_rows(
|
|||
cells,
|
||||
pk_path=pk_path,
|
||||
row_path=row_path,
|
||||
row_url=row_url,
|
||||
delete_url=delete_url,
|
||||
update_url=update_url,
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
@ -941,6 +987,43 @@ class TableDropView(BaseView):
|
|||
return Response.json({"ok": True}, status=200)
|
||||
|
||||
|
||||
class TableFragmentView(BaseView):
|
||||
name = "table-fragment"
|
||||
|
||||
def __init__(self, datasette):
|
||||
self.ds = datasette
|
||||
|
||||
async def get(self, request):
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
request = await _fragment_request_for_row(request, resolved)
|
||||
view_data = await table_view_data(
|
||||
self.ds,
|
||||
request,
|
||||
resolved,
|
||||
extra_extras={"_html"},
|
||||
context_for_html_hack=True,
|
||||
default_labels=True,
|
||||
)
|
||||
if isinstance(view_data, Response):
|
||||
return view_data
|
||||
data, _rows, _columns, _expanded_columns, _sql, _next_url = view_data
|
||||
templates = data["custom_table_templates"]
|
||||
html = await self.ds.render_template(
|
||||
templates,
|
||||
dict(
|
||||
data,
|
||||
append_querystring=append_querystring,
|
||||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=self.ds.urls.path,
|
||||
settings=self.ds.settings_dict(),
|
||||
count_limit=resolved.db.count_limit,
|
||||
),
|
||||
request=request,
|
||||
view_name="table",
|
||||
)
|
||||
return Response.html(html)
|
||||
|
||||
|
||||
async def _columns_to_select(table_columns, pks, request):
|
||||
columns = list(table_columns)
|
||||
if "_col" in request.args:
|
||||
|
|
|
|||
|
|
@ -274,13 +274,20 @@ Here is an example of a custom ``_table.html`` template:
|
|||
.. code-block:: jinja
|
||||
|
||||
{% for row in display_rows %}
|
||||
<div>
|
||||
<div data-row="{{ row.row_path }}">
|
||||
<h2>{{ row["title"] }}</h2>
|
||||
<p>{{ row["description"] }}<lp>
|
||||
<p>Category: {{ row.display("category_id") }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
If your custom table template should support Datasette's row editing UI, include
|
||||
``data-row="{{ row.row_path }}"`` on the outer element that represents each row.
|
||||
This does not need to be a ``<tr>``: it can be a ``<div>``, ``<li>`` or any other
|
||||
element that wraps the HTML for that row. Datasette uses this attribute to find
|
||||
the element to remove after a delete, or replace after an edit. Any edit or
|
||||
delete controls should be rendered inside that same element.
|
||||
|
||||
.. _custom_pages:
|
||||
|
||||
Custom pages
|
||||
|
|
|
|||
|
|
@ -664,6 +664,11 @@ async def test_table_html_compound_primary_key(ds_client):
|
|||
assert [
|
||||
[str(td) for td in tr.select("td")] for tr in table.select("tbody tr")
|
||||
] == expected
|
||||
rows = table.select("tbody tr")
|
||||
assert rows[0]["data-row"] == "a,b"
|
||||
assert "data-row-pk-path" not in rows[0].attrs
|
||||
assert rows[1]["data-row"] == "a~2Fb,~2Ec-d"
|
||||
assert "data-row-pk-path" not in rows[1].attrs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -859,12 +864,24 @@ 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"}
|
||||
|
||||
row = soup.select_one("table.rows-and-columns tbody tr")
|
||||
assert row["data-row-pk-path"] == "1"
|
||||
assert row["data-row-path"] == "1"
|
||||
assert row["data-row-url"] == "/data/items/1"
|
||||
assert row["data-row-delete-url"] == "/data/items/1/-/delete"
|
||||
assert row["data-row-update-url"] == "/data/items/1/-/update"
|
||||
assert row["data-row"] == "1"
|
||||
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"]'
|
||||
|
|
@ -885,6 +902,97 @@ async def test_row_delete_action_data_attributes():
|
|||
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")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
soup = Soup(response.text, "html.parser")
|
||||
assert soup.find("html") is None
|
||||
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"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures/simple_primary_key/-/fragment?id=2&_row=1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
rows = soup.select("[data-row]")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["data-row"] == "1"
|
||||
|
||||
|
||||
def test_table_data_uses_base_url(app_client_base_url_prefix):
|
||||
response = app_client_base_url_prefix.get("/prefix/fixtures/simple_primary_key")
|
||||
assert response.status_code == 200
|
||||
import json
|
||||
import re
|
||||
|
||||
soup = Soup(response.text, "html.parser")
|
||||
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": "/prefix/fixtures/simple_primary_key"
|
||||
}
|
||||
|
||||
|
||||
def test_table_fragment_custom_table_include():
|
||||
with make_app_client(
|
||||
template_dir=str(pathlib.Path(__file__).parent / "test_templates")
|
||||
) as client:
|
||||
response = client.get("/fixtures/complex_foreign_keys/-/fragment?f1=1&f2=2")
|
||||
assert response.status == 200
|
||||
assert (
|
||||
'<div class="custom-table-row">'
|
||||
'1 - 2 - <a href="/fixtures/simple_primary_key/1">hello</a> <em>1</em>'
|
||||
"</div>"
|
||||
) == str(Soup(response.text, "html.parser").select_one("div.custom-table-row"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_uses_render_cell_hook():
|
||||
from datasette import hookimpl
|
||||
from markupsafe import Markup
|
||||
|
||||
class TestRenderCellPlugin:
|
||||
__name__ = "TestRenderCellPlugin"
|
||||
|
||||
@hookimpl
|
||||
def render_cell(self, value, column, table, database):
|
||||
if database == "data" and table == "items" and column == "name":
|
||||
return Markup("<strong>{}</strong>".format(value))
|
||||
return None
|
||||
|
||||
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("insert into items values (1, 'Alice')")
|
||||
ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin")
|
||||
try:
|
||||
response = await ds.client.get("/data/items/-/fragment?id=1")
|
||||
assert response.status_code == 200
|
||||
assert "<strong>Alice</strong>" in response.text
|
||||
finally:
|
||||
ds.pm.unregister(name="TestRenderCellPlugin")
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_row_table_renders_thead(ds_client):
|
||||
response = await ds_client.get("/fixtures/123_starts_with_digits")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue