Big visual improvement to /-/queries pages

Including /db/-/queries

Refs https://github.com/simonw/datasette/issues/2735#issuecomment-4536860239
This commit is contained in:
Simon Willison 2026-05-25 12:55:36 -07:00
commit 8ab8999ba9
3 changed files with 229 additions and 34 deletions

View file

@ -2,6 +2,155 @@
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
{% block extra_head %}
{{- super() -}}
<style>
.query-list-page {
max-width: 64rem;
}
.query-list-filters {
margin: 0.5rem 0 1rem;
}
.query-list-search {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin: 0 0 0.75rem;
}
.query-list-search label {
width: auto;
}
.query-list-search input[type=search] {
box-sizing: border-box;
flex: 1 1 18rem;
max-width: 24rem;
}
.query-list-search button[type=submit] {
font-size: 0.78rem;
height: 2rem;
line-height: 1.1;
padding: 0.35rem 0.65rem;
}
.query-list-filter-groups {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 0.8rem 1.4rem;
}
.query-list-filter-group {
border: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
min-width: 0;
padding: 0;
}
.query-list-filter-group legend {
font-weight: 700;
margin: 0 0.45rem 0 0;
padding: 0;
}
.query-list-filter-group label {
align-items: center;
border: 1px solid #c8d1dc;
border-radius: 0.25rem;
cursor: pointer;
display: inline-flex;
font-size: 0.82rem;
gap: 0.3rem;
line-height: 1.1;
padding: 0.35rem 0.55rem;
}
.query-list-filter-group input {
margin: 0;
}
.query-list-filter-group input:checked + span {
font-weight: 700;
}
.query-list-results {
border-collapse: collapse;
font-size: 0.9rem;
margin: 0.25rem 0 1rem;
min-width: 36rem;
width: 100%;
}
.query-list-results th,
.query-list-results td {
border-bottom: 1px solid #d7dde5;
padding: 0.45rem 0.7rem;
text-align: left;
vertical-align: top;
}
.query-list-results th {
background-color: #edf6fb;
border-top: 1px solid #d7dde5;
color: #39445a;
font-weight: 700;
}
.query-list-results tbody tr:nth-child(even) {
background-color: rgba(39, 104, 144, 0.05);
}
.query-list-results a.query-list-title {
font-weight: 700;
}
.query-list-description {
color: #4f5b6d;
font-size: 0.78rem;
margin: 0.15rem 0 0;
}
.query-list-pill {
background-color: #eef1f5;
border: 1px solid #d7dde5;
border-radius: 0.25rem;
color: #39445a;
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
padding: 0.25rem 0.4rem;
white-space: nowrap;
}
.query-list-pill-write {
background-color: #fff4db;
border-color: #e2b64e;
}
.query-list-pill-published {
background-color: #e7f5ec;
border-color: #9ecfab;
color: #267a3e;
}
.query-list-pill-unpublished {
background-color: #f7edf0;
border-color: #dbb8c1;
}
.query-list-pagination a {
border: 1px solid #007bff;
border-radius: 0.25rem;
display: inline-block;
padding: 0.45rem 0.7rem;
}
.query-list-pagination-bottom {
margin-top: 0.75rem;
}
@media (max-width: 700px) {
.query-list-search input[type=search] {
max-width: none;
}
.query-list-filter-group {
display: block;
}
.query-list-filter-group legend {
margin-bottom: 0.3rem;
}
.query-list-filter-group label {
margin: 0 0.25rem 0.35rem 0;
}
}
</style>
{% endblock %}
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %}
@ -10,49 +159,66 @@
{% block content %}
<h1>Queries</h1>
<div class="query-list-page">
<form action="{{ query_list_path }}" method="get">
<p>
<h1 style="padding-left: 10px; border-left: 10px solid #{% if database_color %}{{ database_color }}{% else %}666{% endif %}">Queries</h1>
<form class="query-list-filters core" action="{{ query_list_path }}" method="get">
<p class="query-list-search">
<label for="query-search">Search</label>
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
<button type="submit">Search</button>
</p>
<p>
<label for="query-is-write">Mode</label>
<select id="query-is-write" name="is_write">
<option value=""{% if not filters.is_write %} selected{% endif %}>Any</option>
<option value="0"{% if filters.is_write == "0" %} selected{% endif %}>Read-only</option>
<option value="1"{% if filters.is_write == "1" %} selected{% endif %}>Writable</option>
</select>
<label for="query-is-published">Publication</label>
<select id="query-is-published" name="is_published">
<option value=""{% if not filters.is_published %} selected{% endif %}>Any</option>
<option value="1"{% if filters.is_published == "1" %} selected{% endif %}>Published</option>
<option value="0"{% if filters.is_published == "0" %} selected{% endif %}>Unpublished</option>
</select>
</p>
<div class="query-list-filter-groups">
<fieldset class="query-list-filter-group">
<legend>Mode</legend>
<label><input type="radio" name="is_write" value=""{% if not filters.is_write %} checked{% endif %}> <span>Any</span></label>
<label><input type="radio" name="is_write" value="0"{% if filters.is_write == "0" %} checked{% endif %}> <span>Read-only</span></label>
<label><input type="radio" name="is_write" value="1"{% if filters.is_write == "1" %} checked{% endif %}> <span>Writable</span></label>
</fieldset>
<fieldset class="query-list-filter-group">
<legend>Publication</legend>
<label><input type="radio" name="is_published" value=""{% if not filters.is_published %} checked{% endif %}> <span>Any</span></label>
<label><input type="radio" name="is_published" value="1"{% if filters.is_published == "1" %} checked{% endif %}> <span>Published</span></label>
<label><input type="radio" name="is_published" value="0"{% if filters.is_published == "0" %} checked{% endif %}> <span>Unpublished</span></label>
</fieldset>
</div>
</form>
{% if queries %}
<ul class="bullets">
{% for query in queries %}
<li>
{% if show_database %}
<a href="{{ urls.database(query.database) }}">{{ query.database }}</a>:
{% endif %}
<a href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if query.is_write %}<span>Writable</span>{% endif %}
{% if query.is_published %}<span>Published</span>{% endif %}
</li>
{% endfor %}
</ul>
<div class="table-wrapper"><table class="query-list-results">
<thead>
<tr>
{% if show_database %}<th scope="col">Database</th>{% endif %}
<th scope="col">Query</th>
<th scope="col">Mode</th>
<th scope="col">Publication</th>
</tr>
</thead>
<tbody>
{% for query in queries %}
<tr>
{% if show_database %}
<td><a class="query-list-database" href="{{ urls.database(query.database) }}">{{ query.database }}</a></td>
{% endif %}
<td>
<a class="query-list-title" href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if query.description %}<p class="query-list-description">{{ query.description }}</p>{% endif %}
</td>
<td>{% if query.is_write %}<span class="query-list-pill query-list-pill-write">Writable</span>{% else %}<span class="query-list-pill">Read-only</span>{% endif %}</td>
<td>{% if query.is_published %}<span class="query-list-pill query-list-pill-published">Published</span>{% else %}<span class="query-list-pill query-list-pill-unpublished">Unpublished</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}
<p>No queries found.</p>
{% endif %}
{% if next_url %}
<p><a href="{{ next_url }}">Next page</a></p>
<nav class="query-list-pagination query-list-pagination-bottom" aria-label="Query pagination"><a href="{{ next_url }}">Next page</a></nav>
{% endif %}
</div>
{% endblock %}

View file

@ -487,9 +487,9 @@ def _as_optional_bool(value, name):
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value):
def _query_list_limit(value, default=50):
if value in (None, ""):
return 50
return default
try:
return min(max(1, int(value)), 1000)
except ValueError as ex:
@ -1136,7 +1136,10 @@ class QueryListView(BaseView):
database = await self.database_name(request)
format_ = request.url_vars.get("format") or "html"
try:
limit = _query_list_limit(request.args.get("_size"))
limit = _query_list_limit(
request.args.get("_size"),
default=20 if format_ == "html" else 50,
)
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_published = _as_optional_bool(
request.args.get("is_published"), "is_published"
@ -1175,6 +1178,9 @@ class QueryListView(BaseView):
data = {
"ok": True,
"database": database,
"database_color": (
self.ds.get_database(database).color if database is not None else None
),
"queries": page["queries"],
"next": page["next"],
"next_url": next_url,

View file

@ -451,12 +451,34 @@ async def test_query_list_search_filter_and_html():
assert html_response.status_code == 200
assert "Demo query 02" in html_response.text
assert "Demo query 01" not in html_response.text
assert 'class="query-list-results"' in html_response.text
assert "<legend>Mode</legend>" in html_response.text
assert 'type="radio" name="is_published" value="1"' in html_response.text
assert json_response.json()["queries"][0]["name"] == "demo_query_02"
assert [query["name"] for query in filtered_response.json()["queries"]] == [
"private_query"
]
@pytest.mark.asyncio
async def test_query_list_html_defaults_to_twenty_and_shows_pagination():
ds = Datasette(memory=True)
ds.root_enabled = True
ds.add_memory_database("query_list_html_pagination", name="data")
await ds.invoke_startup()
await add_numbered_queries(ds, "data", 25)
response = await ds.client.get("/data/-/queries", actor={"id": "root"})
json_response = await ds.client.get("/data/-/queries.json", actor={"id": "root"})
assert response.status_code == 200
assert response.text.count('aria-label="Query pagination"') == 1
assert "Demo query 20" in response.text
assert "Demo query 21" not in response.text
assert 'href="/data/-/queries?_next=' in response.text
assert len(json_response.json()["queries"]) == 25
@pytest.mark.asyncio
async def test_global_query_list_api_and_html():
ds = Datasette(memory=True)
@ -519,7 +541,8 @@ async def test_global_query_list_api_and_html():
("beta", "beta_first"),
]
assert html_response.status_code == 200
assert 'href="/beta">beta</a>:' in html_response.text
assert '<th scope="col">Database</th>' in html_response.text
assert 'class="query-list-database" href="/beta">beta</a>' in html_response.text
assert "Beta first" in html_response.text
assert "Alpha first" not in html_response.text