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:
Simon Willison 2026-06-13 18:41:00 -07:00
commit e50d176722
8 changed files with 571 additions and 71 deletions

View file

@ -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$",

View file

@ -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;

View file

@ -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);

View file

@ -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 %}

View file

@ -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>

View file

@ -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:

View file

@ -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

View file

@ -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")