Prototype of new /-/jump menu plus plugin hook

This commit is contained in:
Simon Willison 2026-05-21 15:02:14 -07:00
commit fae847ac10
15 changed files with 1007 additions and 132 deletions

View file

@ -58,7 +58,7 @@ from .views.special import (
AllowedResourcesView,
PermissionRulesView,
PermissionCheckView,
TablesView,
JumpView,
InstanceSchemaView,
DatabaseSchemaView,
TableSchemaView,
@ -1219,13 +1219,24 @@ class Datasette:
return db_plugin_config
def static_hash(self, filename):
if not hasattr(self, "_static_hashes"):
self._static_hashes = {}
path = os.path.join(str(app_root), "datasette/static", filename)
signature = (os.path.getmtime(path), os.path.getsize(path))
cached = self._static_hashes.get(filename)
if cached and cached["signature"] == signature:
return cached["hash"]
with open(path) as fp:
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
self._static_hashes[filename] = {
"signature": signature,
"hash": static_hash,
}
return static_hash
def app_css_hash(self):
if not hasattr(self, "_app_css_hash"):
with open(os.path.join(str(app_root), "datasette/static/app.css")) as fp:
self._app_css_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[
:6
]
return self._app_css_hash
return self.static_hash("app.css")
async def get_canned_queries(self, database_name, actor):
queries = {}
@ -2028,6 +2039,22 @@ class Datasette:
links.extend(extra_links)
return links
async def jump_start():
html_bits = []
for hook in pm.hook.jump_start(
datasette=self,
actor=request.actor if request else None,
request=request or None,
):
extra_html = await await_me_maybe(hook)
if not extra_html:
continue
if isinstance(extra_html, (list, tuple)):
html_bits.extend(extra_html)
else:
html_bits.append(extra_html)
return Markup("").join(Markup(html) for html in html_bits)
template_context = {
**context,
**{
@ -2036,11 +2063,13 @@ class Datasette:
"urls": self.urls,
"actor": request.actor if request else None,
"menu_links": menu_links,
"jump_start": jump_start,
"display_actor": display_actor,
"show_logout": request is not None
and "ds_actor" in request.cookies
and request.actor,
"app_css_hash": self.app_css_hash(),
"navigation_search_js_hash": self.static_hash("navigation-search.js"),
"zip": zip,
"body_scripts": body_scripts,
"format_bytes": format_bytes,
@ -2222,8 +2251,8 @@ class Datasette:
r"/-/api$",
)
add_route(
TablesView.as_view(self),
r"/-/tables(\.(?P<format>json))?$",
JumpView.as_view(self),
r"/-/jump(\.(?P<format>json))?$",
)
add_route(
InstanceSchemaView.as_view(self),

View file

@ -67,6 +67,9 @@ def handle_exception(datasette, request, exception):
info,
urls=datasette.urls,
app_css_hash=datasette.app_css_hash(),
navigation_search_js_hash=datasette.static_hash(
"navigation-search.js"
),
menu_links=lambda: [],
)
),

View file

@ -157,6 +157,16 @@ def menu_links(datasette, actor, request):
"""Links for the navigation menu"""
@hookspec
def jump_items_sql(datasette, actor, request):
"""SQL fragments for extra items in the jump menu, optionally with display_name"""
@hookspec
def jump_start(datasette, actor, request):
"""HTML to display in the jump menu before the user types"""
@hookspec
def row_actions(datasette, actor, request, database, table, row):
"""Links for the row actions menu"""

33
datasette/jump.py Normal file
View file

@ -0,0 +1,33 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class JumpSQL:
sql: str
params: dict[str, Any] | None = None
has_display_name: bool = False
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
"""Rename named SQL parameters so UNION fragments cannot collide."""
if not params:
return sql, {}
renamed = {key: f"{prefix}_{key}" for key in params}
def replace(match):
key = match.group(1)
if key not in renamed:
return match.group(0)
return f":{renamed[key]}"
return _PARAM_RE.sub(replace, sql), {
renamed[key]: value for key, value in params.items()
}

View file

@ -100,16 +100,65 @@ class NavigationSearch extends HTMLElement {
background-color: #dbeafe;
}
.jump-start-content {
border-bottom: 1px solid #e5e7eb;
margin-bottom: 0.5rem;
padding: 0.5rem 0.5rem 1rem;
}
.jump-start-content:empty {
display: none;
}
.result-name {
font-weight: 500;
color: #111827;
}
.result-label {
font-size: 0.875rem;
color: #4b5563;
}
.result-description {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.results-heading {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0;
padding: 0.5rem 1rem 0.25rem;
text-transform: uppercase;
}
.recent-actions {
padding: 0.25rem 1rem 0.75rem;
}
.clear-recent {
background: transparent;
border: 0;
color: #2563eb;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
padding: 0;
}
.clear-recent:hover {
text-decoration: underline;
}
.no-results {
padding: 2rem;
text-align: center;
@ -168,8 +217,8 @@ class NavigationSearch extends HTMLElement {
<input
type="text"
class="search-input"
placeholder="Search..."
aria-label="Search navigation"
placeholder="Jump to..."
aria-label="Jump to"
autocomplete="off"
spellcheck="false"
>
@ -231,6 +280,13 @@ class NavigationSearch extends HTMLElement {
// Click on result item
resultsContainer.addEventListener("click", (e) => {
const clearRecent = e.target.closest("[data-clear-recent-items]");
if (clearRecent) {
e.preventDefault();
this.clearRecentItems();
return;
}
const item = e.target.closest(".result-item");
if (item) {
const index = parseInt(item.dataset.index);
@ -306,12 +362,13 @@ class NavigationSearch extends HTMLElement {
filterLocalItems(query) {
if (!query.trim()) {
this.matches = [];
this.matches = this.allItems || [];
} else {
const lowerQuery = query.toLowerCase();
this.matches = (this.allItems || []).filter(
(item) =>
item.name.toLowerCase().includes(lowerQuery) ||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
item.url.toLowerCase().includes(lowerQuery),
);
}
@ -319,43 +376,165 @@ class NavigationSearch extends HTMLElement {
this.renderResults();
}
renderResults() {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
recentItemsStorageKey() {
return "datasette.navigationSearch.recentItems";
}
if (this.matches.length === 0) {
const message = input.value.trim()
? "No results found"
: "Start typing to search...";
container.innerHTML = `<div class="no-results">${message}</div>`;
loadRecentItems() {
if (typeof localStorage === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(this.recentItemsStorageKey());
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((item) => item && item.name && item.url)
.map((item) => ({
name: String(item.name),
display_name: item.display_name ? String(item.display_name) : "",
url: String(item.url),
type: item.type ? String(item.type) : "",
description: item.description ? String(item.description) : "",
}))
.slice(0, 5);
} catch (e) {
return [];
}
}
saveRecentItem(match) {
if (typeof localStorage === "undefined" || !match || !match.name || !match.url) {
return;
}
container.innerHTML = this.matches
.map(
(match, index) => `
<div
class="result-item ${
index === this.selectedIndex ? "selected" : ""
}"
try {
const item = {
name: String(match.name),
display_name: match.display_name ? String(match.display_name) : "",
url: String(match.url),
type: match.type ? String(match.type) : "",
description: match.description ? String(match.description) : "",
};
const recentItems = this.loadRecentItems().filter(
(recentItem) => recentItem.url !== item.url,
);
localStorage.setItem(
this.recentItemsStorageKey(),
JSON.stringify([item, ...recentItems].slice(0, 5)),
);
} catch (e) {
// localStorage may be unavailable, full, or disabled.
}
}
clearRecentItems() {
if (typeof localStorage === "undefined") {
return;
}
try {
localStorage.removeItem(this.recentItemsStorageKey());
} catch (e) {
localStorage.setItem(this.recentItemsStorageKey(), "[]");
}
this.renderResults();
}
startContentHtml() {
const template = this.querySelector("template[data-jump-start]");
return template ? template.innerHTML.trim() : "";
}
resultItemHtml(match, index) {
const displayName = match.display_name || match.name;
const label =
match.display_name && match.display_name !== match.name
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
: "";
const description = match.description
? `<div class="result-description">${this.escapeHtml(
match.description,
)}</div>`
: "";
return `
<div
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
data-index="${index}"
role="option"
aria-selected="${index === this.selectedIndex}"
>
<div>
<div class="result-name">${this.escapeHtml(
match.name,
)}</div>
${description}
<div class="result-name">${this.escapeHtml(displayName)}</div>
${label}
<div class="result-url">${this.escapeHtml(match.url)}</div>
</div>
</div>
`,
`;
}
renderResults() {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
const showStartContent = !input.value.trim();
const startContent = showStartContent ? this.startContentHtml() : "";
const startBlock = startContent
? `<div class="jump-start-content">${startContent}</div>`
: "";
const recentItems = showStartContent ? this.loadRecentItems() : [];
const defaultMatches = showStartContent ? [] : this.matches;
const renderedMatches = [...recentItems, ...defaultMatches];
this.renderedMatches = renderedMatches;
if (renderedMatches.length) {
if (
this.selectedIndex < 0 ||
this.selectedIndex >= renderedMatches.length
) {
this.selectedIndex = 0;
}
} else {
this.selectedIndex = -1;
}
if (renderedMatches.length === 0) {
if (startBlock) {
container.innerHTML = startBlock;
} else if (showStartContent) {
container.innerHTML = "";
} else {
const message = input.value.trim()
? "No results found"
: "Start typing to search...";
container.innerHTML = `<div class="no-results">${message}</div>`;
}
return;
}
const recentHtml = recentItems.length
? `<div class="results-heading">Recent</div>${recentItems
.map((match, index) => this.resultItemHtml(match, index))
.join("")}<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
: "";
const defaultHtml = defaultMatches
.map((match, index) =>
this.resultItemHtml(match, recentItems.length + index),
)
.join("");
container.innerHTML = startBlock + recentHtml + defaultHtml;
// Scroll selected item into view
if (this.selectedIndex >= 0) {
const selectedItem = container.children[this.selectedIndex];
const selectedItem = container.querySelector(
`.result-item[data-index="${this.selectedIndex}"]`,
);
if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest" });
}
@ -363,22 +542,27 @@ class NavigationSearch extends HTMLElement {
}
moveSelection(direction) {
const matches = this.renderedMatches || this.matches;
const newIndex = this.selectedIndex + direction;
if (newIndex >= 0 && newIndex < this.matches.length) {
if (newIndex >= 0 && newIndex < matches.length) {
this.selectedIndex = newIndex;
this.renderResults();
}
}
selectCurrentItem() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
const matches = this.renderedMatches || this.matches;
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
this.selectItem(this.selectedIndex);
}
}
selectItem(index) {
const match = this.matches[index];
const matches = this.renderedMatches || this.matches;
const match = matches[index];
if (match) {
this.saveRecentItem(match);
// Dispatch custom event
this.dispatchEvent(
new CustomEvent("select", {
@ -405,7 +589,7 @@ class NavigationSearch extends HTMLElement {
input.value = "";
input.focus();
// Reset state - start with no items shown
// Reset state, then populate the default jump list.
this.matches = [];
this.selectedIndex = -1;
this.renderResults();
@ -418,7 +602,7 @@ class NavigationSearch extends HTMLElement {
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
div.textContent = text == null ? "" : text;
return div.innerHTML;
}
}

View file

@ -70,7 +70,14 @@
{% endfor %}
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="/-/tables"></navigation-search>
<script src="{{ urls.static('navigation-search.js') }}{% if navigation_search_js_hash is defined %}?{{ navigation_search_js_hash }}{% endif %}" defer></script>
{% if jump_start is defined %}
{% set jump_start_html = jump_start() %}
{% else %}
{% set jump_start_html = "" %}
{% endif %}
<navigation-search url="/-/jump">{% if jump_start_html %}
<template data-jump-start>{{ jump_start_html }}</template>
{% endif %}</navigation-search>
</body>
</html>

View file

@ -1,11 +1,14 @@
import json
import logging
from datasette.jump import JumpSQL, namespace_sql_params
from datasette.plugins import pm
from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import (
actor_matches_allow,
add_cors_headers,
await_me_maybe,
tilde_encode,
tilde_decode,
)
@ -910,75 +913,231 @@ class ApiExplorerView(BaseView):
)
class TablesView(BaseView):
class JumpView(BaseView):
"""
Simple endpoint that uses the new allowed_resources() API.
Returns JSON list of all tables the actor can view.
Supports ?q=foo+bar to filter tables matching .*foo.*bar.* pattern,
ordered by shortest name first.
Endpoint for the jump menu. Returns JSON navigation items the actor can use.
"""
name = "tables"
name = "jump"
has_json_alternate = False
async def get(self, request):
# Get search query parameter
q = request.args.get("q", "").strip()
async def _query_display_names_sql(self, request):
selects = []
params = {}
for database_name in self.ds.databases.keys():
queries = await self.ds.get_canned_queries(database_name, request.actor)
for query_name, query in queries.items():
display_name = query.get("title") if isinstance(query, dict) else None
if not display_name:
continue
index = len(selects)
params[f"display_database_{index}"] = database_name
params[f"display_query_{index}"] = query_name
params[f"display_name_{index}"] = str(display_name)
selects.append(f"""
SELECT
:display_database_{index} AS database_name,
:display_query_{index} AS query_name,
:display_name_{index} AS display_name
""")
if not selects:
return (
"SELECT NULL AS database_name, NULL AS query_name, NULL AS display_name WHERE 0",
{},
)
return " UNION ALL ".join(selects), params
# Get SQL for allowed resources using the permission system
permission_sql, params = await self.ds.allowed_resources_sql(
async def _core_fragments(self, request):
database_sql, database_params = await self.ds.allowed_resources_sql(
action="view-database", actor=request.actor
)
table_sql, table_params = await self.ds.allowed_resources_sql(
action="view-table", actor=request.actor
)
query_sql, query_params = await self.ds.allowed_resources_sql(
action="view-query", actor=request.actor
)
query_display_names_sql, query_display_names_params = (
await self._query_display_names_sql(request)
)
return [
JumpSQL(
sql=f"""
WITH allowed_databases AS (
{database_sql}
)
SELECT
'database' AS type,
parent AS label,
'Database' AS description,
NULL AS url,
parent AS database_name,
NULL AS resource_name,
parent AS search_text,
10 AS sort_key,
'datasette' AS source,
NULL AS display_name
FROM allowed_databases
""",
params=database_params,
has_display_name=True,
),
JumpSQL(
sql=f"""
WITH allowed_tables AS (
{table_sql}
)
SELECT
CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type,
allowed_tables.parent || ': ' || allowed_tables.child AS label,
CASE WHEN catalog_views.view_name IS NULL THEN 'Table' ELSE 'View' END AS description,
NULL AS url,
allowed_tables.parent AS database_name,
allowed_tables.child AS resource_name,
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
CASE WHEN catalog_views.view_name IS NULL THEN 20 ELSE 25 END AS sort_key,
'datasette' AS source,
NULL AS display_name
FROM allowed_tables
LEFT JOIN catalog_views
ON catalog_views.database_name = allowed_tables.parent
AND catalog_views.view_name = allowed_tables.child
""",
params=table_params,
has_display_name=True,
),
JumpSQL(
sql=f"""
WITH allowed_queries AS (
{query_sql}
),
query_display_names AS (
{query_display_names_sql}
)
SELECT
'query' AS type,
allowed_queries.parent || ': ' || allowed_queries.child AS label,
'Canned query' AS description,
NULL AS url,
allowed_queries.parent AS database_name,
allowed_queries.child AS resource_name,
allowed_queries.parent || ' ' || allowed_queries.child || ' ' || COALESCE(query_display_names.display_name, '') AS search_text,
30 AS sort_key,
'datasette' AS source,
query_display_names.display_name AS display_name
FROM allowed_queries
LEFT JOIN query_display_names
ON query_display_names.database_name = allowed_queries.parent
AND query_display_names.query_name = allowed_queries.child
""",
params={**query_params, **query_display_names_params},
has_display_name=True,
),
]
# Build query based on whether we have a search query
if q:
# Build SQL LIKE pattern from search terms
# Split search terms by whitespace and build pattern: %term1%term2%term3%
terms = q.split()
pattern = "%" + "%".join(terms) + "%"
async def _plugin_fragments(self, request):
fragments = []
for hook in pm.hook.jump_items_sql(
datasette=self.ds,
actor=request.actor,
request=request,
):
value = await await_me_maybe(hook)
if value is None:
continue
if isinstance(value, JumpSQL):
fragments.append(value)
elif isinstance(value, (list, tuple)):
for fragment in value:
if fragment is not None:
assert isinstance(
fragment, JumpSQL
), "jump_items_sql must return JumpSQL instances"
fragments.append(fragment)
else:
raise TypeError("jump_items_sql must return JumpSQL instances")
return fragments
# Build query with CTE to filter by search pattern
sql = f"""
WITH allowed_tables AS (
{permission_sql}
def _url_for_row(self, row):
if row["url"]:
return row["url"]
if row["type"] == "database":
return self.ds.urls.database(row["database_name"])
if row["type"] in ("table", "view"):
return self.ds.urls.table(row["database_name"], row["resource_name"])
if row["type"] == "query":
return self.ds.urls.query(row["database_name"], row["resource_name"])
return ""
async def get(self, request):
q = request.args.get("q", "").strip()
terms = q.split()
pattern = "%" + "%".join(terms) + "%" if terms else "%"
fragments = await self._core_fragments(request)
fragments.extend(await self._plugin_fragments(request))
union_parts = []
all_params = {"q": q, "pattern": pattern}
for index, fragment in enumerate(fragments):
fragment_sql, fragment_params = namespace_sql_params(
fragment.sql,
fragment.params or {},
f"jump_{index}",
)
SELECT parent, child
FROM allowed_tables
WHERE child LIKE :pattern COLLATE NOCASE
ORDER BY length(child), child
"""
all_params = {**params, "pattern": pattern}
else:
# No search query - return all tables, ordered by name
# Fetch 101 to detect if we need to truncate
sql = f"""
WITH allowed_tables AS (
{permission_sql}
)
SELECT parent, child
FROM allowed_tables
ORDER BY parent, child
LIMIT 101
"""
all_params = params
union_parts.append(f"""
SELECT
type,
label,
description,
url,
database_name,
resource_name,
search_text,
sort_key,
source,
{"display_name" if fragment.has_display_name else "NULL AS display_name"}
FROM (
{fragment_sql}
)
""")
all_params.update(fragment_params)
# Execute against internal database
sql = f"""
WITH jump_items AS (
{" UNION ALL ".join(union_parts)}
)
SELECT *
FROM jump_items
WHERE :q = ''
OR search_text LIKE :pattern COLLATE NOCASE
ORDER BY
CASE
WHEN lower(COALESCE(display_name, label)) = lower(:q) THEN 0
WHEN lower(COALESCE(display_name, label)) LIKE lower(:q || '%') THEN 1
ELSE 2
END,
sort_key,
length(COALESCE(display_name, label)),
label
LIMIT 101
"""
result = await self.ds.get_internal_database().execute(sql, all_params)
# Build response with truncation
rows = list(result.rows)
truncated = len(rows) > 100
if truncated:
rows = rows[:100]
matches = [
{
"name": f"{row['parent']}: {row['child']}",
"url": self.ds.urls.table(row["parent"], row["child"]),
matches = []
for row in rows:
match = {
"name": row["label"],
"url": self._url_for_row(row),
"type": row["type"],
"description": row["description"],
}
for row in rows
]
if row["display_name"]:
match["display_name"] = row["display_name"]
matches.append(match)
return Response.json({"matches": matches, "truncated": truncated})

View file

@ -144,46 +144,65 @@ Shows currently attached databases. `Databases example <https://latest.datasette
}
]
.. _TablesView:
.. _JumpView:
/-/tables
---------
/-/jump
-------
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and canned queries, and plugins can contribute additional items.
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
The endpoint supports a ``?q=`` query parameter for filtering items by name.
Canned queries with a configured ``title`` also include a ``display_name`` in
their results, and can be found by searching for that title. Plugins can provide
the same extra field from ``jump_items_sql`` by returning a ``display_name``
column and setting ``JumpSQL(..., has_display_name=True)``.
`Tables example <https://latest.datasette.io/-/tables>`_:
`Jump example <https://latest.datasette.io/-/jump>`_:
.. code-block:: json
{
"matches": [
{
"name": "fixtures/facetable",
"url": "/fixtures/facetable"
"name": "fixtures",
"url": "/fixtures",
"type": "database",
"description": "Database"
},
{
"name": "fixtures/searchable",
"url": "/fixtures/searchable"
"name": "fixtures: facetable",
"url": "/fixtures/facetable",
"type": "table",
"description": "Table"
},
{
"name": "fixtures: recent_releases",
"display_name": "Recent Datasette releases",
"url": "/fixtures/recent_releases",
"type": "query",
"description": "Canned query"
}
]
],
"truncated": false
}
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
.. code-block:: json
{
"matches": [
{
"name": "fixtures/facetable",
"url": "/fixtures/facetable"
"name": "fixtures: facetable",
"url": "/fixtures/facetable",
"type": "table",
"description": "Table"
}
]
],
"truncated": false
}
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
.. _JsonDataView_threads:

View file

@ -52,7 +52,7 @@ async def test_ds():
@pytest.mark.asyncio
async def test_tables_endpoint_global_access(test_ds):
"""Test /-/tables with global access permissions"""
"""Test allowed_resources() with global access permissions"""
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "alice":
@ -91,7 +91,7 @@ async def test_tables_endpoint_global_access(test_ds):
@pytest.mark.asyncio
async def test_tables_endpoint_database_restriction(test_ds):
"""Test /-/tables with database-level restriction"""
"""Test allowed_resources() with database-level restriction"""
def rules_callback(datasette, actor, action):
if actor and actor.get("role") == "analyst":
@ -133,7 +133,7 @@ async def test_tables_endpoint_database_restriction(test_ds):
@pytest.mark.asyncio
async def test_tables_endpoint_table_exception(test_ds):
"""Test /-/tables with table-level exception (deny database, allow specific table)"""
"""Test allowed_resources() with table-level exception (deny database, allow specific table)"""
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "carol":
@ -217,7 +217,7 @@ async def test_tables_endpoint_deny_overrides_allow(test_ds):
@pytest.mark.asyncio
async def test_tables_endpoint_no_permissions():
"""Test /-/tables when user has no custom permissions (only defaults)"""
"""Test allowed_resources() when user has no custom permissions (only defaults)"""
ds = Datasette()
await ds.invoke_startup()
@ -241,7 +241,7 @@ async def test_tables_endpoint_no_permissions():
@pytest.mark.asyncio
async def test_tables_endpoint_specific_table_only(test_ds):
"""Test /-/tables when only specific tables are allowed (no parent/global rules)"""
"""Test allowed_resources() when only specific tables are allowed (no parent/global rules)"""
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "dave":
@ -283,7 +283,7 @@ async def test_tables_endpoint_specific_table_only(test_ds):
@pytest.mark.asyncio
async def test_tables_endpoint_empty_result(test_ds):
"""Test /-/tables when all tables are explicitly denied"""
"""Test allowed_resources() when all tables are explicitly denied"""
def rules_callback(datasette, actor, action):
if actor and actor.get("id") == "blocked":
@ -314,7 +314,7 @@ async def test_tables_endpoint_empty_result(test_ds):
@pytest.mark.asyncio
async def test_tables_endpoint_no_query_returns_all():
"""Test /-/tables without query parameter returns all tables"""
"""Test allowed_resources() without query parameter returns all tables"""
ds = Datasette()
await ds.invoke_startup()
@ -338,7 +338,7 @@ async def test_tables_endpoint_no_query_returns_all():
@pytest.mark.asyncio
async def test_tables_endpoint_truncation():
"""Test /-/tables truncates at 100 tables and sets truncated flag"""
"""Test allowed_resources() truncates at 100 tables and sets truncated flag"""
ds = Datasette()
await ds.invoke_startup()
@ -359,7 +359,7 @@ async def test_tables_endpoint_truncation():
@pytest.mark.asyncio
async def test_tables_endpoint_search_single_term():
"""Test /-/tables?q=user to filter tables matching 'user'"""
"""Test allowed_resources()?q=user to filter tables matching 'user'"""
ds = Datasette()
await ds.invoke_startup()
@ -396,7 +396,7 @@ async def test_tables_endpoint_search_single_term():
@pytest.mark.asyncio
async def test_tables_endpoint_search_multiple_terms():
"""Test /-/tables?q=user+profile to filter tables matching .*user.*profile.*"""
"""Test allowed_resources()?q=user+profile to filter tables matching .*user.*profile.*"""
ds = Datasette()
await ds.invoke_startup()

View file

@ -1019,6 +1019,13 @@ async def test_navigation_menu_links(
search_button.find("kbd")["title"]
== "Keyboard shortcut: press / to open Jump to"
)
navigation_search_script = soup.find(
"script", {"src": re.compile(r"navigation-search\.js")}
)
assert navigation_search_script["src"] == (
"/-/static/navigation-search.js?"
+ ds_client.ds.static_hash("navigation-search.js")
)
assert details.find("li").find("button") == search_button
if not actor_id:
# The app menu is always visible, but anonymous users do not see logout

View file

@ -228,7 +228,7 @@ async def test_orphan_stale_catalog_child_entries_removed(tmp_path):
""")
assert [tuple(row) for row in catalog_tables.rows] == [("alpha", "alpha_table")]
response = await ds2.client.get("/-/tables.json")
response = await ds2.client.get("/-/jump.json")
assert response.status_code == 200
ds2.close()

View file

@ -195,7 +195,7 @@ async def test_skip_permission_checks_with_admin_actor(datasette_with_permission
@pytest.mark.asyncio
async def test_skip_permission_checks_shows_denied_tables():
"""Test that skip_permission_checks=True shows tables from denied databases in /-/tables.json"""
"""Test that skip_permission_checks=True shows tables from denied databases in /-/jump.json"""
ds = Datasette(
config={
"databases": {
@ -211,8 +211,8 @@ async def test_skip_permission_checks_shows_denied_tables():
await db.execute_write("INSERT INTO test_table (id, name) VALUES (1, 'Alice')")
await ds._refresh_schemas()
# Without skip_permission_checks, tables from denied database should not appear in /-/tables.json
response = await ds.client.get("/-/tables.json")
# Without skip_permission_checks, tables from denied database should not appear in /-/jump.json
response = await ds.client.get("/-/jump.json")
assert response.status_code == 200
data = response.json()
table_names = [match["name"] for match in data["matches"]]
@ -221,7 +221,7 @@ async def test_skip_permission_checks_shows_denied_tables():
assert len(fixtures_tables) == 0
# With skip_permission_checks=True, tables from denied database SHOULD appear
response = await ds.client.get("/-/tables.json", skip_permission_checks=True)
response = await ds.client.get("/-/jump.json", skip_permission_checks=True)
assert response.status_code == 200
data = response.json()
table_names = [match["name"] for match in data["matches"]]

224
tests/test_jump.py Normal file
View file

@ -0,0 +1,224 @@
import pytest
import pytest_asyncio
from markupsafe import Markup
from datasette import hookimpl
from datasette.app import Datasette
from datasette.plugins import pm
@pytest_asyncio.fixture
async def ds_for_jump():
ds = Datasette(
config={
"databases": {
"content": {
"allow": {"id": "*"},
"tables": {
"articles": {"allow": {"id": "editor"}},
"comments": {"allow": True},
},
"queries": {
"recent_comments": {
"sql": "select * from comments",
"allow": {"id": "*"},
"title": "Recent comments",
},
"release_notes": {
"sql": "select 1",
"allow": {"id": "*"},
"title": "Recent Datasette releases",
},
"editor_report": {
"sql": "select * from articles",
"allow": {"id": "editor"},
},
},
},
"private": {
"allow": False,
"queries": {
"private_report": "select 1",
},
},
}
}
)
await ds.invoke_startup()
content_db = ds.add_memory_database("jump_test_content", name="content")
await content_db.execute_write(
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
)
await content_db.execute_write(
"CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body TEXT)"
)
await content_db.execute_write(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
)
await content_db.execute_write(
"CREATE VIEW IF NOT EXISTS comment_summary AS SELECT body FROM comments"
)
private_db = ds.add_memory_database("jump_test_private", name="private")
await private_db.execute_write(
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
)
public_db = ds.add_memory_database("jump_test_public", name="public")
await public_db.execute_write(
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
)
await ds._refresh_schemas()
return ds
@pytest.mark.asyncio
async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_jump):
response = await ds_for_jump.client.get(
"/-/jump.json?q=content", actor={"id": "user"}
)
assert response.status_code == 200
data = response.json()
matches_by_type_and_name = {
(match["type"], match["name"]): match for match in data["matches"]
}
assert ("database", "content") in matches_by_type_and_name
assert ("table", "content: comments") in matches_by_type_and_name
assert ("view", "content: comment_summary") in matches_by_type_and_name
assert ("query", "content: recent_comments") in matches_by_type_and_name
assert matches_by_type_and_name[("database", "content")]["url"] == "/content"
assert (
matches_by_type_and_name[("query", "content: recent_comments")]["display_name"]
== "Recent comments"
)
assert (
matches_by_type_and_name[("query", "content: recent_comments")]["url"]
== "/content/recent_comments"
)
@pytest.mark.asyncio
async def test_jump_searches_and_displays_canned_query_titles(ds_for_jump):
response = await ds_for_jump.client.get(
"/-/jump.json?q=datasette", actor={"id": "user"}
)
assert response.status_code == 200
data = response.json()
assert data["matches"] == [
{
"name": "content: release_notes",
"display_name": "Recent Datasette releases",
"url": "/content/release_notes",
"type": "query",
"description": "Canned query",
}
]
@pytest.mark.asyncio
async def test_jump_respects_resource_permissions(ds_for_jump):
regular = await ds_for_jump.client.get(
"/-/jump.json?q=articles", actor={"id": "regular"}
)
editor = await ds_for_jump.client.get(
"/-/jump.json?q=articles", actor={"id": "editor"}
)
private = await ds_for_jump.client.get(
"/-/jump.json?q=secrets", actor={"id": "editor"}
)
assert {match["name"] for match in regular.json()["matches"]} == {
"public: articles"
}
assert {match["name"] for match in editor.json()["matches"]} == {
"content: articles",
"public: articles",
}
assert private.json()["matches"] == []
@pytest.mark.asyncio
async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
from datasette.jump import JumpSQL
class JumpPlugin:
@hookimpl
def jump_items_sql(self, datasette, actor, request):
return JumpSQL(
sql="""
SELECT
'plugin' AS type,
'plugin-dashboard: ' || :actor_id AS label,
'Plugin supplied item' AS description,
'/-/plugin-dashboard' AS url,
NULL AS database_name,
NULL AS resource_name,
'plugin dashboard ' || :actor_id AS search_text,
80 AS sort_key,
'test-plugin' AS source,
'Plugin dashboard for ' || :actor_id AS display_name
""",
params={"actor_id": actor["id"] if actor else "anonymous"},
has_display_name=True,
)
plugin = JumpPlugin()
pm.register(plugin, name="test-jump-plugin")
try:
response = await ds_for_jump.client.get(
"/-/jump.json?q=dashboard", actor={"id": "alice"}
)
finally:
pm.unregister(name="test-jump-plugin")
assert response.status_code == 200
plugin_matches = [
match for match in response.json()["matches"] if match["type"] == "plugin"
]
assert plugin_matches == [
{
"name": "plugin-dashboard: alice",
"display_name": "Plugin dashboard for alice",
"url": "/-/plugin-dashboard",
"type": "plugin",
"description": "Plugin supplied item",
}
]
@pytest.mark.asyncio
async def test_jump_start_hook_renders_empty_state_template(ds_for_jump):
class JumpStartPlugin:
@hookimpl
def jump_start(self, datasette, actor, request):
if not actor:
return None
return Markup(
'<section class="agent-jump-start">'
"<h3>Agent chat</h3>"
'<a href="/-/agent/new">Start a new agent chat</a>'
"</section>"
)
plugin = JumpStartPlugin()
pm.register(plugin, name="test-jump-start-plugin")
try:
anonymous = await ds_for_jump.client.get("/")
authenticated = await ds_for_jump.client.get("/", actor={"id": "alice"})
finally:
pm.unregister(name="test-jump-start-plugin")
assert 'url="/-/jump"' in authenticated.text
assert "<template data-jump-start>" not in anonymous.text
assert "<template data-jump-start>" in authenticated.text
assert "Start a new agent chat" in authenticated.text
@pytest.mark.asyncio
async def test_tables_endpoint_removed(ds_for_jump):
response = await ds_for_jump.client.get("/-/tables.json")
assert response.status_code == 404

View file

@ -0,0 +1,199 @@
import json
import subprocess
import textwrap
def test_navigation_search_tracks_and_renders_recent_items():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
class FakeElement {
constructor() {
this.innerHTML = "";
this.value = "";
this.dataset = {};
this.open = false;
}
addEventListener() {}
close() { this.open = false; }
focus() {}
querySelector() {
return { scrollIntoView() {} };
}
showModal() { this.open = true; }
}
class FakeShadowRoot {
constructor() {
this.innerHTML = "";
this.dialog = new FakeElement();
this.input = new FakeElement();
this.results = new FakeElement();
}
querySelector(selector) {
if (selector == "dialog") return this.dialog;
if (selector == ".search-input") return this.input;
if (selector == ".results-container") return this.results;
return new FakeElement();
}
}
global.HTMLElement = class {
constructor() {
this.attributes = {};
}
attachShadow() {
this.shadowRoot = new FakeShadowRoot();
return this.shadowRoot;
}
dispatchEvent() {}
getAttribute(name) {
return this.attributes[name] || null;
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attributes[name] = value;
}
};
global.CustomEvent = class {
constructor(name, options) {
this.name = name;
this.options = options;
}
};
global.customElements = {
registry: new Map(),
define(name, cls) {
this.registry.set(name, cls);
},
};
global.document = {
addEventListener() {},
activeElement: null,
createElement() {
return {
set textContent(value) {
this.innerHTML = String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
},
};
},
};
global.localStorage = {
store: {},
getItem(key) {
return Object.prototype.hasOwnProperty.call(this.store, key)
? this.store[key]
: null;
},
setItem(key, value) {
this.store[key] = String(value);
},
removeItem(key) {
delete this.store[key];
},
};
global.window = { location: { href: "" } };
vm.runInThisContext(
fs.readFileSync("datasette/static/navigation-search.js", "utf8"),
{ filename: "navigation-search.js" }
);
const Component = customElements.registry.get("navigation-search");
const element = new Component();
const items = Array.from({ length: 6 }, (_, index) => ({
name: `Item ${index + 1}`,
url: `/item-${index + 1}`,
type: "table",
description: "Table",
}));
items[5].name = "content: recent_datasette_releases";
items[5].display_name = "Recent Datasette releases";
for (const item of items) {
element.matches = [item];
element.renderedMatches = [item];
element.selectedIndex = 0;
element.selectCurrentItem();
}
const stored = JSON.parse(
Object.values(localStorage.store).find((value) => value.includes("/item-6"))
);
if (stored.length !== 5) {
throw new Error(`Expected 5 recent items, got ${stored.length}`);
}
if (stored[0].url !== "/item-6" || stored[4].url !== "/item-2") {
throw new Error(`Unexpected recent order: ${JSON.stringify(stored)}`);
}
if (stored[0].display_name !== "Recent Datasette releases") {
throw new Error(`Missing display_name in recent item: ${JSON.stringify(stored[0])}`);
}
element.matches = [
items[5],
items[4],
{
name: "Other",
url: "/other",
type: "database",
description: "Database",
},
];
element.shadowRoot.input.value = "";
element.renderResults();
const html = element.shadowRoot.results.innerHTML;
if (!html.includes("Recent")) {
throw new Error(`Missing Recent heading: ${html}`);
}
if (!html.includes("Recent Datasette releases") || !html.includes("Item 5")) {
throw new Error(`Missing recent items: ${html}`);
}
if (!html.includes("content: recent_datasette_releases")) {
throw new Error(`Missing canonical item name for display_name item: ${html}`);
}
if (!html.includes("Item 4") || !html.includes("Item 2")) {
throw new Error(`Expected all stored recent items in empty state: ${html}`);
}
if (html.includes("Other")) {
throw new Error(`Rendered non-recent item in empty state: ${html}`);
}
if (!html.includes("Clear recent")) {
throw new Error(`Missing Clear recent control: ${html}`);
}
element.clearRecentItems();
if (localStorage.getItem(element.recentItemsStorageKey()) !== null) {
throw new Error("Expected recent items to be cleared");
}
element.renderResults();
if (element.shadowRoot.results.innerHTML.includes("Clear recent")) {
throw new Error("Clear recent should disappear after clearing");
}
process.stdout.write(JSON.stringify(stored));
""")
result = subprocess.run(
["node", "-e", script],
cwd=".",
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert [item["url"] for item in json.loads(result.stdout)] == [
"/item-6",
"/item-5",
"/item-4",
"/item-3",
"/item-2",
]
assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases"

View file

@ -33,7 +33,7 @@ async def ds_with_tables():
await ds.invoke_startup()
# Add content database with some tables
content_db = ds.add_memory_database("content")
content_db = ds.add_memory_database("search_tables_content", name="content")
await content_db.execute_write(
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
)
@ -45,27 +45,28 @@ async def ds_with_tables():
)
# Add private database with a table
private_db = ds.add_memory_database("private")
private_db = ds.add_memory_database("search_tables_private", name="private")
await private_db.execute_write(
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
)
# Add another public database
public_db = ds.add_memory_database("public")
public_db = ds.add_memory_database("search_tables_public", name="public")
await public_db.execute_write(
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
)
await ds._refresh_schemas()
return ds
# /-/tables.json tests
# /-/jump.json table search tests
@pytest.mark.asyncio
async def test_tables_basic_search(ds_with_tables):
"""Test basic table search functionality."""
# Search for "articles" - should find it in both content and public databases
# but only return public.articles for anonymous user (content.articles requires auth)
response = await ds_with_tables.client.get("/-/tables.json?q=articles")
response = await ds_with_tables.client.get("/-/jump.json?q=articles")
assert response.status_code == 200
data = response.json()
@ -85,7 +86,7 @@ async def test_tables_search_with_auth(ds_with_tables):
"""Test that authenticated users see more tables."""
# Editor user should see content.articles
response = await ds_with_tables.client.get(
"/-/tables.json?q=articles",
"/-/jump.json?q=articles",
actor={"id": "editor"},
)
assert response.status_code == 200
@ -103,7 +104,7 @@ async def test_tables_search_partial_match(ds_with_tables):
"""Test that search matches partial table names."""
# Search for "com" should match "comments"
response = await ds_with_tables.client.get(
"/-/tables.json?q=com",
"/-/jump.json?q=com",
actor={"id": "user"},
)
assert response.status_code == 200
@ -119,7 +120,7 @@ async def test_tables_search_respects_database_permissions(ds_with_tables):
# Search for "secrets" which is in the private database
# Even authenticated users shouldn't see it because database is denied
response = await ds_with_tables.client.get(
"/-/tables.json?q=secrets",
"/-/jump.json?q=secrets",
actor={"id": "user"},
)
assert response.status_code == 200
@ -134,7 +135,7 @@ async def test_tables_search_respects_table_permissions(ds_with_tables):
"""Test that tables with specific permissions are filtered correctly."""
# Regular authenticated user searching for "users"
response = await ds_with_tables.client.get(
"/-/tables.json?q=users",
"/-/jump.json?q=users",
actor={"id": "regular"},
)
assert response.status_code == 200
@ -149,7 +150,7 @@ async def test_tables_search_respects_table_permissions(ds_with_tables):
async def test_tables_search_response_structure(ds_with_tables):
"""Test that response has correct structure."""
response = await ds_with_tables.client.get(
"/-/tables.json?q=users",
"/-/jump.json?q=users",
actor={"id": "user"},
)
assert response.status_code == 200