diff --git a/datasette/app.py b/datasette/app.py index 9c9b7de434..4366bb08f0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -92,6 +92,7 @@ TableInsertView, TableUpsertView, TableSetColumnTypeView, + TableSetLabelColumnsView, TableDropView, TableFragmentView, table_view, @@ -119,6 +120,7 @@ module_from_path, move_plugins_and_allow, move_table_config, + normalize_label_columns, parse_metadata, resolve_env_secrets, resolve_routes, @@ -830,6 +832,8 @@ async def invoke_startup(self): await self._save_queries_from_config() # Load column_types from config into internal DB await self._apply_column_types_config() + # Seed label_column config into internal DB (first run only) + await self._apply_label_columns_config() for hook in pm.hook.startup(datasette=self): await await_me_maybe(hook) self._startup_invoked = True @@ -1447,6 +1451,60 @@ async def remove_column_type( [database, resource, column], ) + async def get_label_columns(self, database: str, resource: str): + """ + Return the explicit label_column override for this resource, as a + list of column names, or None if no override has been set (in which + case automatic detection should be used). + """ + row = await self.get_internal_database().execute( + "SELECT columns FROM label_columns " + "WHERE database_name = ? AND resource_name = ?", + [database, resource], + ) + rows = row.rows + if not rows: + return None + return json.loads(rows[0][0]) + + async def set_label_columns( + self, database: str, resource: str, columns: list + ) -> None: + """Set the label column(s) for a resource. Overwrites any existing value.""" + await self.get_internal_database().execute_write( + """INSERT OR REPLACE INTO label_columns + (database_name, resource_name, columns) + VALUES (?, ?, ?)""", + [database, resource, json.dumps(columns)], + ) + + async def remove_label_columns(self, database: str, resource: str) -> None: + """Remove the label column(s) override for a resource.""" + await self.get_internal_database().execute_write( + "DELETE FROM label_columns " + "WHERE database_name = ? AND resource_name = ?", + [database, resource], + ) + + async def _apply_label_columns_config(self): + """ + Seed label_column config from datasette.json into the internal DB, + but only the first time a table is seen - once a row exists (whether + from config or from the set-label-columns API) it is left alone on + subsequent startups. + """ + for db_name, db_conf in (self.config or {}).get("databases", {}).items(): + for table_name, table_conf in db_conf.get("tables", {}).items(): + if "label_column" not in table_conf: + continue + columns = normalize_label_columns(table_conf["label_column"]) + await self.get_internal_database().execute_write( + """INSERT INTO label_columns (database_name, resource_name, columns) + VALUES (?, ?, ?) + ON CONFLICT(database_name, resource_name) DO NOTHING""", + [db_name, table_name, json.dumps(columns)], + ) + def get_internal_database(self): return self._internal_database @@ -2125,17 +2183,17 @@ async def expand_foreign_keys(self, actor, database, table, column, values): ) if not visible: return {} - label_column = await db.label_column_for_table(other_table) - if not label_column: + label_columns = await db.label_columns_for_table(other_table) + if not label_columns: return {(fk["column"], value): str(value) for value in values} labeled_fks = {} sql = """ - select {other_column}, {label_column} + select {other_column}, {label_columns} from {other_table} where {other_column} in ({placeholders}) """.format( other_column=escape_sqlite(other_column), - label_column=escape_sqlite(label_column), + label_columns=", ".join(escape_sqlite(column) for column in label_columns), other_table=escape_sqlite(other_table), placeholders=", ".join(["?"] * len(set(values))), ) @@ -2144,8 +2202,19 @@ async def expand_foreign_keys(self, actor, database, table, column, values): except QueryInterrupted: pass else: - for id, value in results: - labeled_fks[(fk["column"], id)] = value + for row in results: + id = row[0] + if len(label_columns) == 1: + labeled_fks[(fk["column"], id)] = row[1] + else: + label_values = [ + str(value) + for value in row[1:] + if value is not None and value != "" + ] + labeled_fks[(fk["column"], id)] = ( + " ".join(label_values) if label_values else str(id) + ) return labeled_fks def absolute_url(self, request, path): @@ -2736,6 +2805,10 @@ def add_route(view, regex): TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/set-column-type$", ) + add_route( + TableSetLabelColumnsView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-label-columns$", + ) add_route( TableFragmentView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/fragment$", diff --git a/datasette/database.py b/datasette/database.py index e7fe1ed9eb..b76c839787 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -665,12 +665,10 @@ async def primary_keys(self, table): async def fts_table(self, table): return await self.execute_fn(lambda conn: detect_fts(conn, table)) - async def label_column_for_table(self, table): - explicit_label_column = (await self.ds.table_config(self.name, table)).get( - "label_column" - ) - if explicit_label_column: - return explicit_label_column + async def label_columns_for_table(self, table): + explicit_label_columns = await self.ds.get_label_columns(self.name, table) + if explicit_label_columns is not None: + return explicit_label_columns def column_details(conn): # Returns {column_name: (type, is_unique)} @@ -695,13 +693,13 @@ def column_details(conn): if is_unique and type_ is str ] if len(unique_text_columns) == 1: - return unique_text_columns[0] + return [unique_text_columns[0]] column_names = list(column_details.keys()) # Is there a name or title column? name_or_title = [c for c in column_names if c.lower() in ("name", "title")] if name_or_title: - return name_or_title[0] + return [name_or_title[0]] # If a table has two columns, one of which is ID, then label_column is the other one if ( column_names @@ -709,9 +707,9 @@ def column_details(conn): and ("id" in column_names or "pk" in column_names) and not set(column_names) == {"id", "pk"} ): - return [c for c in column_names if c not in ("id", "pk")][0] + return [c for c in column_names if c not in ("id", "pk")][0:1] # Couldn't find a label: - return None + return [] async def foreign_keys_for_table(self, table): return await self.execute_fn( diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 2f78570b15..0a4b208ef1 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -105,6 +105,12 @@ def register_actions(): description="Set column type", resource_class=TableResource, ), + Action( + name="set-label-columns", + abbr="slc", + description="Set label column(s)", + resource_class=TableResource, + ), Action( name="drop-table", abbr="dt", diff --git a/datasette/default_table_actions.py b/datasette/default_table_actions.py index e41434efbc..cfead5bab5 100644 --- a/datasette/default_table_actions.py +++ b/datasette/default_table_actions.py @@ -6,24 +6,41 @@ def table_actions(datasette, actor, database, table, request): async def inner(): db = datasette.get_database(database) - if not db.is_mutable: - return [] - if not await datasette.allowed( + actions = [] + if db.is_mutable and await datasette.allowed( action="alter-table", resource=TableResource(database=database, table=table), actor=actor, ): - return [] - return [ - { - "type": "button", - "label": "Alter table", - "description": "Change columns and primary key for this table.", - "attrs": { - "aria-label": "Alter table {}".format(table), - "data-table-action": "alter-table", - }, - } - ] + actions.append( + { + "type": "button", + "label": "Alter table", + "description": "Change columns and primary key for this table.", + "attrs": { + "aria-label": "Alter table {}".format(table), + "data-table-action": "alter-table", + }, + } + ) + # Not gated on db.is_mutable - label configuration is a display + # preference stored in the internal DB, not a schema change. + if await datasette.allowed( + action="set-label-columns", + resource=TableResource(database=database, table=table), + actor=actor, + ): + actions.append( + { + "type": "button", + "label": "Set label column(s)", + "description": "Choose which column(s) are used to label this table's rows.", + "attrs": { + "aria-label": "Set label columns for {}".format(table), + "data-table-action": "set-label-columns", + }, + } + ) + return actions return inner diff --git a/datasette/static/app.css b/datasette/static/app.css index ce800f61b7..d6b37ff135 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1518,6 +1518,183 @@ dialog.row-delete-dialog::backdrop { cursor: wait; } +dialog.table-label-columns-dialog { + --ink: #0f0f0f; + --paper: #eef6ff; + --muted: #6b6b6b; + --rule: #d8e6f5; + --accent: #1a56db; + --card: #ffffff; + border: none; + border-radius: var(--modal-border-radius, 0.75rem); + padding: 0; + margin: auto; + width: min(440px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(560px, calc(100vh - 32px)); + box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)); + animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out; + overflow: hidden; + font-family: system-ui, -apple-system, sans-serif; + background: var(--card); +} + +dialog.table-label-columns-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-label-columns-dialog::backdrop { + background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5)); + backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + -webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px)); + animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out; +} + +.table-label-columns-dialog .table-label-columns-form { + display: flex; + flex-direction: column; + min-height: 0; +} + +.table-label-columns-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-label-columns-dialog .modal-title { + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-label-columns-help, +.table-label-columns-error { + margin: 0; + padding: 16px 24px 0; +} + +.table-label-columns-help { + color: var(--muted); + font-size: 0.85rem; +} + +.table-label-columns-error { + color: #b91c1c; + font-size: 0.9rem; +} + +.table-label-columns-list { + list-style: none; + margin: 12px 0 0; + padding: 0 24px; + overflow-y: auto; +} + +.table-label-columns-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--rule); +} + +.table-label-columns-label { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--ink); + font-size: 0.9rem; +} + +.table-label-columns-name { + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.table-label-columns-move-controls { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.table-label-columns-move-controls button { + border: 1px solid var(--rule); + background: var(--paper); + border-radius: 4px; + width: 26px; + height: 26px; + cursor: pointer; + color: var(--ink); +} + +.table-label-columns-move-controls button:disabled { + opacity: 0.4; + cursor: default; +} + +.table-label-columns-dialog .modal-footer { + padding: 18px 20px 14px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); + margin-top: 18px; +} + +.table-label-columns-dialog .btn { + border: none; + border-radius: 5px; + padding: 9px 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + touch-action: manipulation; + font-family: inherit; + transition: background 0.12s; +} + +.table-label-columns-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); + margin-right: auto; +} + +.table-label-columns-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-label-columns-dialog .btn-ghost.table-label-columns-cancel { + margin-right: 0; +} + +.table-label-columns-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-label-columns-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-label-columns-dialog .btn:disabled { + opacity: 0.65; + cursor: wait; +} + dialog.row-edit-dialog { --ink: #0f0f0f; --paper: #eef6ff; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 9f4f89b983..7dd9acc2e9 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -6,6 +6,8 @@ var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; var tableCreateDialogState = null; var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; var tableAlterDialogState = null; +var TABLE_LABEL_COLUMNS_DIALOG_ID = "table-label-columns-dialog"; +var tableLabelColumnsDialogState = null; function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); @@ -1797,6 +1799,10 @@ function tableAlterData() { return tablePageData().alterTable; } +function tableLabelColumnsData() { + return tablePageData().labelColumns; +} + function tableAlterColumnTypes() { var data = tableAlterData() || {}; return data.columnTypes && data.columnTypes.length @@ -5281,6 +5287,333 @@ function initRowInsertActions(manager) { }); } +function clearTableLabelColumnsDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); +} + +function showTableLabelColumnsDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.dialog.setAttribute("aria-describedby", "table-label-columns-error"); + state.error.focus(); +} + +function setTableLabelColumnsDialogBusy(state, isBusy) { + state.isBusy = isBusy; + state.cancelButton.disabled = isBusy; + state.saveButton.disabled = isBusy; + state.resetButton.disabled = isBusy || !state.isOverridden; + state.saveButton.textContent = isBusy ? "Saving..." : "Save"; + state.list + .querySelectorAll("input, button") + .forEach(function (control) { + control.disabled = isBusy; + }); + if (!isBusy) { + updateTableLabelColumnsMoveButtons(state); + state.resetButton.disabled = !state.isOverridden; + } +} + +function updateTableLabelColumnsMoveButtons(state) { + var rows = Array.prototype.slice.call( + state.list.querySelectorAll(".table-label-columns-row"), + ); + rows.forEach(function (row, index) { + row.querySelector(".table-label-columns-move-up").disabled = + state.isBusy || index === 0; + row.querySelector(".table-label-columns-move-down").disabled = + state.isBusy || index === rows.length - 1; + }); +} + +function moveTableLabelColumnsRow(state, row, direction) { + if (direction === "up") { + var previous = row.previousElementSibling; + if (previous) { + state.list.insertBefore(row, previous); + } + } else { + var next = row.nextElementSibling; + if (next) { + state.list.insertBefore(next, row); + } + } + updateTableLabelColumnsMoveButtons(state); +} + +function addTableLabelColumnsRow(state, column, checked) { + var row = document.createElement("li"); + row.className = "table-label-columns-row"; + row.innerHTML = + '" + + '
' + + '' + + '' + + "
"; + row.querySelector(".table-label-columns-checkbox").checked = !!checked; + row.querySelector(".table-label-columns-checkbox").value = column; + row.querySelector(".table-label-columns-name").textContent = column; + row + .querySelector(".table-label-columns-move-up") + .addEventListener("click", function () { + moveTableLabelColumnsRow(state, row, "up"); + }); + row + .querySelector(".table-label-columns-move-down") + .addEventListener("click", function () { + moveTableLabelColumnsRow(state, row, "down"); + }); + state.list.appendChild(row); + return row; +} + +function renderTableLabelColumnsFields(state, data) { + state.list.innerHTML = ""; + var current = data.current || []; + var remaining = (data.columns || []).filter(function (column) { + return current.indexOf(column) === -1; + }); + current.forEach(function (column) { + addTableLabelColumnsRow(state, column, true); + }); + remaining.forEach(function (column) { + addTableLabelColumnsRow(state, column, false); + }); + updateTableLabelColumnsMoveButtons(state); +} + +function collectTableLabelColumnsPayload(state) { + var rows = Array.prototype.slice.call( + state.list.querySelectorAll(".table-label-columns-row"), + ); + var columns = []; + rows.forEach(function (row) { + var checkbox = row.querySelector(".table-label-columns-checkbox"); + if (checkbox.checked) { + columns.push(checkbox.value); + } + }); + return columns; +} + +async function submitTableLabelColumns(state, columns) { + if (state.isBusy) { + return; + } + var data = tableLabelColumnsData(); + if (!data || !data.path) { + showTableLabelColumnsDialogError( + state, + "Could not find the set label columns URL.", + ); + return; + } + clearTableLabelColumnsDialogError(state); + setTableLabelColumnsDialogBusy(state, true); + try { + var response = await fetch(data.path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ columns: columns }), + }); + var responseData = null; + try { + responseData = await response.json(); + } catch (_error) { + responseData = null; + } + if (!response.ok || (responseData && responseData.ok === false)) { + throw rowMutationRequestError(response, responseData); + } + state.shouldRestoreFocus = false; + state.dialog.close(); + location.reload(); + } catch (error) { + setTableLabelColumnsDialogBusy(state, false); + showTableLabelColumnsDialogError( + state, + error.message || "Could not save label columns", + ); + } +} + +function ensureTableLabelColumnsDialog(manager) { + if (tableLabelColumnsDialogState) { + return tableLabelColumnsDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = TABLE_LABEL_COLUMNS_DIALOG_ID; + dialog.className = "table-label-columns-dialog"; + dialog.setAttribute("aria-labelledby", "table-label-columns-title"); + dialog.innerHTML = ` + + + +

Choose one or more columns to use as the display label for this table's rows. Checked columns are used in the order shown below - use the arrows to reorder them.

+ + + + `; + document.body.appendChild(dialog); + + tableLabelColumnsDialogState = { + dialog: dialog, + form: dialog.querySelector(".table-label-columns-form"), + title: dialog.querySelector(".modal-title"), + error: dialog.querySelector(".table-label-columns-error"), + list: dialog.querySelector(".table-label-columns-list"), + resetButton: dialog.querySelector(".table-label-columns-reset"), + cancelButton: dialog.querySelector(".table-label-columns-cancel"), + saveButton: dialog.querySelector(".table-label-columns-save"), + currentButton: null, + isBusy: false, + isOverridden: false, + shouldRestoreFocus: true, + manager: manager, + }; + + tableLabelColumnsDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + var state = tableLabelColumnsDialogState; + submitTableLabelColumns(state, collectTableLabelColumnsPayload(state)); + }); + + tableLabelColumnsDialogState.resetButton.addEventListener( + "click", + function () { + submitTableLabelColumns(tableLabelColumnsDialogState, null); + }, + ); + + tableLabelColumnsDialogState.cancelButton.addEventListener( + "click", + function () { + var state = tableLabelColumnsDialogState; + if (!state.isBusy) { + state.shouldRestoreFocus = true; + dialog.close(); + } + }, + ); + + dialog.addEventListener("click", function (ev) { + var state = tableLabelColumnsDialogState; + if (ev.target === dialog && !state.isBusy) { + state.shouldRestoreFocus = true; + dialog.close(); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + var state = tableLabelColumnsDialogState; + if (state.isBusy) { + ev.preventDefault(); + return; + } + ev.preventDefault(); + state.shouldRestoreFocus = true; + dialog.close(); + }); + + dialog.addEventListener("cancel", function (ev) { + var state = tableLabelColumnsDialogState; + if (state.isBusy) { + ev.preventDefault(); + } else { + state.shouldRestoreFocus = true; + } + }); + + dialog.addEventListener("close", function () { + var state = tableLabelColumnsDialogState; + clearTableLabelColumnsDialogError(state); + setTableLabelColumnsDialogBusy(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return tableLabelColumnsDialogState; +} + +function openTableLabelColumnsDialog(button, manager) { + var data = tableLabelColumnsData(); + if (!data) { + return; + } + var state = ensureTableLabelColumnsDialog(manager); + if (!state) { + return; + } + + var menu = button.closest("details"); + if (menu) { + menu.open = false; + } + state.manager = manager; + state.currentButton = button; + state.isOverridden = !!data.isOverridden; + state.shouldRestoreFocus = true; + state.title.textContent = data.tableName + ? "Set label column(s) for " + data.tableName + : "Set label column(s)"; + clearTableLabelColumnsDialogError(state); + renderTableLabelColumnsFields(state, data); + setTableLabelColumnsDialogBusy(state, false); + + if (!state.dialog.open) { + state.dialog.showModal(); + } + var firstCheckbox = state.list.querySelector( + ".table-label-columns-checkbox", + ); + if (firstCheckbox) { + firstCheckbox.focus(); + } +} + +function initTableLabelColumnsActions(manager) { + if (!window.fetch || !window.HTMLDialogElement || !tableLabelColumnsData()) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest( + 'button[data-table-action="set-label-columns"]', + ); + if (!button) { + return; + } + ev.preventDefault(); + openTableLabelColumnsDialog(button, manager); + }); +} + document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; @@ -5290,4 +5623,5 @@ document.addEventListener("datasette_init", function (evt) { initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); + initTableLabelColumnsActions(manager); }); diff --git a/datasette/templates/debug_autocomplete.html b/datasette/templates/debug_autocomplete.html index 380639a360..1678182efe 100644 --- a/datasette/templates/debug_autocomplete.html +++ b/datasette/templates/debug_autocomplete.html @@ -26,8 +26,8 @@

Debug autocomplete

{{ error }}

{% elif autocomplete_url %}

{{ database_name }} / {{ table_name }}

- {% if label_column %} -

Label column: {{ label_column }}

+ {% if label_columns %} +

Label column{{ "s" if label_columns|length > 1 else "" }}: {{ label_columns|join(", ") }}

{% else %}

No label column detected. Results will use primary key values.

{% endif %} @@ -64,7 +64,7 @@

Suggested tables

- + {% endfor %} diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 1caff3a83e..bd582e229c 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1504,6 +1504,18 @@ def recursive_move(src, dest, path=None): ) +def normalize_label_columns(value): + """ + Normalize a ``label_column`` config value (a string, a list of strings, + or None) to a list of strings. + """ + if value is None: + return [] + if isinstance(value, str): + return [value] + return list(value) + + def move_table_config(metadata: dict, config: dict): """ Move all known table configuration keys from metadata to config. diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index bf172667ff..a629f0fe36 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -113,6 +113,13 @@ async def initialize_metadata_tables(db): PRIMARY KEY (database_name, resource_name, column_name) ); + CREATE TABLE IF NOT EXISTS label_columns ( + database_name TEXT NOT NULL, + resource_name TEXT NOT NULL, + columns TEXT NOT NULL, -- JSON list of column names, order = label order + PRIMARY KEY (database_name, resource_name) + ); + CREATE TABLE IF NOT EXISTS queries ( database_name TEXT NOT NULL, name TEXT NOT NULL, diff --git a/datasette/views/row.py b/datasette/views/row.py index 129216b925..e93f60e2f3 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -34,7 +34,7 @@ from .table import ( display_columns_and_rows, _table_page_data, - row_label_from_label_column, + row_label_from_label_columns, ) from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry @@ -495,10 +495,12 @@ async def template_data(): "{}".format(cell["value"]) ) - label_column = await db.label_column_for_table(table) if is_table else None + label_columns = ( + await db.label_columns_for_table(table) if is_table else None + ) row_path = path_from_row_pks(rows[0], pks, False) pk_path = path_from_row_pks(rows[0], pks, False, False) - row_label = row_label_from_label_column(expanded_rows[0], label_column) + row_label = row_label_from_label_columns(expanded_rows[0], label_columns) for display_row in display_rows: display_row.pk_path = pk_path display_row.row_path = row_path @@ -585,6 +587,7 @@ async def template_data(): is_view=not is_table, table_insert_ui=None, table_alter_ui=None, + label_columns_ui=None, ), "row_actions": row_actions, "top_row": make_slot_function( @@ -703,8 +706,8 @@ def _truncated_row_flash_label(label): async def _row_flash_message(db, action, resolved, row=None): pk_label = ", ".join(resolved.pk_values) - label_column = await db.label_column_for_table(resolved.table) - label = row_label_from_label_column(row or resolved.row, label_column) + label_columns = await db.label_columns_for_table(resolved.table) + label = row_label_from_label_columns(row or resolved.row, label_columns) if label: label = _truncated_row_flash_label(label) if label and label != pk_label: diff --git a/datasette/views/special.py b/datasette/views/special.py index 3245bc136a..824821c820 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -121,13 +121,13 @@ async def _suggested_tables(self, request): if scanned >= 100: break continue - label_column = await db.label_column_for_table(table_name) - if label_column: + label_columns = await db.label_columns_for_table(table_name) + if label_columns: suggestions.append( { "database": database_name, "table": table_name, - "label_column": label_column, + "label_columns": label_columns, "url": self.ds.urls.path( "-/debug/autocomplete?" + urllib.parse.urlencode( @@ -177,7 +177,9 @@ async def get(self, request): "autocomplete_url": "{}/-/autocomplete".format( self.ds.urls.table(database_name, table_name) ), - "label_column": await db.label_column_for_table(table_name), + "label_columns": await db.label_columns_for_table( + table_name + ), } ) else: diff --git a/datasette/views/table.py b/datasette/views/table.py index 1fc151e696..94d9de7b9a 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -259,18 +259,23 @@ def __str__(self): return json.dumps(d, default=repr, indent=2) -def row_label_from_label_column(row, label_column): - if not label_column: +def row_label_from_label_columns(row, label_columns): + if not label_columns: return None - try: - value = row[label_column] - except (KeyError, IndexError): - return None - if isinstance(value, dict): - value = value.get("label") - if value is None or value == "": + values = [] + for label_column in label_columns: + try: + value = row[label_column] + except (KeyError, IndexError): + continue + if isinstance(value, dict): + value = value.get("label") + if value is None or value == "": + continue + values.append(str(value)) + if not values: return None - return str(value) + return " ".join(values) async def run_sequential(*args): @@ -448,6 +453,7 @@ async def _table_page_data( is_view, table_insert_ui, table_alter_ui, + label_columns_ui, ): data = { "database": database_name, @@ -458,6 +464,8 @@ async def _table_page_data( data["insertRow"] = table_insert_ui if table_alter_ui: data["alterTable"] = table_alter_ui + if label_columns_ui: + data["labelColumns"] = label_columns_ui if not is_view: foreign_keys = await _foreign_key_autocomplete_urls( datasette, request, db, database_name, table_name @@ -606,6 +614,29 @@ async def _table_alter_ui( return data +async def _table_label_columns_ui(datasette, request, db, database_name, table_name): + if not await datasette.allowed( + action="set-label-columns", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return None + + column_details = await db.table_column_details(table_name) + columns = [column.name for column in column_details if not column.hidden] + current = await db.label_columns_for_table(table_name) + explicit = await datasette.get_label_columns(database_name, table_name) + return { + "path": "{}/-/set-label-columns".format( + datasette.urls.table(database_name, table_name) + ), + "tableName": table_name, + "columns": columns, + "current": current, + "isOverridden": explicit is not None, + } + + async def display_columns_and_rows( datasette, database_name, @@ -645,9 +676,9 @@ async def display_columns_and_rows( pks_for_display = pks if not pks_for_display: pks_for_display = ["rowid"] - label_column = None + label_columns = None if link_column: - label_column = await db.label_column_for_table(table_name) + label_columns = await db.label_columns_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( @@ -695,7 +726,7 @@ 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_label = row_label_from_label_columns(row, label_columns) row_action_label = pk_path if row_label and row_label != pk_path: row_action_label = "{} {}".format(pk_path, row_label) @@ -1317,6 +1348,95 @@ async def post(self, request): ) +class TableSetLabelColumnsView(BaseView): + name = "table-set-label-columns" + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + try: + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + + database_name = resolved.db.name + table_name = resolved.table + + if not await self.ds.allowed( + action="set-label-columns", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied"], 403) + + content_type = request.headers.get("content-type") or "" + if not content_type.startswith("application/json"): + return _error(["Invalid content-type, must be application/json"], 400) + + try: + data = await request.json() + except json.JSONDecodeError as e: + return _error(["Invalid JSON: {}".format(e)], 400) + + if not isinstance(data, dict): + return _error(["JSON must be a dictionary"], 400) + + invalid_keys = set(data.keys()) - {"columns"} + if invalid_keys: + return _error( + ['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))], + 400, + ) + + if "columns" not in data: + return _error(['"columns" is required'], 400) + columns = data["columns"] + + if columns is None: + await self.ds.remove_label_columns(database_name, table_name) + return Response.json( + { + "ok": True, + "database": database_name, + "table": table_name, + "columns": None, + }, + status=200, + ) + + if ( + not isinstance(columns, list) + or not columns + or not all(isinstance(c, str) for c in columns) + ): + return _error( + ['"columns" must be a non-empty list of strings, or null'], 400 + ) + + if len(set(columns)) != len(columns): + return _error(['"columns" must not contain duplicates'], 400) + + column_details = await self.ds._get_resource_column_details( + database_name, table_name + ) + for column in columns: + if column not in column_details: + return _error(["Column not found: {}".format(column)], 400) + + await self.ds.set_label_columns(database_name, table_name, columns) + + return Response.json( + { + "ok": True, + "database": database_name, + "table": table_name, + "columns": columns, + }, + status=200, + ) + + class TableDropView(BaseView): name = "table-drop" @@ -1443,7 +1563,7 @@ def _autocomplete_prefix_like(column): return "{} like :prefix escape char(92)".format(escape_sqlite(column)) -def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True): +def _autocomplete_order_by(pks, label_columns, exact_pk, label_matches_first=True): clauses = [] if exact_pk: clauses.append( @@ -1451,15 +1571,18 @@ def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True escape_sqlite(pks[0]) ) ) - if label_column: - label_like = _autocomplete_like(label_column) + if label_columns: + label_likes = [_autocomplete_like(column) for column in label_columns] + any_label_like = " or ".join(label_likes) if label_matches_first: - clauses.append("case when {} then 0 else 1 end".format(label_like)) - clauses.append( - "case when {} then length(cast({} as text)) end".format( - label_like, escape_sqlite(label_column) + clauses.append("case when {} then 0 else 1 end".format(any_label_like)) + total_length = " + ".join( + "case when {} then length(cast({} as text)) else 0 end".format( + label_like, escape_sqlite(column) ) + for label_like, column in zip(label_likes, label_columns) ) + clauses.append("case when {} then {} end".format(any_label_like, total_length)) else: clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0]))) clauses.extend(escape_sqlite(pk) for pk in pks) @@ -1476,12 +1599,15 @@ def _autocomplete_initial_order_by(pks): return ", ".join(order_by) -def _autocomplete_response_rows(rows, pks, label_column): +def _autocomplete_response_rows(rows, pks, label_columns): response_rows = [] for row in rows: item = {"pks": {pk: row[pk] for pk in pks}} - if label_column: - item["label"] = row[label_column] + if label_columns: + if len(label_columns) == 1: + item["label"] = row[label_columns[0]] + else: + item["label"] = row_label_from_label_columns(row, label_columns) response_rows.append(item) return response_rows @@ -1511,10 +1637,8 @@ async def get(self, request): pks = await db.primary_keys(table_name) if not pks: pks = ["rowid"] - label_column = await db.label_column_for_table(table_name) - select_columns = list( - dict.fromkeys(pks + ([label_column] if label_column else [])) - ) + label_columns = await db.label_columns_for_table(table_name) + select_columns = list(dict.fromkeys(pks + label_columns)) select_sql = ", ".join(escape_sqlite(column) for column in select_columns) q = request.args.get("q") or "" initial_arg = request.args.get("_initial") @@ -1533,11 +1657,12 @@ async def get(self, request): } like_columns = pks[:] - if label_column and label_column not in like_columns: - like_columns.append(label_column) + for label_column in label_columns: + if label_column not in like_columns: + like_columns.append(label_column) where_sql = " or ".join(_autocomplete_like(column) for column in like_columns) exact_pk = len(pks) == 1 - order_by = _autocomplete_order_by(pks, label_column, exact_pk) + order_by = _autocomplete_order_by(pks, label_columns, exact_pk) if initial: where_sql = "1 = 1" @@ -1591,7 +1716,7 @@ async def get(self, request): return Response.json({"rows": []}) return Response.json( - {"rows": _autocomplete_response_rows(results.rows, pks, label_column)} + {"rows": _autocomplete_response_rows(results.rows, pks, label_columns)} ) @@ -2176,11 +2301,11 @@ async def table_view_data( # Expand labeled columns if requested expanded_columns = [] - # List of (fk_dict, label_column-or-None) pairs for that table + # List of (fk_dict, label_columns-or-None) pairs for that table expandable_columns = [] for fk in await db.foreign_keys_for_table(table_name): - label_column = await db.label_column_for_table(fk["other_table"]) - expandable_columns.append((fk, label_column)) + label_columns = await db.label_columns_for_table(fk["other_table"]) + expandable_columns.append((fk, label_columns or None)) columns_to_expand = None try: @@ -2384,6 +2509,9 @@ async def table_view_data( table_alter_ui = await _table_alter_ui( datasette, request, db, database_name, table_name, is_view, pks ) + label_columns_ui = await _table_label_columns_ui( + datasette, request, db, database_name, table_name + ) data["table_insert_ui"] = table_insert_ui data["table_alter_ui"] = table_alter_ui data["table_page_data"] = await _table_page_data( @@ -2395,6 +2523,7 @@ async def table_view_data( is_view=is_view, table_insert_ui=table_insert_ui, table_alter_ui=table_alter_ui, + label_columns_ui=label_columns_ui, ) return data, rows[:page_size], columns, expanded_columns, sql, next_url diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index db659c807e..221cb02be8 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -1070,14 +1070,15 @@ async def resolve(self, context): class ExpandableColumnsExtra(Extra): - description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``." + description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_columns)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_columns`` is a list of label column(s) in the referenced table, or ``None``." docs_note = "See :ref:`expand_foreign_keys` for how to expand these labels." example = ExtraExample( "/fixtures/facetable.json?_extra=expandable_columns", note=( - "Each item is a ``[foreign_key, label_column]`` pair: the " - "foreign key relationship, then the column in the other table " - "that would be used as the label for each expanded value." + "Each item is a ``[foreign_key, label_columns]`` pair: the " + "foreign key relationship, then the list of column(s) in the " + "other table that would be used as the label for each expanded " + "value." ), ) scopes = {ExtraScope.TABLE} @@ -1086,8 +1087,8 @@ async def resolve(self, context): expandables = [] db = context.datasette.databases[context.database_name] for fk in await db.foreign_keys_for_table(context.table_name): - label_column = await db.label_column_for_table(fk["other_table"]) - expandables.append((fk, label_column)) + label_columns = await db.label_columns_for_table(fk["other_table"]) + expandables.append((fk, label_columns or None)) return expandables diff --git a/docs/authentication.rst b/docs/authentication.rst index 8101699ca0..9d97a3b9db 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -50,7 +50,7 @@ The one exception is the "root" account, which you can sign into while using Dat The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including: * All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.) -* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``) +* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``set-label-columns``, ``drop-table``) * Debug permissions (``permissions-debug``, ``debug-menu``) * Any custom permissions defined by plugins @@ -903,7 +903,7 @@ To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs`` } .. [[[end]]] -Other table-scoped write permissions, including ``set-column-type``, can be configured in the same place. +Other table-scoped write permissions, including ``set-column-type`` and ``set-label-columns``, can be configured in the same place. And for ``insert-row`` against the ``reports`` table in that ``docs`` database: @@ -1408,6 +1408,18 @@ set-column-type Actor is allowed to set assigned :ref:`column types ` for columns in a table. +``resource`` - ``datasette.resources.TableResource(database, table)`` + ``database`` is the name of the database (string) + + ``table`` is the name of the table (string) + +.. _actions_set_label_columns: + +set-label-columns +----------------- + +Actor is allowed to set the :ref:`label column(s) ` used to build a display label for rows in a table. + ``resource`` - ``datasette.resources.TableResource(database, table)`` ``database`` is the name of the database (string) diff --git a/docs/configuration.rst b/docs/configuration.rst index d01f515395..74f042259a 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -936,6 +936,50 @@ You can override this automatic detection by specifying which column should be u } .. [[[end]]] +``label_column`` can also be set to a list of columns, in which case the values of those columns will be joined with a space to build the label: + +.. [[[cog + config_example(cog, textwrap.dedent( + """ + databases: + mydatabase: + tables: + example_table: + label_column: [first_name, last_name] + """).strip() + ) +.. ]]] + +.. tab:: datasette.yaml + + .. code-block:: yaml + + databases: + mydatabase: + tables: + example_table: + label_column: [first_name, last_name] + +.. tab:: datasette.json + + .. code-block:: json + + { + "databases": { + "mydatabase": { + "tables": { + "example_table": { + "label_column": [ + "first_name", + "last_name" + ] + } + } + } + } + } +.. [[[end]]] + .. _table_configuration_hidden: ``hidden`` diff --git a/docs/internals.rst b/docs/internals.rst index 9f75640fc2..c53f68327c 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2228,8 +2228,8 @@ The ``Database`` class also provides properties and methods for introspecting th ``await db.fts_table(table)`` - string or None The name of the FTS table associated with this table, if one exists. -``await db.label_column_for_table(table)`` - string or None - The label column that is associated with this table - either automatically detected or using the ``"label_column"`` key in configuration, see :ref:`table_configuration_label_column`. +``await db.label_columns_for_table(table)`` - list of strings + The label column(s) associated with this table - either automatically detected, set using the ``"label_column"`` key in configuration, or configured at runtime via the ``set-label-columns`` API, see :ref:`table_configuration_label_column`. Returns an empty list if no label column could be determined. ``await db.foreign_keys_for_table(table)`` - list of dictionaries Details of columns in this table which are foreign keys to other tables. A list of dictionaries where each dictionary is shaped like this: ``{"column": string, "other_table": string, "other_column": string}``. diff --git a/docs/json_api.rst b/docs/json_api.rst index eca22fdc3d..5eb81b8ca2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -738,11 +738,11 @@ The available table extras are listed below. false ``expandable_columns`` - List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.) + List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_columns)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_columns`` is a list of label column(s) in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.) ``GET /fixtures/facetable.json?_extra=expandable_columns`` - Each item is a ``[foreign_key, label_column]`` pair: the foreign key relationship, then the column in the other table that would be used as the label for each expanded value. + Each item is a ``[foreign_key, label_columns]`` pair: the foreign key relationship, then the list of column(s) in the other table that would be used as the label for each expanded value. .. code-block:: json @@ -753,7 +753,7 @@ The available table extras are listed below. "other_table": "facet_cities", "other_column": "id" }, - "name" + ["name"] ] ] @@ -2426,6 +2426,48 @@ This API stores the assignment in Datasette's internal database, so it can be us Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. +.. _TableSetLabelColumnsView: + +Setting label columns +~~~~~~~~~~~~~~~~~~~~~ + +To set the label column(s) for a table, make a ``POST`` to ``//
{{ suggestion.database }} {{ suggestion.table }}{{ suggestion.label_column }}{{ suggestion.label_columns|join(", ") }}
/-/set-label-columns``. This requires the :ref:`actions_set_label_columns` permission. + +:: + + POST //
/-/set-label-columns + Content-Type: application/json + Authorization: Bearer dstok_ + +.. code-block:: json + + { + "columns": ["first_name", "last_name"] + } + +This will return a ``200`` response like this: + +.. code-block:: json + + { + "ok": true, + "database": "data", + "table": "people", + "columns": ["first_name", "last_name"] + } + +To revert to Datasette's automatic label column detection, set ``columns`` to ``null``: + +.. code-block:: json + + { + "columns": null + } + +This API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones. See :ref:`table_configuration_label_column` for more about label columns. + +Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. + .. _TableDropView: Dropping tables diff --git a/tests/test_label_column_for_table.py b/tests/test_label_column_for_table.py index 7667b5955d..103b3fc2b7 100644 --- a/tests/test_label_column_for_table.py +++ b/tests/test_label_column_for_table.py @@ -5,93 +5,154 @@ @pytest.mark.asyncio @pytest.mark.parametrize( - "create_sql,table_name,config,expected_label_column", + "create_sql,table_name,config,expected_label_columns", [ - # Explicit label_column + # Explicit label_column, single string ( "create table t1 (id integer primary key, name text, title text);", "t1", {"t1": {"label_column": "title"}}, - "title", + ["title"], + ), + # Explicit label_column, list of columns + ( + "create table t1b (id integer primary key, first_name text, last_name text);", + "t1b", + {"t1b": {"label_column": ["first_name", "last_name"]}}, + ["first_name", "last_name"], ), # Single unique text column ( "create table t2 (id integer primary key, name2 text unique, title text);", "t2", {}, - "name2", + ["name2"], ), ( "create table t3 (id integer primary key, title2 text unique, name text);", "t3", {}, - "title2", + ["title2"], ), # Two unique text columns means it cannot decide on one ( "create table t3x (id integer primary key, name2 text unique, title2 text unique);", "t3x", {}, - None, + [], ), # Name or title column ( "create table t4 (id integer primary key, name text);", "t4", {}, - "name", + ["name"], ), ( "create table t5 (id integer primary key, title text);", "t5", {}, - "title", + ["title"], ), # But not if there are multiple non-unique text that are not called title ( "create table t5x (id integer primary key, other1 text, other2 text);", "t5x", {}, - None, + [], ), ( "create table t6 (id integer primary key, Name text);", "t6", {}, - "Name", + ["Name"], ), ( "create table t7 (id integer primary key, Title text);", "t7", {}, - "Title", + ["Title"], ), # Two columns, one of which is id ( "create table t8 (id integer primary key, content text);", "t8", {}, - "content", + ["content"], ), ( "create table t9 (pk integer primary key, content text);", "t9", {}, - "content", + ["content"], ), ], ) -async def test_label_column_for_table( - create_sql, table_name, config, expected_label_column +async def test_label_columns_for_table( + create_sql, table_name, config, expected_label_columns ): - """Test cases for label_column_for_table method""" + """Test cases for label_columns_for_table method""" ds = Datasette() - db = ds.add_database(Database(ds, memory_name="test_label_column_for_table")) + db = ds.add_database(Database(ds, memory_name="test_label_columns_for_table")) await db.execute_write_script(create_sql) + await ds.invoke_startup() if config: - ds.config = {"databases": {"test_label_column_for_table": {"tables": config}}} - actual_label_column = await db.label_column_for_table(table_name) - if expected_label_column is None: - assert actual_label_column is None - else: - assert actual_label_column == expected_label_column + ds.config = {"databases": {"test_label_columns_for_table": {"tables": config}}} + # label_column config is only seeded into the internal DB once, so + # re-apply it explicitly for tables configured after startup. + await ds._apply_label_columns_config() + actual_label_columns = await db.label_columns_for_table(table_name) + assert actual_label_columns == expected_label_columns + + +@pytest.mark.asyncio +async def test_internal_db_override_wins_over_config(): + ds = Datasette() + db = ds.add_database(Database(ds, memory_name="test_label_columns_override")) + await db.execute_write_script( + "create table t1 (id integer primary key, name text, title text);" + ) + await ds.invoke_startup() + ds.config = { + "databases": { + "test_label_columns_override": {"tables": {"t1": {"label_column": "title"}}} + } + } + await ds._apply_label_columns_config() + assert await db.label_columns_for_table("t1") == ["title"] + + await ds.set_label_columns("test_label_columns_override", "t1", ["name"]) + assert await db.label_columns_for_table("t1") == ["name"] + + # Re-applying config should NOT stomp the override (seed-once semantics) + await ds._apply_label_columns_config() + assert await db.label_columns_for_table("t1") == ["name"] + + +@pytest.mark.asyncio +async def test_removing_label_columns_override_reverts_to_config(): + ds = Datasette() + db = ds.add_database(Database(ds, memory_name="test_label_columns_remove")) + await db.execute_write_script( + "create table t1 (id integer primary key, name text, title text);" + ) + await ds.invoke_startup() + ds.config = { + "databases": { + "test_label_columns_remove": {"tables": {"t1": {"label_column": "title"}}} + } + } + await ds._apply_label_columns_config() + + await ds.set_label_columns("test_label_columns_remove", "t1", ["name"]) + assert await db.label_columns_for_table("t1") == ["name"] + + await ds.remove_label_columns("test_label_columns_remove", "t1") + # No row left in the internal DB, so falls through to auto-detection + # (both "name" and "title" columns exist, but "name" wins the "name or + # title" heuristic tie-break since it is listed first). + assert await db.label_columns_for_table("t1") == ["name"] + + # A subsequent startup re-seeds the config value, since no row exists + await ds._apply_label_columns_config() + assert await db.label_columns_for_table("t1") == ["title"] diff --git a/tests/test_label_columns_api.py b/tests/test_label_columns_api.py new file mode 100644 index 0000000000..6f48f4705d --- /dev/null +++ b/tests/test_label_columns_api.py @@ -0,0 +1,363 @@ +import time + +import pytest +from bs4 import BeautifulSoup as Soup + +from datasette.app import Datasette +from datasette.utils import sqlite3 + + +def write_token(ds, actor_id="root", permissions=None): + to_sign = {"a": actor_id, "token": "dstok", "t": int(time.time())} + if permissions: + to_sign["_r"] = {"a": permissions} + return "dstok_{}".format(ds.sign(to_sign, namespace="token")) + + +def _headers(token): + return { + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + } + + +def _window_data_from_html(html, variable_name): + soup = Soup(html, "html.parser") + scripts = soup.find_all("script") + matching_scripts = [ + script for script in scripts if variable_name in (script.string or "") + ] + assert len(matching_scripts) == 1 + script_text = matching_scripts[0].string.strip() + prefix = f"window.{variable_name} = " + assert script_text.startswith(prefix) + import json + + return json.loads(script_text[len(prefix) :].rstrip(";")) + + +@pytest.fixture +def ds_lc(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute( + "create table people (id integer primary key, first_name text, last_name text)" + ) + db.execute("insert into people values (1, 'Alice', 'Smith')") + db.commit() + ds = Datasette([db_path]) + ds.root_enabled = True + yield ds + ds.close() + + +@pytest.fixture +def ds_lc_editor_permission(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute( + "create table people (id integer primary key, first_name text, last_name text)" + ) + db.execute("insert into people values (1, 'Alice', 'Smith')") + db.commit() + ds = Datasette( + [db_path], + config={ + "databases": { + "data": { + "tables": { + "people": { + "permissions": {"set-label-columns": {"id": "editor"}}, + } + } + } + } + }, + ) + ds.root_enabled = True + yield ds + ds.close() + + +@pytest.mark.asyncio +async def test_set_label_columns_api_single_column(ds_lc): + await ds_lc.invoke_startup() + token = write_token(ds_lc, permissions=["slc"]) + response = await ds_lc.client.post( + "/data/people/-/set-label-columns", + json={"columns": ["first_name"]}, + headers=_headers(token), + ) + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "database": "data", + "table": "people", + "columns": ["first_name"], + } + assert await ds_lc.get_label_columns("data", "people") == ["first_name"] + + +@pytest.mark.asyncio +async def test_set_label_columns_api_multiple_columns(ds_lc): + await ds_lc.invoke_startup() + token = write_token(ds_lc, permissions=["slc"]) + response = await ds_lc.client.post( + "/data/people/-/set-label-columns", + json={"columns": ["first_name", "last_name"]}, + headers=_headers(token), + ) + assert response.status_code == 200 + assert response.json()["columns"] == ["first_name", "last_name"] + db = ds_lc.databases["data"] + assert await db.label_columns_for_table("people") == ["first_name", "last_name"] + + +@pytest.mark.asyncio +async def test_clear_label_columns_api(ds_lc): + await ds_lc.invoke_startup() + await ds_lc.set_label_columns("data", "people", ["first_name"]) + token = write_token(ds_lc, permissions=["slc"]) + response = await ds_lc.client.post( + "/data/people/-/set-label-columns", + json={"columns": None}, + headers=_headers(token), + ) + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "database": "data", + "table": "people", + "columns": None, + } + assert await ds_lc.get_label_columns("data", "people") is None + + +@pytest.mark.asyncio +async def test_set_label_columns_reflected_in_foreign_key_labels(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute( + "create table people (id integer primary key, first_name text, last_name text)" + ) + db.execute("insert into people values (1, 'Alice', 'Smith')") + db.execute( + "create table orders (id integer primary key, person_id integer, " + "foreign key (person_id) references people(id))" + ) + db.execute("insert into orders values (1, 1)") + db.commit() + ds = Datasette([db_path]) + ds.root_enabled = True + try: + await ds.invoke_startup() + await ds.set_label_columns("data", "people", ["first_name", "last_name"]) + response = await ds.client.get( + "/data/orders.json?_labels=on", actor={"id": "root"} + ) + assert response.status_code == 200 + data = response.json() + assert data["rows"][0]["person_id"] == {"value": 1, "label": "Alice Smith"} + finally: + db.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body,special_case,expected_status,expected_errors", + ( + ( + {"columns": ["first_name"]}, + "no_permission", + 403, + ["Permission denied"], + ), + ( + None, + "invalid_json", + 400, + [ + "Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" + ], + ), + ( + {"columns": ["first_name"]}, + "invalid_content_type", + 400, + ["Invalid content-type, must be application/json"], + ), + ( + [], + None, + 400, + ["JSON must be a dictionary"], + ), + ( + {}, + None, + 400, + ['"columns" is required'], + ), + ( + {"columns": "first_name"}, + None, + 400, + ['"columns" must be a non-empty list of strings, or null'], + ), + ( + {"columns": []}, + None, + 400, + ['"columns" must be a non-empty list of strings, or null'], + ), + ( + {"columns": [1]}, + None, + 400, + ['"columns" must be a non-empty list of strings, or null'], + ), + ( + {"columns": ["first_name", "first_name"]}, + None, + 400, + ['"columns" must not contain duplicates'], + ), + ( + {"columns": ["not_a_column"]}, + None, + 400, + ["Column not found: not_a_column"], + ), + ( + {"columns": ["first_name"], "extra": True}, + None, + 400, + ['Invalid parameter: "extra"'], + ), + ), +) +async def test_set_label_columns_api_errors( + ds_lc, body, special_case, expected_status, expected_errors +): + await ds_lc.invoke_startup() + token = write_token( + ds_lc, + permissions=(["slc"] if special_case != "no_permission" else ["vi"]), + ) + kwargs = { + "headers": { + "Authorization": f"Bearer {token}", + "Content-Type": ( + "text/plain" + if special_case == "invalid_content_type" + else "application/json" + ), + } + } + if special_case == "invalid_json": + kwargs["content"] = "{bad json" + else: + kwargs["json"] = body + response = await ds_lc.client.post("/data/people/-/set-label-columns", **kwargs) + assert response.status_code == expected_status + assert response.json() == {"ok": False, "errors": expected_errors} + + +@pytest.mark.asyncio +async def test_set_label_columns_api_works_for_immutable_database(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "immutable.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute( + "create table people (id integer primary key, first_name text, last_name text)" + ) + db.commit() + ds = Datasette([], immutables=[db_path]) + ds.root_enabled = True + try: + await ds.invoke_startup() + token = write_token(ds, permissions=["slc"]) + response = await ds.client.post( + "/immutable/people/-/set-label-columns", + json={"columns": ["first_name"]}, + headers=_headers(token), + ) + assert response.status_code == 200 + assert await ds.get_label_columns("immutable", "people") == ["first_name"] + finally: + db.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() + + +def table_data_from_soup(soup): + import json + import re + + table_script = [ + s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "") + ][0] + match = re.search( + r"window\._datasetteTableData\s*=\s*({.*?});", + table_script.string, + re.DOTALL, + ) + return json.loads(match.group(1)) + + +@pytest.mark.asyncio +async def test_set_label_columns_action_button_hidden_without_permission(ds_lc): + await ds_lc.invoke_startup() + response = await ds_lc.client.get("/data/people") + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + assert soup.select_one('button[data-table-action="set-label-columns"]') is None + assert "labelColumns" not in table_data_from_soup(soup) + + +@pytest.mark.asyncio +async def test_set_label_columns_action_button_and_data(ds_lc): + await ds_lc.invoke_startup() + response = await ds_lc.client.get("/data/people", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + + button = soup.select_one( + 'button.action-menu-button[data-table-action="set-label-columns"]' + ) + assert button is not None + assert button["aria-label"] == "Set label columns for people" + description = button.find("span", class_="dropdown-description") + assert description.text.strip() == ( + "Choose which column(s) are used to label this table's rows." + ) + + data = table_data_from_soup(soup)["labelColumns"] + assert data["path"] == "/data/people/-/set-label-columns" + assert data["tableName"] == "people" + assert data["columns"] == ["id", "first_name", "last_name"] + assert data["current"] == [] + assert data["isOverridden"] is False + + +@pytest.mark.asyncio +async def test_set_label_columns_ui_data_reflects_override(ds_lc): + await ds_lc.invoke_startup() + await ds_lc.set_label_columns("data", "people", ["first_name", "last_name"]) + response = await ds_lc.client.get("/data/people", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + data = table_data_from_soup(soup)["labelColumns"] + assert data["current"] == ["first_name", "last_name"] + assert data["isOverridden"] is True diff --git a/tests/test_playwright.py b/tests/test_playwright.py index ee396de58a..4466f97603 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -138,6 +138,7 @@ def write_playwright_config(config_path): "insert-row": True, "update-row": True, "delete-row": True, + "set-label-columns": True, }, }, "defaults_demo": { @@ -890,3 +891,45 @@ def test_delete_row_flow_removes_row(page, datasette_server): page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for() page.locator('tr[data-row="1"]').wait_for(state="detached") assert project_rows(datasette_server, id=1) == [] + + +@pytest.mark.playwright +def test_set_label_columns_flow(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="set-label-columns"]').click() + + dialog = page.locator("#table-label-columns-dialog") + dialog.wait_for() + assert ( + dialog.locator(".modal-title").inner_text() + == "Set label column(s) for projects" + ) + assert dialog.locator(".table-label-columns-reset").is_enabled() + + def checked_column_names(): + return dialog.locator( + ".table-label-columns-row:has(.table-label-columns-checkbox:checked) " + ".table-label-columns-name" + ).all_inner_texts() + + assert checked_column_names() == ["title"] + + notes_row = dialog.locator(".table-label-columns-row", has_text="notes") + notes_row.locator(".table-label-columns-checkbox").check() + dialog.locator(".table-label-columns-save").click() + dialog.wait_for(state="hidden") + + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="set-label-columns"]').click() + dialog.wait_for() + assert checked_column_names() == ["title", "notes"] + + dialog.locator(".table-label-columns-reset").click() + dialog.wait_for(state="hidden") + + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="set-label-columns"]').click() + dialog.wait_for() + assert checked_column_names() == ["title"] + assert dialog.locator(".table-label-columns-reset").is_disabled()