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:
Simon Willison 2026-06-14 07:30:34 -07:00
commit 574290fb23
8 changed files with 474 additions and 20 deletions

View file

@ -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",

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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>

View file

@ -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

View file

@ -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():

View file

@ -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")