From e50d176722e572230cf105072e08b41dfc50bf00 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 13 Jun 2026 18:41:00 -0700 Subject: [PATCH] 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. --- datasette/app.py | 5 + datasette/static/app.css | 24 ++- datasette/static/table.js | 368 ++++++++++++++++++++++++++++---- datasette/templates/_table.html | 2 +- datasette/templates/table.html | 1 + datasette/views/table.py | 115 ++++++++-- docs/custom_templates.rst | 9 +- tests/test_table_html.py | 118 +++++++++- 8 files changed, 571 insertions(+), 71 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 4931f486..4d7f483a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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[^\/\.]+)/(?P[^\/\.]+)/-/set-column-type$", ) + add_route( + TableFragmentView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/fragment$", + ) add_route( TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", diff --git a/datasette/static/app.css b/datasette/static/app.css index 5c92d59c..aca39e58 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -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; diff --git a/datasette/static/table.js b/datasette/static/table.js index 044c9509..1ae2f30d 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -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) { - +

Editing row

-

Loading row...

- +

Loading row...

+
`; @@ -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); diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index 2ecf5cc2..38454da3 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {% endfor %} diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 4dc908e0..026599cc 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -4,6 +4,7 @@ {% block extra_head %} {{- super() -}} + diff --git a/datasette/views/table.py b/datasette/views/table.py index cd6a016a..33357165 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -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 = '{flat_pks}'.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: diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index c324fb79..aa82a536 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -274,13 +274,20 @@ Here is an example of a custom ``_table.html`` template: .. code-block:: jinja {% for row in display_rows %} -
+

{{ row["title"] }}

{{ row["description"] }}

Category: {{ row.display("category_id") }}

{% 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 ``
``: it can be a ``
``, ``
  • `` 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 diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 6be6942d..4e24f129 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -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 ( + '
    ' + '1 - 2 - hello 1' + "
    " + ) == 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("{}".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 "Alice" 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")
  • {{ cell.value }}