diff --git a/datasette/static/app.css b/datasette/static/app.css
index 9226580b..8e68c1bf 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -1264,9 +1264,15 @@ dialog.row-delete-dialog::backdrop {
justify-content: flex-start;
gap: 12px;
flex-shrink: 0;
+ min-width: 0;
}
.row-delete-dialog .modal-title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ min-width: 0;
+ max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
@@ -1388,16 +1394,30 @@ dialog.row-edit-dialog::backdrop {
align-items: center;
gap: 12px;
flex-shrink: 0;
+ min-width: 0;
}
.row-edit-dialog .modal-title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ min-width: 0;
+ max-width: 100%;
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
-.row-edit-dialog .modal-title code {
+.row-edit-dialog .modal-title .row-dialog-action,
+.row-delete-dialog .modal-title .row-dialog-action {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.row-edit-dialog .modal-title code,
+.row-delete-dialog .modal-title code {
display: inline;
+ flex: 0 0 auto;
padding: 2px 5px;
border: 1px solid var(--rule);
border-radius: 4px;
@@ -1407,6 +1427,14 @@ dialog.row-edit-dialog::backdrop {
overflow-wrap: anywhere;
}
+.row-edit-dialog .modal-title .row-dialog-label,
+.row-delete-dialog .modal-title .row-dialog-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.row-edit-form {
display: flex;
flex: 1 1 auto;
diff --git a/datasette/static/table.js b/datasette/static/table.js
index c8109c62..53c4592b 100644
--- a/datasette/static/table.js
+++ b/datasette/static/table.js
@@ -471,6 +471,10 @@ function rowDisplayLabel(row) {
return tildeDecode(row.getAttribute("data-row") || "");
}
+function rowTitleLabel(row) {
+ return row.getAttribute("data-row-label") || "";
+}
+
function tableBaseUrl() {
var tableUrl =
window._datasetteTableData && window._datasetteTableData.tableUrl;
@@ -607,6 +611,7 @@ function ensureRowDeleteDialog(manager) {
rowDeleteDialogState = {
dialog: dialog,
+ title: dialog.querySelector(".modal-title"),
message: dialog.querySelector(".row-delete-message"),
rowId: dialog.querySelector(".row-delete-id"),
error: dialog.querySelector(".row-delete-error"),
@@ -741,6 +746,12 @@ function openRowDeleteDialog(button, manager) {
clearRowDeleteDialogError(state);
setRowDeleteDialogBusy(state, false);
+ setRowDialogTitle(
+ state.title,
+ "Delete row",
+ state.currentPkPath || "this row",
+ rowTitleLabel(row),
+ );
state.rowId.textContent = state.currentPkPath || "this row";
if (!state.dialog.open) {
@@ -1335,16 +1346,26 @@ function renderRowInsertFields(state, data) {
(firstControl || state.saveButton).focus();
}
-function setRowEditDialogTitle(state, text, codeText) {
- state.title.textContent = "";
- state.title.appendChild(document.createTextNode(text));
+function setRowDialogTitle(title, text, codeText, labelText) {
+ title.textContent = "";
+ var action = document.createElement("span");
+ action.className = "row-dialog-action";
+ action.textContent = text;
+ title.appendChild(action);
if (!codeText) {
return;
}
- state.title.appendChild(document.createTextNode(" "));
+ title.appendChild(document.createTextNode(" "));
var code = document.createElement("code");
code.textContent = codeText;
- state.title.appendChild(code);
+ title.appendChild(code);
+ if (labelText && labelText !== codeText) {
+ title.appendChild(document.createTextNode(" "));
+ var label = document.createElement("span");
+ label.className = "row-dialog-label";
+ label.textContent = labelText;
+ title.appendChild(label);
+ }
}
function ensureRowEditDialog(manager) {
@@ -1494,7 +1515,12 @@ async function openRowEditDialog(button, manager) {
setRowEditDialogLoading(state, true);
state.fields.innerHTML = "";
state.dialog.removeAttribute("aria-describedby");
- setRowEditDialogTitle(state, "Edit row", state.currentPkPath || "this row");
+ setRowDialogTitle(
+ state.title,
+ "Edit row",
+ state.currentPkPath || "this row",
+ rowTitleLabel(row),
+ );
state.summary.hidden = true;
state.summary.textContent = "";
@@ -1561,8 +1587,8 @@ function openRowInsertDialog(button, manager) {
setRowEditDialogLoading(state, false);
state.fields.innerHTML = "";
state.dialog.removeAttribute("aria-describedby");
- setRowEditDialogTitle(
- state,
+ setRowDialogTitle(
+ state.title,
insertData.tableName ? "Insert row into " + insertData.tableName : "Insert row",
);
state.summary.hidden = true;
diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html
index 38454da3..171b6442 100644
--- a/datasette/templates/_table.html
+++ b/datasette/templates/_table.html
@@ -22,7 +22,7 @@
{% for row in display_rows %}
-
+
{% for cell in row %}
| {{ cell.value }} |
{% endfor %}
diff --git a/datasette/views/table.py b/datasette/views/table.py
index c58e39f3..c962637f 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -66,10 +66,12 @@ class Row:
cells,
pk_path=None,
row_path=None,
+ row_label=None,
):
self.cells = cells
self.pk_path = pk_path
self.row_path = row_path
+ self.row_label = row_label
def __iter__(self):
return iter(self.cells)
@@ -96,6 +98,20 @@ class Row:
return json.dumps(d, default=repr, indent=2)
+def row_label_from_label_column(row, label_column):
+ if not label_column:
+ return None
+ try:
+ value = row[label_column]
+ except KeyError:
+ return None
+ if isinstance(value, dict):
+ value = value.get("label")
+ if value is None or value == "":
+ return None
+ return str(value)
+
+
async def run_sequential(*args):
# This used to be swappable for asyncio.gather() to run things in
# parallel, but this lead to hard-to-debug locking issues with
@@ -325,6 +341,9 @@ async def display_columns_and_rows(
pks_for_display = pks
if not pks_for_display:
pks_for_display = ["rowid"]
+ label_column = None
+ if link_column:
+ label_column = await db.label_column_for_table(table_name)
row_action_permissions = {}
if link_column and request is not None and db.is_mutable:
row_action_permissions = await datasette.allowed_many(
@@ -372,6 +391,10 @@ async def display_columns_and_rows(
is_special_link_column = len(pks) != 1
pk_path = path_from_row_pks(row, pks, not pks, False)
row_path = path_from_row_pks(row, pks, not pks)
+ row_label = row_label_from_label_column(row, label_column)
+ row_action_label = pk_path
+ if row_label and row_label != pk_path:
+ row_action_label = "{} {}".format(pk_path, row_label)
table_path = datasette.urls.table(database_name, table_name)
row_link = '{flat_pks}'.format(
table_path=table_path,
@@ -407,7 +430,7 @@ async def display_columns_and_rows(
'data-row-action="edit">'
"{edit_icon}".format(
edit_icon=edit_icon,
- row_label=markupsafe.escape(pk_path),
+ row_label=markupsafe.escape(row_action_label),
)
)
if row_action_permissions.get("delete-row"):
@@ -417,7 +440,7 @@ async def display_columns_and_rows(
'data-row-action="delete">'
"{delete_icon}".format(
delete_icon=delete_icon,
- row_label=markupsafe.escape(pk_path),
+ row_label=markupsafe.escape(row_action_label),
)
)
if row_actions:
@@ -544,6 +567,7 @@ async def display_columns_and_rows(
cells,
pk_path=pk_path,
row_path=row_path,
+ row_label=row_label,
)
)
else:
diff --git a/tests/test_table_html.py b/tests/test_table_html.py
index 71d4b852..364457d9 100644
--- a/tests/test_table_html.py
+++ b/tests/test_table_html.py
@@ -682,8 +682,10 @@ async def test_table_html_compound_primary_key(ds_client):
rows = table.select("tbody tr")
assert rows[0]["data-row"] == "a,b"
assert "data-row-pk-path" not in rows[0].attrs
+ assert "data-row-label" not in rows[0].attrs
assert rows[1]["data-row"] == "a~2Fb,~2Ec-d"
assert "data-row-pk-path" not in rows[1].attrs
+ assert "data-row-label" not in rows[1].attrs
@pytest.mark.asyncio
@@ -884,13 +886,17 @@ async def test_row_delete_action_data_attributes():
row = soup.select_one("table.rows-and-columns tbody tr")
assert row["data-row"] == "1"
- assert {key for key in row.attrs if key.startswith("data-row")} == {"data-row"}
+ assert row["data-row-label"] == "One"
+ assert {key for key in row.attrs if key.startswith("data-row")} == {
+ "data-row",
+ "data-row-label",
+ }
edit_button = row.select_one(
'button.row-inline-action-edit[data-row-action="edit"]'
)
assert edit_button is not None
- assert edit_button["aria-label"] == "Edit row 1"
+ assert edit_button["aria-label"] == "Edit row 1 One"
assert edit_button["title"] == "Edit row"
assert edit_button.find("svg") is not None
@@ -898,7 +904,7 @@ async def test_row_delete_action_data_attributes():
'button.row-inline-action-delete[data-row-action="delete"]'
)
assert button is not None
- assert button["aria-label"] == "Delete row 1"
+ assert button["aria-label"] == "Delete row 1 One"
assert button["title"] == "Delete row"
assert button.find("svg") is not None
finally:
@@ -1033,7 +1039,11 @@ async def test_table_fragment_endpoint(ds_client):
rows = soup.select("[data-row]")
assert len(rows) == 1
assert rows[0]["data-row"] == "1"
- assert {key for key in rows[0].attrs if key.startswith("data-row")} == {"data-row"}
+ assert rows[0]["data-row-label"] == "hello"
+ assert {key for key in rows[0].attrs if key.startswith("data-row")} == {
+ "data-row",
+ "data-row-label",
+ }
@pytest.mark.asyncio
@@ -1046,6 +1056,7 @@ async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
rows = soup.select("[data-row]")
assert len(rows) == 1
assert rows[0]["data-row"] == "1"
+ assert rows[0]["data-row-label"] == "hello"
def test_table_data_uses_base_url(app_client_base_url_prefix):