Autocomplete widget and /-/debug/autocomplete test page

This commit is contained in:
Simon Willison 2026-06-13 22:59:37 -07:00
commit aa5fb7be3d
8 changed files with 642 additions and 0 deletions

View file

@ -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<database>[^\/\.]+)\.db$",

View file

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

View file

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

View file

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

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Debug autocomplete{% endblock %}
{% block extra_head %}
{{ super() }}
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
{% endblock %}
{% block content %}
<h1>Debug autocomplete</h1>
<form class="core debug-autocomplete-form" action="{{ urls.path('-/debug/autocomplete') }}" method="get">
<p>
<label for="debug-autocomplete-database">Database</label>
<input id="debug-autocomplete-database" type="text" name="database" value="{{ database_name or "" }}">
</p>
<p>
<label for="debug-autocomplete-table">Table</label>
<input id="debug-autocomplete-table" type="text" name="table" value="{{ table_name or "" }}">
</p>
<p><input type="submit" value="Open autocomplete"></p>
</form>
{% if error %}
<p class="message-error">{{ error }}</p>
{% elif autocomplete_url %}
<h2>{{ database_name }} / {{ table_name }}</h2>
{% if label_column %}
<p>Label column: <code>{{ label_column }}</code></p>
{% else %}
<p>No label column detected. Results will use primary key values.</p>
{% endif %}
<div class="debug-autocomplete-demo">
<label for="debug-autocomplete-input">Search rows</label>
<datasette-autocomplete src="{{ autocomplete_url }}">
<input id="debug-autocomplete-input" type="text">
</datasette-autocomplete>
</div>
<h3>Selected row</h3>
<pre class="debug-autocomplete-selected" aria-live="polite">No row selected.</pre>
<script>
document.addEventListener("datasette-autocomplete-select", function (event) {
var output = document.querySelector(".debug-autocomplete-selected");
if (output) {
output.textContent = JSON.stringify(event.detail.row, null, 2);
}
});
</script>
{% else %}
<h2>Suggested tables</h2>
{% if suggestions %}
<p>Showing up to five tables with a detected label column.</p>
<table class="rows-and-columns">
<thead>
<tr>
<th>Database</th>
<th>Table</th>
<th>Label column</th>
</tr>
</thead>
<tbody>
{% for suggestion in suggestions %}
<tr>
<td>{{ suggestion.database }}</td>
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
<td><code>{{ suggestion.label_column }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No tables with detected label columns found.</p>
{% endif %}
<p>Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.</p>
{% endif %}
{% endblock %}

View file

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

View file

@ -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 "<code>name</code>" 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

View file

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