mirror of
https://github.com/simonw/datasette.git
synced 2026-06-21 16:24:40 +02:00
Add foreign key autocomplete to row forms
Expose single-primary-key foreign key autocomplete URLs in table page metadata and load the autocomplete component when needed. Enhance insert and edit dialogs to wrap foreign-key inputs with the autocomplete web component, show linked selected-row labels, reserve metadata space, and keep the dropdown as a fixed overlay above modal chrome. Add an explicit _initial=1 autocomplete mode for empty-field starter suggestions while keeping blank q responses empty by default, with tests for the endpoint and table metadata.
This commit is contained in:
parent
aa5fb7be3d
commit
574290fb23
8 changed files with 474 additions and 20 deletions
|
|
@ -2019,6 +2019,11 @@ class Datasette:
|
|||
|
||||
other_table = fk["other_table"]
|
||||
other_column = fk["other_column"]
|
||||
if other_column is None:
|
||||
other_pks = await db.primary_keys(other_table)
|
||||
if len(other_pks) != 1:
|
||||
return {}
|
||||
other_column = other_pks[0]
|
||||
visible, _ = await self.check_visibility(
|
||||
actor,
|
||||
action="view-table",
|
||||
|
|
|
|||
|
|
@ -1594,6 +1594,20 @@ textarea.row-edit-input {
|
|||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.row-edit-field-meta-autocomplete {
|
||||
line-height: 1.2;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.row-edit-fk-pk {
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.row-edit-fk-link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -1622,10 +1636,10 @@ datasette-autocomplete input[type="text"],
|
|||
left: 0;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 3px);
|
||||
z-index: 20;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
top: auto;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-list[hidden] {
|
||||
|
|
@ -1643,8 +1657,8 @@ datasette-autocomplete input[type="text"],
|
|||
}
|
||||
|
||||
.datasette-autocomplete-option[aria-selected="true"] {
|
||||
background: #eef4ff;
|
||||
box-shadow: inset 3px 0 0 #1a56db;
|
||||
background: var(--paper);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-status {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
this.boundKeydown = this.handleKeydown.bind(this);
|
||||
this.boundBlur = this.handleBlur.bind(this);
|
||||
this.boundFocus = this.handleFocus.bind(this);
|
||||
this.boundPositionListbox = this.positionListbox.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
|
@ -109,7 +110,7 @@
|
|||
}
|
||||
|
||||
handleFocus() {
|
||||
if (this.input.value.trim()) {
|
||||
if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) {
|
||||
this.scheduleSearch();
|
||||
}
|
||||
}
|
||||
|
|
@ -155,7 +156,8 @@
|
|||
|
||||
async search() {
|
||||
var query = this.input.value.trim();
|
||||
if (!query) {
|
||||
var initial = !query && this.hasAttribute("suggest-on-focus");
|
||||
if (!query && !initial) {
|
||||
this.close();
|
||||
this.status.textContent = "";
|
||||
return;
|
||||
|
|
@ -167,6 +169,11 @@
|
|||
|
||||
var url = new URL(src, location.href);
|
||||
url.searchParams.set("q", query);
|
||||
if (initial) {
|
||||
url.searchParams.set("_initial", "1");
|
||||
} else {
|
||||
url.searchParams.delete("_initial");
|
||||
}
|
||||
var fetchId = this.fetchId + 1;
|
||||
this.fetchId = fetchId;
|
||||
this.status.textContent = "Searching...";
|
||||
|
|
@ -225,9 +232,49 @@
|
|||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.status.textContent =
|
||||
this.results.length + (this.results.length === 1 ? " match" : " matches");
|
||||
this.positionListbox();
|
||||
this.setActiveIndex(0);
|
||||
}
|
||||
|
||||
positionListbox() {
|
||||
if (!this.input || !this.listbox || this.listbox.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
var gap = 3;
|
||||
var margin = 8;
|
||||
var inputRect = this.input.getBoundingClientRect();
|
||||
this.listbox.style.maxHeight = "";
|
||||
var defaultMaxHeight = parseFloat(
|
||||
window.getComputedStyle(this.listbox).maxHeight,
|
||||
);
|
||||
if (!Number.isFinite(defaultMaxHeight)) {
|
||||
defaultMaxHeight = 256;
|
||||
}
|
||||
var scrollHeight = Math.ceil(this.listbox.scrollHeight);
|
||||
var desiredHeight = Math.min(scrollHeight, defaultMaxHeight);
|
||||
var availableBelow = Math.max(
|
||||
0,
|
||||
(window.innerHeight || document.documentElement.clientHeight) -
|
||||
inputRect.bottom -
|
||||
gap -
|
||||
margin,
|
||||
);
|
||||
|
||||
this.listbox.style.left = inputRect.left + "px";
|
||||
this.listbox.style.top = inputRect.bottom + gap + "px";
|
||||
this.listbox.style.width = inputRect.width + "px";
|
||||
if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) {
|
||||
this.listbox.style.maxHeight = "none";
|
||||
} else {
|
||||
this.listbox.style.maxHeight =
|
||||
Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) +
|
||||
"px";
|
||||
}
|
||||
window.addEventListener("resize", this.boundPositionListbox);
|
||||
document.addEventListener("scroll", this.boundPositionListbox, true);
|
||||
}
|
||||
|
||||
setActiveIndex(index) {
|
||||
var options = this.listbox.querySelectorAll("[role='option']");
|
||||
if (!options.length) {
|
||||
|
|
@ -278,11 +325,17 @@
|
|||
if (this.listbox) {
|
||||
this.listbox.hidden = true;
|
||||
this.listbox.textContent = "";
|
||||
this.listbox.style.left = "";
|
||||
this.listbox.style.maxHeight = "";
|
||||
this.listbox.style.top = "";
|
||||
this.listbox.style.width = "";
|
||||
}
|
||||
if (this.input) {
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
window.removeEventListener("resize", this.boundPositionListbox);
|
||||
document.removeEventListener("scroll", this.boundPositionListbox, true);
|
||||
this.activeIndex = -1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,6 +496,158 @@ function tableInsertData() {
|
|||
return window._datasetteTableData && window._datasetteTableData.insertRow;
|
||||
}
|
||||
|
||||
function tableForeignKeys() {
|
||||
return (window._datasetteTableData && window._datasetteTableData.foreignKeys) || {};
|
||||
}
|
||||
|
||||
function foreignKeyAutocompleteUrl(column) {
|
||||
return tableForeignKeys()[column] || null;
|
||||
}
|
||||
|
||||
function autocompleteRowPk(row) {
|
||||
var pks = (row && row.pks) || {};
|
||||
var keys = Object.keys(pks);
|
||||
if (keys.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
return pks[keys[0]];
|
||||
}
|
||||
|
||||
function foreignKeyRowUrl(autocompleteUrl, pk) {
|
||||
var url = new URL(autocompleteUrl, location.href);
|
||||
if (!/\/-\/autocomplete\/?$/.test(url.pathname)) {
|
||||
return null;
|
||||
}
|
||||
url.pathname =
|
||||
url.pathname.replace(/\/-\/autocomplete\/?$/, "") + "/" + tildeEncode(pk);
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function foreignKeyLabelText(row) {
|
||||
var pk = autocompleteRowPk(row);
|
||||
var label = row && row.label;
|
||||
if (
|
||||
label !== null &&
|
||||
typeof label !== "undefined" &&
|
||||
String(label) !== String(pk)
|
||||
) {
|
||||
return String(label);
|
||||
}
|
||||
return "View row";
|
||||
}
|
||||
|
||||
function rowEditMetaTextWithoutCurrentValue(meta) {
|
||||
return (meta.dataset.baseMeta || "")
|
||||
.split(" · ")
|
||||
.filter(function (part) {
|
||||
return part !== "Current value: NULL";
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function updateRowEditForeignKeySeparator(meta) {
|
||||
var separator = meta.querySelector(".row-edit-fk-separator");
|
||||
if (!separator) {
|
||||
return;
|
||||
}
|
||||
var baseMeta = meta.querySelector(".row-edit-base-meta");
|
||||
var hasBaseMeta = !!(baseMeta && baseMeta.textContent);
|
||||
separator.textContent = hasBaseMeta ? " · " : "";
|
||||
separator.hidden = !hasBaseMeta;
|
||||
}
|
||||
|
||||
function updateRowEditFieldMetaHidden(meta) {
|
||||
var baseMeta = meta.querySelector(".row-edit-base-meta");
|
||||
var hasBaseMeta = !!(baseMeta && baseMeta.textContent);
|
||||
var foreignKeyLinkWrap = meta.querySelector(".row-edit-fk-link-wrap");
|
||||
var hasForeignKeyLink = foreignKeyLinkWrap && !foreignKeyLinkWrap.hidden;
|
||||
meta.hidden =
|
||||
meta.dataset.reserveSpace !== "1" && !hasBaseMeta && !hasForeignKeyLink;
|
||||
}
|
||||
|
||||
function setRowEditBaseMetaText(meta, text) {
|
||||
var baseMeta = meta.querySelector(".row-edit-base-meta");
|
||||
if (!baseMeta) {
|
||||
return;
|
||||
}
|
||||
baseMeta.textContent = text || "";
|
||||
updateRowEditForeignKeySeparator(meta);
|
||||
updateRowEditFieldMetaHidden(meta);
|
||||
}
|
||||
|
||||
function setForeignKeyMetaLink(meta, autocompleteUrl, row) {
|
||||
var wrap = meta.querySelector(".row-edit-fk-link-wrap");
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
var pkSpan = wrap.querySelector(".row-edit-fk-pk");
|
||||
var link = wrap.querySelector("a");
|
||||
var pk = autocompleteRowPk(row);
|
||||
var url =
|
||||
pk === null || typeof pk === "undefined"
|
||||
? null
|
||||
: foreignKeyRowUrl(autocompleteUrl, pk);
|
||||
if (!url) {
|
||||
wrap.hidden = true;
|
||||
pkSpan.textContent = "";
|
||||
link.removeAttribute("href");
|
||||
link.textContent = "";
|
||||
link.removeAttribute("aria-label");
|
||||
setRowEditBaseMetaText(meta, meta.dataset.baseMeta || "");
|
||||
updateRowEditFieldMetaHidden(meta);
|
||||
return;
|
||||
}
|
||||
setRowEditBaseMetaText(meta, rowEditMetaTextWithoutCurrentValue(meta));
|
||||
var pkText = String(pk);
|
||||
var linkText = foreignKeyLabelText(row);
|
||||
pkSpan.textContent = pkText;
|
||||
link.href = url;
|
||||
link.textContent = linkText;
|
||||
link.setAttribute(
|
||||
"aria-label",
|
||||
"Open referenced row " + pkText + " " + linkText + " in a new tab",
|
||||
);
|
||||
wrap.hidden = false;
|
||||
updateRowEditFieldMetaHidden(meta);
|
||||
}
|
||||
|
||||
async function resolveForeignKeyMetaLink(control, autocompleteUrl, meta) {
|
||||
var value = control.value.trim();
|
||||
if (!value) {
|
||||
setForeignKeyMetaLink(meta, autocompleteUrl, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var url = new URL(autocompleteUrl, location.href);
|
||||
url.searchParams.set("q", value);
|
||||
try {
|
||||
var response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP " + response.status);
|
||||
}
|
||||
var data = await response.json();
|
||||
if (control.value.trim() !== value) {
|
||||
return;
|
||||
}
|
||||
var rows = (data && data.rows) || [];
|
||||
var row = rows.find(function (candidate) {
|
||||
var pk = autocompleteRowPk(candidate);
|
||||
return pk !== null && typeof pk !== "undefined" && String(pk) === value;
|
||||
});
|
||||
setForeignKeyMetaLink(meta, autocompleteUrl, row || null);
|
||||
} catch (_error) {
|
||||
if (control.value.trim() === value) {
|
||||
setForeignKeyMetaLink(meta, autocompleteUrl, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tableInsertUrl() {
|
||||
var data = tableInsertData();
|
||||
if (data && data.path) {
|
||||
|
|
@ -819,6 +971,17 @@ function rowEditValueType(value) {
|
|||
return "string";
|
||||
}
|
||||
|
||||
function rowEditControlElement(control, autocompleteUrl) {
|
||||
if (!autocompleteUrl || control.nodeName !== "INPUT") {
|
||||
return control;
|
||||
}
|
||||
var autocomplete = document.createElement("datasette-autocomplete");
|
||||
autocomplete.setAttribute("src", autocompleteUrl);
|
||||
autocomplete.setAttribute("suggest-on-focus", "");
|
||||
autocomplete.appendChild(control);
|
||||
return autocomplete;
|
||||
}
|
||||
|
||||
function createRowEditField(column, value, isPk, columnType, index, options) {
|
||||
options = options || {};
|
||||
var field = document.createElement("div");
|
||||
|
|
@ -871,6 +1034,10 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
var meta = document.createElement("span");
|
||||
meta.id = metaId;
|
||||
meta.className = "row-edit-field-meta";
|
||||
if (options.autocompleteUrl) {
|
||||
meta.classList.add("row-edit-field-meta-autocomplete");
|
||||
meta.dataset.reserveSpace = "1";
|
||||
}
|
||||
var metaParts = [];
|
||||
if (isPk) {
|
||||
metaParts.push("Primary key");
|
||||
|
|
@ -888,7 +1055,49 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
if (columnType && columnType.type) {
|
||||
metaParts.push("Custom type: " + columnType.type);
|
||||
}
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
meta.dataset.baseMeta = metaParts.join(" · ");
|
||||
var baseMeta = document.createElement("span");
|
||||
baseMeta.className = "row-edit-base-meta";
|
||||
baseMeta.textContent = meta.dataset.baseMeta;
|
||||
meta.appendChild(baseMeta);
|
||||
if (options.autocompleteUrl) {
|
||||
var foreignKeyLinkWrap = document.createElement("span");
|
||||
foreignKeyLinkWrap.className = "row-edit-fk-link-wrap";
|
||||
foreignKeyLinkWrap.hidden = true;
|
||||
var foreignKeySeparator = document.createElement("span");
|
||||
foreignKeySeparator.className = "row-edit-fk-separator";
|
||||
foreignKeySeparator.textContent = meta.dataset.baseMeta ? " · " : "";
|
||||
foreignKeySeparator.hidden = !meta.dataset.baseMeta;
|
||||
foreignKeyLinkWrap.appendChild(foreignKeySeparator);
|
||||
var foreignKeyPk = document.createElement("span");
|
||||
foreignKeyPk.className = "row-edit-fk-pk";
|
||||
foreignKeyLinkWrap.appendChild(foreignKeyPk);
|
||||
foreignKeyLinkWrap.appendChild(document.createTextNode(" "));
|
||||
var foreignKeyLink = document.createElement("a");
|
||||
foreignKeyLink.className = "row-edit-fk-link";
|
||||
foreignKeyLink.target = "_blank";
|
||||
foreignKeyLink.rel = "noopener noreferrer";
|
||||
foreignKeyLinkWrap.appendChild(foreignKeyLink);
|
||||
meta.appendChild(foreignKeyLinkWrap);
|
||||
updateRowEditFieldMetaHidden(meta);
|
||||
}
|
||||
var controlElement = rowEditControlElement(control, options.autocompleteUrl);
|
||||
if (options.autocompleteUrl) {
|
||||
control.addEventListener("input", function () {
|
||||
setForeignKeyMetaLink(meta, options.autocompleteUrl, null);
|
||||
});
|
||||
control.addEventListener("change", function () {
|
||||
resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta);
|
||||
});
|
||||
controlElement.addEventListener("datasette-autocomplete-select", function (ev) {
|
||||
setForeignKeyMetaLink(
|
||||
meta,
|
||||
options.autocompleteUrl,
|
||||
ev.detail && ev.detail.row,
|
||||
);
|
||||
});
|
||||
resolveForeignKeyMetaLink(control, options.autocompleteUrl, meta);
|
||||
}
|
||||
|
||||
if (useDefaultInitially) {
|
||||
var defaultBlock = document.createElement("div");
|
||||
|
|
@ -939,14 +1148,14 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
|
|||
|
||||
defaultBlock.appendChild(defaultText);
|
||||
defaultBlock.appendChild(setValueButton);
|
||||
customWrap.appendChild(control);
|
||||
customWrap.appendChild(controlElement);
|
||||
customWrap.appendChild(useDefaultButton);
|
||||
controlWrap.appendChild(defaultBlock);
|
||||
controlWrap.appendChild(customWrap);
|
||||
} else {
|
||||
controlWrap.appendChild(control);
|
||||
controlWrap.appendChild(controlElement);
|
||||
}
|
||||
if (meta.textContent) {
|
||||
if (meta.textContent || options.autocompleteUrl) {
|
||||
controlWrap.appendChild(meta);
|
||||
}
|
||||
field.appendChild(label);
|
||||
|
|
@ -1307,6 +1516,7 @@ function renderRowEditFields(state, data) {
|
|||
columnTypes[column],
|
||||
index,
|
||||
{
|
||||
autocompleteUrl: foreignKeyAutocompleteUrl(column),
|
||||
primaryKeyReadonly: true,
|
||||
},
|
||||
),
|
||||
|
|
@ -1333,6 +1543,7 @@ function renderRowInsertFields(state, data) {
|
|||
column.column_type,
|
||||
index,
|
||||
{
|
||||
autocompleteUrl: foreignKeyAutocompleteUrl(column.name),
|
||||
defaultValue: column.default,
|
||||
hasDefault: column.has_default,
|
||||
notnull: column.notnull,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% if table_insert_ui %}
|
||||
<script>window._datasetteTableData = {{ {"tableUrl": urls.table(database, table), "insertRow": table_insert_ui}|tojson }};</script>
|
||||
{% else %}
|
||||
<script>window._datasetteTableData = {{ {"tableUrl": urls.table(database, table)}|tojson }};</script>
|
||||
{% endif %}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
|
|
|
|||
|
|
@ -250,6 +250,47 @@ def _column_value_type_for_insert_form(column_detail, column_type):
|
|||
return "string"
|
||||
|
||||
|
||||
async def _foreign_key_autocomplete_urls(
|
||||
datasette, request, db, database_name, table_name
|
||||
):
|
||||
autocomplete_urls = {}
|
||||
for fk in await db.foreign_keys_for_table(table_name):
|
||||
if not await db.table_exists(fk["other_table"]):
|
||||
continue
|
||||
other_pks = await db.primary_keys(fk["other_table"])
|
||||
other_column = fk["other_column"]
|
||||
if other_column is None and len(other_pks) == 1:
|
||||
other_column = other_pks[0]
|
||||
if len(other_pks) != 1 or other_column != other_pks[0]:
|
||||
continue
|
||||
visible, _ = await datasette.check_visibility(
|
||||
request.actor,
|
||||
action="view-table",
|
||||
resource=TableResource(database=database_name, table=fk["other_table"]),
|
||||
)
|
||||
if not visible:
|
||||
continue
|
||||
autocomplete_urls[fk["column"]] = "{}/-/autocomplete".format(
|
||||
datasette.urls.table(database_name, fk["other_table"])
|
||||
)
|
||||
return autocomplete_urls
|
||||
|
||||
|
||||
async def _table_page_data(
|
||||
datasette, request, db, database_name, table_name, is_view, table_insert_ui
|
||||
):
|
||||
data = {"tableUrl": datasette.urls.table(database_name, table_name)}
|
||||
if table_insert_ui:
|
||||
data["insertRow"] = table_insert_ui
|
||||
if not is_view:
|
||||
foreign_keys = await _foreign_key_autocomplete_urls(
|
||||
datasette, request, db, database_name, table_name
|
||||
)
|
||||
if foreign_keys:
|
||||
data["foreignKeys"] = foreign_keys
|
||||
return data
|
||||
|
||||
|
||||
async def _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
):
|
||||
|
|
@ -1164,6 +1205,12 @@ def _autocomplete_pk_order_by(pks):
|
|||
return ", ".join(escape_sqlite(pk) for pk in pks)
|
||||
|
||||
|
||||
def _autocomplete_initial_order_by(pks):
|
||||
order_by = [f"{escape_sqlite(pks[0])} desc"]
|
||||
order_by.extend(escape_sqlite(pk) for pk in pks[1:])
|
||||
return ", ".join(order_by)
|
||||
|
||||
|
||||
def _autocomplete_response_rows(rows, pks, label_column):
|
||||
response_rows = []
|
||||
for row in rows:
|
||||
|
|
@ -1202,7 +1249,14 @@ class TableAutocompleteView(BaseView):
|
|||
)
|
||||
select_sql = ", ".join(escape_sqlite(column) for column in select_columns)
|
||||
q = request.args.get("q") or ""
|
||||
if not q:
|
||||
initial_arg = request.args.get("_initial")
|
||||
initial = (
|
||||
not q
|
||||
and initial_arg is not None
|
||||
and initial_arg != ""
|
||||
and value_as_boolean(initial_arg)
|
||||
)
|
||||
if not q and not initial:
|
||||
return Response.json({"rows": []})
|
||||
params = {
|
||||
"q": q,
|
||||
|
|
@ -1215,6 +1269,12 @@ class TableAutocompleteView(BaseView):
|
|||
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)
|
||||
|
||||
if initial:
|
||||
where_sql = "1 = 1"
|
||||
order_by = _autocomplete_initial_order_by(pks)
|
||||
|
||||
sql = """
|
||||
select {select_sql}
|
||||
from {table}
|
||||
|
|
@ -1225,7 +1285,7 @@ class TableAutocompleteView(BaseView):
|
|||
select_sql=select_sql,
|
||||
table=escape_sqlite(table_name),
|
||||
where=where_sql,
|
||||
order_by=_autocomplete_order_by(pks, label_column, exact_pk),
|
||||
order_by=order_by,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -1990,9 +2050,19 @@ async def table_view_data(
|
|||
sort = "rowid"
|
||||
data["sort"] = sort
|
||||
data["sort_desc"] = sort_desc
|
||||
data["table_insert_ui"] = await _table_insert_ui(
|
||||
table_insert_ui = await _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
data["table_insert_ui"] = table_insert_ui
|
||||
data["table_page_data"] = await _table_page_data(
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
database_name,
|
||||
table_name,
|
||||
is_view,
|
||||
table_insert_ui,
|
||||
)
|
||||
|
||||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,53 @@ async def test_autocomplete_blank_q_returns_no_results():
|
|||
assert response.status_code == 200
|
||||
assert response.json() == {"rows": []}
|
||||
|
||||
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"rows": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_initial_returns_latest_rows():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("autocomplete_initial")
|
||||
await db.execute_write_script("""
|
||||
create table people (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
insert into people (id, name) values
|
||||
(1, 'Alice'),
|
||||
(2, 'Bob'),
|
||||
(3, 'Cleo');
|
||||
""")
|
||||
|
||||
response = await ds.client.get(
|
||||
"/autocomplete_initial/people/-/autocomplete?_initial=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"id": 3}, "label": "Cleo"},
|
||||
{"pks": {"id": 2}, "label": "Bob"},
|
||||
{"pks": {"id": 1}, "label": "Alice"},
|
||||
]
|
||||
}
|
||||
|
||||
response = await ds.client.get(
|
||||
"/autocomplete_initial/people/-/autocomplete?q=&_initial=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"id": 3}, "label": "Cleo"},
|
||||
{"pks": {"id": 2}, "label": "Bob"},
|
||||
{"pks": {"id": 1}, "label": "Alice"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_escapes_like_characters():
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,61 @@ async def test_table_insert_action_includes_compound_primary_keys():
|
|||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_data_includes_foreign_key_autocomplete_urls():
|
||||
ds = Datasette([])
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_foreign_key_autocomplete"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table authors (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
create table tags (
|
||||
slug text unique,
|
||||
name text
|
||||
);
|
||||
create table articles (
|
||||
id integer primary key,
|
||||
author_id integer references authors(id),
|
||||
implicit_author_id integer references authors,
|
||||
tag_slug text references tags(slug),
|
||||
title text
|
||||
);
|
||||
insert into authors (id, name) values (1, 'Ada Lovelace');
|
||||
insert into tags (slug, name) values ('science', 'Science');
|
||||
insert into articles (
|
||||
id,
|
||||
author_id,
|
||||
implicit_author_id,
|
||||
tag_slug,
|
||||
title
|
||||
) values (
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
'science',
|
||||
'Notes'
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/articles")
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
table_data = table_data_from_soup(soup)
|
||||
assert table_data["foreignKeys"] == {
|
||||
"author_id": "/data/authors/-/autocomplete",
|
||||
"implicit_author_id": "/data/authors/-/autocomplete",
|
||||
}
|
||||
assert any(
|
||||
"autocomplete.js" in (script.get("src") or "")
|
||||
for script in soup.find_all("script")
|
||||
)
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_endpoint(ds_client):
|
||||
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue