mirror of
https://github.com/simonw/datasette.git
synced 2026-07-14 03:24:42 +02:00
Compare commits
1 commit
main
...
claude/dat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8592ce2ed4 |
20 changed files with 1437 additions and 113 deletions
|
|
@ -92,6 +92,7 @@ from .views.table import (
|
|||
TableInsertView,
|
||||
TableUpsertView,
|
||||
TableSetColumnTypeView,
|
||||
TableSetLabelColumnsView,
|
||||
TableDropView,
|
||||
TableFragmentView,
|
||||
table_view,
|
||||
|
|
@ -119,6 +120,7 @@ from .utils import (
|
|||
module_from_path,
|
||||
move_plugins_and_allow,
|
||||
move_table_config,
|
||||
normalize_label_columns,
|
||||
parse_metadata,
|
||||
resolve_env_secrets,
|
||||
resolve_routes,
|
||||
|
|
@ -830,6 +832,8 @@ class Datasette:
|
|||
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 @@ class Datasette:
|
|||
[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 @@ class Datasette:
|
|||
)
|
||||
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 @@ class Datasette:
|
|||
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 @@ class Datasette:
|
|||
TableSetColumnTypeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||
)
|
||||
add_route(
|
||||
TableSetLabelColumnsView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-label-columns$",
|
||||
)
|
||||
add_route(
|
||||
TableFragmentView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
||||
|
|
|
|||
|
|
@ -665,12 +665,10 @@ class Database:
|
|||
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 @@ class Database:
|
|||
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 @@ class Database:
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,24 +6,41 @@ from datasette.resources import TableResource
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
'<label class="table-label-columns-label">' +
|
||||
'<input type="checkbox" class="table-label-columns-checkbox">' +
|
||||
'<span class="table-label-columns-name"></span>' +
|
||||
"</label>" +
|
||||
'<div class="table-label-columns-move-controls">' +
|
||||
'<button type="button" class="table-label-columns-move-up" aria-label="Move column up">↑</button>' +
|
||||
'<button type="button" class="table-label-columns-move-down" aria-label="Move column down">↓</button>' +
|
||||
"</div>";
|
||||
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 = `
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="table-label-columns-title">Set label column(s)</span>
|
||||
</div>
|
||||
<form class="table-label-columns-form" novalidate>
|
||||
<p class="table-label-columns-error" id="table-label-columns-error" role="alert" tabindex="-1" hidden></p>
|
||||
<p class="table-label-columns-help">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.</p>
|
||||
<ul class="table-label-columns-list"></ul>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost table-label-columns-reset">Reset to automatic</button>
|
||||
<button type="button" class="btn btn-ghost table-label-columns-cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary table-label-columns-save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
<p class="message-error">{{ error }}</p>
|
||||
{% elif autocomplete_url %}
|
||||
<h2>{{ database_name }} / {{ table_name }}</h2>
|
||||
{% if label_column %}
|
||||
<p>Label column: <code>{{ label_column }}</code></p>
|
||||
{% if label_columns %}
|
||||
<p>Label column{{ "s" if label_columns|length > 1 else "" }}: <code>{{ label_columns|join(", ") }}</code></p>
|
||||
{% else %}
|
||||
<p>No label column detected. Results will use primary key values.</p>
|
||||
{% endif %}
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
<tr>
|
||||
<td>{{ suggestion.database }}</td>
|
||||
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
|
||||
<td><code>{{ suggestion.label_column }}</code></td>
|
||||
<td><code>{{ suggestion.label_columns|join(", ") }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -1504,6 +1504,18 @@ _table_config_keys = (
|
|||
)
|
||||
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from . import Context, from_extra
|
|||
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 @@ class RowView(BaseView):
|
|||
"<strong>{}</strong>".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 @@ class RowView(BaseView):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -121,13 +121,13 @@ class AutocompleteDebugView(BaseView):
|
|||
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 @@ class AutocompleteDebugView(BaseView):
|
|||
"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:
|
||||
|
|
|
|||
|
|
@ -259,18 +259,23 @@ class Row:
|
|||
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):
|
||||
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
|
||||
if isinstance(value, dict):
|
||||
value = value.get("label")
|
||||
if value is None or value == "":
|
||||
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 @@ class TableSetColumnTypeView(BaseView):
|
|||
)
|
||||
|
||||
|
||||
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 @@ class TableAutocompleteView(BaseView):
|
|||
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 @@ class TableAutocompleteView(BaseView):
|
|||
}
|
||||
|
||||
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 @@ class TableAutocompleteView(BaseView):
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1070,14 +1070,15 @@ class PrivateExtra(Extra):
|
|||
|
||||
|
||||
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 @@ class ExpandableColumnsExtra(Extra):
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <table_configuration_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) <table_configuration_label_column>` 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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}``.
|
||||
|
|
|
|||
|
|
@ -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 ``/<database>/<table>/-/set-label-columns``. This requires the :ref:`actions_set_label_columns` permission.
|
||||
|
||||
::
|
||||
|
||||
POST /<database>/<table>/-/set-label-columns
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer dstok_<rest-of-token>
|
||||
|
||||
.. 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
|
||||
|
|
|
|||
|
|
@ -5,93 +5,154 @@ from datasette.app import Datasette
|
|||
|
||||
@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"]
|
||||
|
|
|
|||
363
tests/test_label_columns_api.py
Normal file
363
tests/test_label_columns_api.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue