diff --git a/datasette/app.py b/datasette/app.py
index 75cab1e7..9b3cf51c 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -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",
diff --git a/datasette/static/app.css b/datasette/static/app.css
index 450324aa..d0356b81 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -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 {
diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js
index 8a5bdc3d..c615000e 100644
--- a/datasette/static/autocomplete.js
+++ b/datasette/static/autocomplete.js
@@ -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;
}
}
diff --git a/datasette/static/table.js b/datasette/static/table.js
index f762ca40..43dd81d7 100644
--- a/datasette/static/table.js
+++ b/datasette/static/table.js
@@ -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,
diff --git a/datasette/templates/table.html b/datasette/templates/table.html
index 5a4e7a68..81b51b28 100644
--- a/datasette/templates/table.html
+++ b/datasette/templates/table.html
@@ -4,12 +4,11 @@
{% block extra_head %}
{{- super() -}}
-{% if table_insert_ui %}
-
-{% else %}
-
-{% endif %}
+
+{% if table_page_data.foreignKeys %}
+
+{% endif %}
diff --git a/datasette/views/table.py b/datasette/views/table.py
index e3e448f6..190529f4 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -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
diff --git a/tests/test_autocomplete.py b/tests/test_autocomplete.py
index 55e7458f..d7d78848 100644
--- a/tests/test_autocomplete.py
+++ b/tests/test_autocomplete.py
@@ -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():
diff --git a/tests/test_table_html.py b/tests/test_table_html.py
index c0af996f..b7d57918 100644
--- a/tests/test_table_html.py
+++ b/tests/test_table_html.py
@@ -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")