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