diff --git a/datasette/app.py b/datasette/app.py index 545b4588..75cab1e7 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -66,6 +66,7 @@ from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, + AutocompleteDebugView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -2539,6 +2540,10 @@ class Datasette: wrap_view(PatternPortfolioView, self), r"/-/patterns$", ) + add_route( + AutocompleteDebugView.as_view(self), + r"/-/debug/autocomplete$", + ) add_route( wrap_view(database_download, self), r"/(?P[^\/\.]+)\.db$", diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py index 6127b2a6..8ea3c287 100644 --- a/datasette/default_debug_menu.py +++ b/datasette/default_debug_menu.py @@ -37,6 +37,11 @@ DEBUG_MENU_ITEMS = ( "Debug allow rules", "Explore how allow blocks match actors against permission rules.", ), + ( + "/-/debug/autocomplete", + "Debug autocomplete", + "Try out table autocomplete against a detected label column.", + ), ( "/-/threads", "Debug threads", diff --git a/datasette/static/app.css b/datasette/static/app.css index 8e68c1bf..450324aa 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1600,6 +1600,73 @@ textarea.row-edit-input { margin: 0; } +datasette-autocomplete { + display: block; + position: relative; + max-width: 38rem; +} + +datasette-autocomplete input[type="text"], +.debug-autocomplete-form input[type="text"] { + box-sizing: border-box; + width: 100%; + max-width: 38rem; +} + +.datasette-autocomplete-list { + background: #fff; + border: 1px solid var(--rule); + border-radius: 5px; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14); + box-sizing: border-box; + left: 0; + max-height: 16rem; + overflow-y: auto; + position: absolute; + right: 0; + top: calc(100% + 3px); + z-index: 20; +} + +.datasette-autocomplete-list[hidden] { + display: none; +} + +.datasette-autocomplete-option { + cursor: pointer; + padding: 7px 9px; +} + +.datasette-autocomplete-option:hover, +.datasette-autocomplete-option[aria-selected="true"] { + background: var(--paper); +} + +.datasette-autocomplete-option[aria-selected="true"] { + background: #eef4ff; + box-shadow: inset 3px 0 0 #1a56db; +} + +.datasette-autocomplete-status { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.debug-autocomplete-demo { + margin: 1rem 0; +} + +.debug-autocomplete-selected { + max-width: 46rem; +} + .row-edit-dialog .modal-footer { padding: 14px 20px; border-top: 1px solid var(--rule); diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js new file mode 100644 index 00000000..8a5bdc3d --- /dev/null +++ b/datasette/static/autocomplete.js @@ -0,0 +1,291 @@ +(function () { + function autocompleteValueFromRow(row) { + var pks = (row && row.pks) || {}; + var keys = Object.keys(pks); + if (!keys.length) { + return ""; + } + if (keys.length === 1) { + return String(pks[keys[0]]); + } + return keys + .map(function (key) { + return key + "=" + pks[key]; + }) + .join(", "); + } + + function autocompleteLabelFromRow(row) { + var value = autocompleteValueFromRow(row); + if (row.label && String(row.label) !== value) { + return row.label + " (" + value + ")"; + } + return value; + } + + if (!window.customElements || customElements.get("datasette-autocomplete")) { + return; + } + + class DatasetteAutocomplete extends HTMLElement { + constructor() { + super(); + this.input = null; + this.listbox = null; + this.status = null; + this.results = []; + this.activeIndex = -1; + this.fetchId = 0; + this.searchTimer = null; + this.boundInput = this.handleInput.bind(this); + this.boundKeydown = this.handleKeydown.bind(this); + this.boundBlur = this.handleBlur.bind(this); + this.boundFocus = this.handleFocus.bind(this); + } + + connectedCallback() { + if (this.input) { + return; + } + this.input = this.querySelector("input"); + if (!this.input) { + return; + } + + var inputId = + this.input.id || + "datasette-autocomplete-" + Math.random().toString(36).slice(2); + this.input.id = inputId; + var listboxId = inputId + "-listbox"; + var statusId = inputId + "-status"; + + this.classList.add("datasette-autocomplete"); + this.input.setAttribute("role", "combobox"); + this.input.setAttribute("aria-autocomplete", "list"); + this.input.setAttribute("aria-expanded", "false"); + this.input.setAttribute("aria-controls", listboxId); + this.input.setAttribute("autocomplete", "off"); + + this.listbox = document.createElement("div"); + this.listbox.className = "datasette-autocomplete-list"; + this.listbox.id = listboxId; + this.listbox.setAttribute("role", "listbox"); + this.listbox.hidden = true; + + this.status = document.createElement("span"); + this.status.className = "datasette-autocomplete-status"; + this.status.id = statusId; + this.status.setAttribute("role", "status"); + this.status.setAttribute("aria-live", "polite"); + + this.input.setAttribute( + "aria-describedby", + [this.input.getAttribute("aria-describedby"), statusId] + .filter(Boolean) + .join(" "), + ); + + this.appendChild(this.listbox); + this.appendChild(this.status); + + this.input.addEventListener("input", this.boundInput); + this.input.addEventListener("keydown", this.boundKeydown); + this.input.addEventListener("blur", this.boundBlur); + this.input.addEventListener("focus", this.boundFocus); + } + + disconnectedCallback() { + if (!this.input) { + return; + } + this.input.removeEventListener("input", this.boundInput); + this.input.removeEventListener("keydown", this.boundKeydown); + this.input.removeEventListener("blur", this.boundBlur); + this.input.removeEventListener("focus", this.boundFocus); + } + + handleInput() { + this.scheduleSearch(); + } + + handleFocus() { + if (this.input.value.trim()) { + this.scheduleSearch(); + } + } + + handleBlur() { + window.setTimeout(() => this.close(), 150); + } + + handleKeydown(ev) { + if (ev.key === "Escape") { + if (!this.listbox.hidden) { + ev.preventDefault(); + this.close(); + } + return; + } + if (ev.key === "ArrowDown") { + ev.preventDefault(); + if (this.listbox.hidden) { + this.scheduleSearch(); + } else { + this.setActiveIndex(this.activeIndex + 1); + } + return; + } + if (ev.key === "ArrowUp") { + ev.preventDefault(); + if (!this.listbox.hidden) { + this.setActiveIndex(this.activeIndex - 1); + } + return; + } + if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) { + ev.preventDefault(); + this.chooseIndex(this.activeIndex); + } + } + + scheduleSearch() { + window.clearTimeout(this.searchTimer); + this.searchTimer = window.setTimeout(() => this.search(), 150); + } + + async search() { + var query = this.input.value.trim(); + if (!query) { + this.close(); + this.status.textContent = ""; + return; + } + var src = this.getAttribute("src"); + if (!src) { + return; + } + + var url = new URL(src, location.href); + url.searchParams.set("q", query); + var fetchId = this.fetchId + 1; + this.fetchId = fetchId; + this.status.textContent = "Searching..."; + + 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 (fetchId !== this.fetchId) { + return; + } + this.results = (data && data.rows) || []; + this.render(); + } catch (_error) { + if (fetchId !== this.fetchId) { + return; + } + this.results = []; + this.close(); + this.status.textContent = "Could not load suggestions"; + } + } + + render() { + this.listbox.textContent = ""; + this.activeIndex = -1; + if (!this.results.length) { + this.close(); + this.status.textContent = "No matches"; + return; + } + + this.results.forEach((row, index) => { + var option = document.createElement("div"); + option.className = "datasette-autocomplete-option"; + option.id = this.input.id + "-option-" + index; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", "false"); + option.dataset.index = String(index); + option.dataset.value = autocompleteValueFromRow(row); + option.textContent = autocompleteLabelFromRow(row); + option.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + this.chooseIndex(index); + }); + this.listbox.appendChild(option); + }); + + this.listbox.hidden = false; + this.input.setAttribute("aria-expanded", "true"); + this.status.textContent = + this.results.length + (this.results.length === 1 ? " match" : " matches"); + this.setActiveIndex(0); + } + + setActiveIndex(index) { + var options = this.listbox.querySelectorAll("[role='option']"); + if (!options.length) { + this.activeIndex = -1; + this.input.removeAttribute("aria-activedescendant"); + return; + } + if (index < 0) { + index = options.length - 1; + } + if (index >= options.length) { + index = 0; + } + options.forEach((option, optionIndex) => { + option.setAttribute( + "aria-selected", + optionIndex === index ? "true" : "false", + ); + }); + this.activeIndex = index; + this.input.setAttribute("aria-activedescendant", options[index].id); + } + + chooseIndex(index) { + var row = this.results[index]; + if (!row) { + return; + } + var value = autocompleteValueFromRow(row); + var label = autocompleteLabelFromRow(row); + this.input.value = value; + this.input.dispatchEvent(new Event("change", { bubbles: true })); + this.close(); + this.status.textContent = "Selected " + label; + this.dispatchEvent( + new CustomEvent("datasette-autocomplete-select", { + bubbles: true, + detail: { + row: row, + value: value, + label: label, + }, + }), + ); + } + + close() { + if (this.listbox) { + this.listbox.hidden = true; + this.listbox.textContent = ""; + } + if (this.input) { + this.input.setAttribute("aria-expanded", "false"); + this.input.removeAttribute("aria-activedescendant"); + } + this.activeIndex = -1; + } + } + + customElements.define("datasette-autocomplete", DatasetteAutocomplete); +})(); diff --git a/datasette/templates/debug_autocomplete.html b/datasette/templates/debug_autocomplete.html new file mode 100644 index 00000000..84dbc14f --- /dev/null +++ b/datasette/templates/debug_autocomplete.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Debug autocomplete{% endblock %} + +{% block extra_head %} +{{ super() }} + +{% endblock %} + +{% block content %} +

Debug autocomplete

+ +
+

+ + +

+

+ + +

+

+
+ +{% if error %} +

{{ error }}

+{% elif autocomplete_url %} +

{{ database_name }} / {{ table_name }}

+ {% if label_column %} +

Label column: {{ label_column }}

+ {% else %} +

No label column detected. Results will use primary key values.

+ {% endif %} +
+ + + + +
+

Selected row

+
No row selected.
+ +{% else %} +

Suggested tables

+ {% if suggestions %} +

Showing up to five tables with a detected label column.

+ + + + + + + + + + {% for suggestion in suggestions %} + + + + + + {% endfor %} + +
DatabaseTableLabel column
{{ suggestion.database }}{{ suggestion.table }}{{ suggestion.label_column }}
+ {% else %} +

No tables with detected label columns found.

+ {% endif %} +

Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.

+{% endif %} + +{% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index aa063ad6..3245bc13 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -91,6 +91,110 @@ class PatternPortfolioView(View): ) +class AutocompleteDebugView(BaseView): + name = "autocomplete_debug" + has_json_alternate = False + + async def _suggested_tables(self, request): + scanned = 0 + reached_scan_limit = False + suggestions = [] + for database_name, db in self.ds.databases.items(): + if scanned >= 100 or len(suggestions) >= 5: + break + remaining = 100 - scanned + results = await db.execute( + "select name from sqlite_master where type = 'table' order by name limit ?", + [remaining], + ) + for row in results.rows: + table_name = row["name"] + scanned += 1 + if scanned >= 100: + reached_scan_limit = True + visible, _ = await self.ds.check_visibility( + request.actor, + action="view-table", + resource=TableResource(database=database_name, table=table_name), + ) + if not visible: + if scanned >= 100: + break + continue + label_column = await db.label_column_for_table(table_name) + if label_column: + suggestions.append( + { + "database": database_name, + "table": table_name, + "label_column": label_column, + "url": self.ds.urls.path( + "-/debug/autocomplete?" + + urllib.parse.urlencode( + { + "database": database_name, + "table": table_name, + } + ) + ), + } + ) + if len(suggestions) >= 5: + break + if scanned >= 100: + break + return suggestions, scanned, reached_scan_limit + + async def get(self, request): + await self.ds.ensure_permission(action="view-instance", actor=request.actor) + database_name = request.args.get("database") + table_name = request.args.get("table") + context = { + "database_name": database_name, + "table_name": table_name, + } + + if database_name or table_name: + if not database_name or not table_name: + context["error"] = "Both database and table are required." + elif database_name not in self.ds.databases: + context["error"] = "Database not found." + else: + db = self.ds.databases[database_name] + if not await db.table_exists(table_name): + context["error"] = "Table not found." + else: + await self.ds.ensure_permission( + action="view-table", + resource=TableResource( + database=database_name, + table=table_name, + ), + actor=request.actor, + ) + context.update( + { + "autocomplete_url": "{}/-/autocomplete".format( + self.ds.urls.table(database_name, table_name) + ), + "label_column": await db.label_column_for_table(table_name), + } + ) + else: + suggestions, scanned, reached_scan_limit = await self._suggested_tables( + request + ) + context.update( + { + "suggestions": suggestions, + "scanned": scanned, + "reached_scan_limit": reached_scan_limit, + } + ) + + return await self.render(["debug_autocomplete.html"], request, context) + + class AuthTokenView(BaseView): name = "auth_token" has_json_alternate = False diff --git a/tests/test_debug_autocomplete.py b/tests/test_debug_autocomplete.py new file mode 100644 index 00000000..f438ace5 --- /dev/null +++ b/tests/test_debug_autocomplete.py @@ -0,0 +1,91 @@ +import pytest +from bs4 import BeautifulSoup as Soup + +from datasette.app import Datasette +from datasette.database import Database + + +@pytest.mark.asyncio +async def test_debug_autocomplete_for_table(): + ds = Datasette(memory=True) + db = ds.add_database( + Database(ds, memory_name="test_debug_autocomplete_for_table"), name="data" + ) + await db.execute_write_script(""" + create table authors ( + id integer primary key, + name text + ); + insert into authors (id, name) values + (1, 'Ada Lovelace'), + (2, 'Grace Hopper'); + """) + + response = await ds.client.get("/-/debug/autocomplete?database=data&table=authors") + + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + assert soup.select_one("h1").text == "Debug autocomplete" + assert any( + "autocomplete.js" in (script.get("src") or "") + for script in soup.find_all("script") + ) + autocomplete = soup.select_one("datasette-autocomplete") + assert autocomplete is not None + assert autocomplete["src"] == "/data/authors/-/autocomplete" + assert soup.select_one("input#debug-autocomplete-input") is not None + assert "Label column:" in response.text + assert "name" in response.text + + +@pytest.mark.asyncio +async def test_debug_autocomplete_suggests_label_column_tables(): + ds = Datasette(memory=True) + db = ds.add_database( + Database(ds, memory_name="test_debug_autocomplete_suggests"), name="data" + ) + await db.execute_write_script(""" + create table authors ( + id integer primary key, + name text + ); + create table releases ( + id integer primary key, + title text + ); + """) + + response = await ds.client.get("/-/debug/autocomplete") + + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + links = {a.text: a["href"] for a in soup.select("table.rows-and-columns a")} + assert links == { + "authors": "/-/debug/autocomplete?database=data&table=authors", + "releases": "/-/debug/autocomplete?database=data&table=releases", + } + assert [code.text for code in soup.select("table.rows-and-columns code")] == [ + "name", + "title", + ] + + +@pytest.mark.asyncio +async def test_debug_autocomplete_scan_limit(): + ds = Datasette(memory=True) + db = ds.add_database( + Database(ds, memory_name="test_debug_autocomplete_scan_limit"), name="data" + ) + await db.execute_write_script( + "\n".join( + f"create table t{i:03d} (id integer primary key);" for i in range(100) + ) + + "\ncreate table z_has_label (id integer primary key, name text);" + ) + + response = await ds.client.get("/-/debug/autocomplete") + + assert response.status_code == 200 + assert "No tables with detected label columns found." in response.text + assert "Scanned 100 tables; stopped at the 100 table scan limit." in response.text + assert "z_has_label" not in response.text diff --git a/tests/test_jump.py b/tests/test_jump.py index 513a809f..0fb6a552 100644 --- a/tests/test_jump.py +++ b/tests/test_jump.py @@ -191,6 +191,7 @@ async def test_debug_menu_items_are_in_jump_for_debug_menu_permission(): "Debug permissions": "/-/permissions", "Debug messages": "/-/messages", "Debug allow rules": "/-/allow-debug", + "Debug autocomplete": "/-/debug/autocomplete", "Debug threads": "/-/threads", "Debug actor": "/-/actor", "Pattern portfolio": "/-/patterns",