jump_items_sql() and makeJumpSections() plugin hooks (#2732)

* /-/tables is now /-/jump
* `jump_items_sql()` plugin hook for contributing to that menu
* JavaScript `makeJumpSections()` hook for populating blank slate
* Menu now stores up to five recently visited items in localStorage
This commit is contained in:
Simon Willison 2026-05-23 21:11:17 -07:00 committed by GitHub
commit a75c9f2401
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1802 additions and 187 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 = {}
@ -2222,8 +2233,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

@ -0,0 +1,75 @@
from datasette import hookimpl
from datasette.jump import JumpSQL
DEBUG_MENU_ITEMS = (
(
"/-/databases",
"Databases",
"List of databases known to this Datasette instance.",
),
(
"/-/plugins",
"Installed plugins",
"Review loaded plugins, their versions and their registered hooks.",
),
(
"/-/versions",
"Version info",
"Check the Python, SQLite and dependency versions used by this server.",
),
(
"/-/settings",
"Settings",
"Inspect the active Datasette settings and configuration values.",
),
(
"/-/permissions",
"Debug permissions",
"Test permission checks for actors, actions and resources.",
),
(
"/-/messages",
"Debug messages",
"Try out temporary flash messages shown to users.",
),
(
"/-/allow-debug",
"Debug allow rules",
"Explore how allow blocks match actors against permission rules.",
),
(
"/-/threads",
"Debug threads",
"Inspect worker threads and database tasks.",
),
(
"/-/actor",
"Debug actor",
"View the actor object for the current signed-in user.",
),
(
"/-/patterns",
"Pattern portfolio",
"Browse Datasette UI patterns.",
),
)
@hookimpl
def jump_items_sql(datasette, actor, request):
async def inner():
if not await datasette.allowed(action="debug-menu", actor=actor):
return []
return [
JumpSQL.menu_item(
label=label,
url=datasette.urls.path(path),
description=description,
search_text=f"debug {label} {description}",
item_type="debug",
)
for path, label, description in DEBUG_MENU_ITEMS
]
return inner

View file

@ -0,0 +1,82 @@
from datasette import hookimpl
from datasette.jump import JumpSQL
@hookimpl
def jump_items_sql(datasette, actor, request):
async def inner():
database_sql, database_params = await datasette.allowed_resources_sql(
action="view-database", actor=actor
)
table_sql, table_params = await datasette.allowed_resources_sql(
action="view-table", actor=actor
)
query_sql, query_params = await datasette.allowed_resources_sql(
action="view-query", actor=actor
)
return [
JumpSQL(
sql=f"""
WITH allowed_databases AS (
{database_sql}
)
SELECT
'database' AS type,
parent AS label,
NULL AS description,
json_object(
'method', 'database',
'database', parent
) AS url,
parent AS search_text,
NULL AS display_name
FROM allowed_databases
""",
params=database_params,
),
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,
NULL AS description,
json_object(
'method', 'table',
'database', allowed_tables.parent,
'table', allowed_tables.child
) AS url,
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
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,
),
JumpSQL(
sql=f"""
WITH allowed_queries AS (
{query_sql}
)
SELECT
'query' AS type,
allowed_queries.parent || ': ' || allowed_queries.child AS label,
NULL AS description,
json_object(
'method', 'query',
'database', allowed_queries.parent,
'query', allowed_queries.child
) AS url,
allowed_queries.parent || ' ' || allowed_queries.child AS search_text,
NULL AS display_name
FROM allowed_queries
""",
params=query_params,
),
]
return inner

View file

@ -1,41 +0,0 @@
from datasette import hookimpl
@hookimpl
def menu_links(datasette, actor):
async def inner():
if not await datasette.allowed(action="debug-menu", actor=actor):
return []
return [
{"href": datasette.urls.path("/-/databases"), "label": "Databases"},
{
"href": datasette.urls.path("/-/plugins"),
"label": "Installed plugins",
},
{
"href": datasette.urls.path("/-/versions"),
"label": "Version info",
},
{
"href": datasette.urls.path("/-/settings"),
"label": "Settings",
},
{
"href": datasette.urls.path("/-/permissions"),
"label": "Debug permissions",
},
{
"href": datasette.urls.path("/-/messages"),
"label": "Debug messages",
},
{
"href": datasette.urls.path("/-/allow-debug"),
"label": "Debug allow rules",
},
{"href": datasette.urls.path("/-/threads"), "label": "Debug threads"},
{"href": datasette.urls.path("/-/actor"), "label": "Debug actor"},
{"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"},
]
return inner

View file

@ -157,6 +157,11 @@ 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"""
@hookspec
def row_actions(datasette, actor, request, database, table, row):
"""Links for the row actions menu"""

68
datasette/jump.py Normal file
View file

@ -0,0 +1,68 @@
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
database: str | None = None
@classmethod
def menu_item(
cls,
*,
label: str,
url: str,
description: str = "Menu item",
search_text: str | None = None,
display_name: str | None = None,
item_type: str = "menu",
) -> "JumpSQL":
if search_text is None:
search_text = " ".join(
text for text in (label, display_name, description) if text is not None
)
return cls(
sql="""
SELECT
:type AS type,
:label AS label,
:description AS description,
:url AS url,
:search_text AS search_text,
:display_name AS display_name
""",
params={
"type": item_type,
"label": label,
"description": description,
"url": url,
"search_text": search_text,
"display_name": display_name,
},
)
_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 query parameters 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

@ -28,7 +28,8 @@ DEFAULT_PLUGINS = (
"datasette.default_column_types",
"datasette.default_magic_parameters",
"datasette.blob_renderer",
"datasette.default_menu_links",
"datasette.default_debug_menu",
"datasette.default_jump_items",
"datasette.handle_exception",
"datasette.forbidden",
"datasette.events",

View file

@ -82,6 +82,19 @@ const datasetteManager = {
return columnActions;
},
makeJumpSections: (context) => {
let jumpSections = [];
datasetteManager.plugins.forEach((plugin) => {
if (plugin.makeJumpSections) {
const sections = plugin.makeJumpSections(context) || [];
jumpSections.push(...sections);
}
});
return jumpSections;
},
/**
* In MVP, each plugin can only have 1 instance.
* In future, panels could be repeated. We omit that for now since so many plugins depend on
@ -192,7 +205,6 @@ const initializeDatasette = () => {
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
window.__DATASETTE__ = datasetteManager;
console.debug("Datasette Manager Created!");
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
detail: datasetteManager,

View file

@ -100,16 +100,81 @@ class NavigationSearch extends HTMLElement {
background-color: #dbeafe;
}
.result-item > div {
flex: 1;
min-width: 0;
}
.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-type {
color: #4b5563;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.result-url {
font-size: 0.875rem;
color: #6b7280;
}
.result-description {
color: #374151;
display: -webkit-box;
font-size: 0.8125rem;
line-height: 1.35;
margin-top: 0.35rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.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 +233,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 +296,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 +378,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 +392,215 @@ 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();
}
jumpSections() {
const manager = window.__DATASETTE__;
if (!manager || typeof manager.makeJumpSections !== "function") {
return [];
}
const sections = manager.makeJumpSections({
navigationSearch: this,
});
return Array.isArray(sections)
? sections.filter(
(section) => section && typeof section.render === "function",
)
: [];
}
jumpSectionsHtml(jumpSections) {
return jumpSections
.map((section, index) => {
const id = section.id
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
: "";
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
})
.join("");
}
renderJumpSections(container, jumpSections) {
jumpSections.forEach((section, index) => {
const node = container.querySelector(
`[data-jump-section-index="${index}"]`,
);
if (!node) {
return;
}
section.render(node, {
navigationSearch: this,
container,
input: this.shadowRoot.querySelector(".search-input"),
});
});
}
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 type = match.type
? `<div class="result-type">${this.escapeHtml(match.type)}</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>
${type}
<div class="result-name">${this.escapeHtml(displayName)}</div>
${label}
<div class="result-url">${this.escapeHtml(match.url)}</div>
${description}
</div>
</div>
`,
`;
}
renderResults() {
const container = this.shadowRoot.querySelector(".results-container");
const input = this.shadowRoot.querySelector(".search-input");
const showStartContent = !input.value.trim();
const jumpSections = showStartContent ? this.jumpSections() : [];
const startBlock = showStartContent
? this.jumpSectionsHtml(jumpSections)
: "";
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;
this.renderJumpSections(container, jumpSections);
} 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;
this.renderJumpSections(container, jumpSections);
// 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 +608,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 +655,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 +668,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

@ -71,6 +71,6 @@
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
<navigation-search url="/-/tables"></navigation-search>
<navigation-search url="/-/jump"></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,183 @@ 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 _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
# Get SQL for allowed resources using the permission system
permission_sql, params = await self.ds.allowed_resources_sql(
action="view-table", actor=request.actor
)
def _resolve_url(self, url):
if not url or url.startswith("/"):
return url
# 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) + "%"
descriptor = json.loads(url)
if not isinstance(descriptor, dict):
raise TypeError("jump item url JSON must be an object")
method_name = descriptor.get("method")
if not isinstance(method_name, str) or not method_name:
raise TypeError("jump item url JSON must include a method")
if method_name.startswith("_"):
raise AttributeError(f"datasette.urls has no method named {method_name!r}")
try:
method = getattr(self.ds.urls, method_name)
except AttributeError as ex:
raise AttributeError(
f"datasette.urls has no method named {method_name!r}"
) from ex
if not callable(method):
raise TypeError(f"datasette.urls.{method_name} is not callable")
kwargs = {key: value for key, value in descriptor.items() if key != "method"}
try:
return method(**kwargs)
except TypeError as ex:
raise TypeError(
f"Invalid arguments for datasette.urls.{method_name}(): {ex}"
) from ex
# Build query with CTE to filter by search pattern
sql = f"""
WITH allowed_tables AS (
{permission_sql}
)
SELECT parent, child
FROM allowed_tables
WHERE child LIKE :pattern COLLATE NOCASE
ORDER BY length(child), child
"""
all_params = {**params, "pattern": pattern}
def _sort_key(self, row, q):
display_label = row["display_name"] or row["label"]
display_label_lower = display_label.lower()
q_lower = q.lower()
if display_label_lower == q_lower:
relevance = 0
elif display_label_lower.startswith(q_lower):
relevance = 1
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}
relevance = 2
type_sort = {
"database": 10,
"table": 20,
"view": 25,
"query": 30,
}.get(row["type"], 50)
return (relevance, type_sort, len(display_label), row["label"])
async def _rows_for_database(self, database_name, indexed_fragments, q, pattern):
params = {"q": q, "pattern": pattern}
union_parts = []
for index, fragment in indexed_fragments:
fragment_sql, fragment_params = namespace_sql_params(
fragment.sql,
fragment.params or {},
f"jump_{index}",
)
SELECT parent, child
FROM allowed_tables
ORDER BY parent, child
LIMIT 101
"""
all_params = params
union_parts.append(f"""
SELECT
type,
label,
description,
url,
search_text,
display_name
FROM (
{fragment_sql}
)
""")
params.update(fragment_params)
sql = f"""
WITH jump_items AS (
{" UNION ALL ".join(union_parts)}
)
SELECT
type,
label,
description,
url,
search_text,
display_name
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,
CASE type
WHEN 'database' THEN 10
WHEN 'table' THEN 20
WHEN 'view' THEN 25
WHEN 'query' THEN 30
ELSE 50
END,
length(COALESCE(display_name, label)),
label
LIMIT 101
"""
db = (
self.ds.get_internal_database()
if database_name is None
else self.ds.get_database(database_name)
)
result = await db.execute(sql, params)
return list(result.rows)
# Execute against internal database
result = await self.ds.get_internal_database().execute(sql, all_params)
async def get(self, request):
q = request.args.get("q", "").strip()
terms = q.split()
pattern = "%" + "%".join(terms) + "%" if terms else "%"
fragments = await self._fragments(request)
# Build response with truncation
rows = list(result.rows)
truncated = len(rows) > 100
if truncated:
fragments_by_database = {}
for index, fragment in enumerate(fragments):
fragments_by_database.setdefault(fragment.database, []).append(
(index, fragment)
)
rows = []
truncated = False
for database_name, indexed_fragments in fragments_by_database.items():
database_rows = await self._rows_for_database(
database_name, indexed_fragments, q, pattern
)
if len(database_rows) > 100:
truncated = True
database_rows = database_rows[:100]
rows.extend(database_rows)
rows.sort(key=lambda row: self._sort_key(row, q))
if len(rows) > 100:
truncated = True
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._resolve_url(row["url"]),
"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

@ -1398,4 +1398,4 @@ Actor is allowed to view the ``/-/permissions`` debug tools.
debug-menu
----------
Controls if the various debug pages are displayed in the navigation menu.
Controls if the various debug pages are displayed in the jump menu.

View file

@ -13,6 +13,12 @@ Unreleased
- Fixed a bug where stale tables and other related resources were not removed from ``catalog_*`` tables when a database was removed. (:issue:`2723`)
- Fixed a Safari bug with the table search mechanism triggered by pressing ``/``. (:issue:`2724`)
- New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`)
- The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``.
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument. Datasette groups these queries by database and executes one ``UNION ALL`` query for each database. Each row returned by this hook includes a ``url`` value, which can be a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods.
- ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog.
- New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query.
- Jump menu results now show their ``type`` as a category label, and can show optional longer ``description`` text for individual results.
- Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item.
- New documented :ref:`datasette.fixtures.populate_fixture_database(conn) <datasette_fixtures_populate_fixture_database>` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites.
.. _v1_0_a29:

View file

@ -144,46 +144,62 @@ 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.
Each item includes a ``type`` string used as a category label in the menu. Items can also include an optional ``description`` with longer text describing that individual result.
`Tables example <https://latest.datasette.io/-/tables>`_:
The endpoint supports a ``?q=`` query parameter for filtering items by name.
`Jump example <https://latest.datasette.io/-/jump>`_:
.. code-block:: json
{
"matches": [
{
"name": "fixtures/facetable",
"url": "/fixtures/facetable"
"name": "fixtures",
"url": "/fixtures",
"type": "database",
"description": null
},
{
"name": "fixtures/searchable",
"url": "/fixtures/searchable"
"name": "fixtures: facetable",
"url": "/fixtures/facetable",
"type": "table",
"description": null
},
{
"name": "fixtures: recent_releases",
"url": "/fixtures/recent_releases",
"type": "query",
"description": null
}
]
],
"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": null
}
]
],
"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

@ -58,6 +58,48 @@ JavaScript plugins are blocks of code that can be registered with Datasette usin
The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
.. _javascript_plugins_makeJumpSections:
makeJumpSections()
~~~~~~~~~~~~~~~~~~
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search.
Each object should have the following:
``id`` - string
A unique string ID for the section, for example ``agent-chat``
``render(node, context)`` - function
A function that will be called with a DOM node to render the section into
The ``context`` object has the following keys:
``navigationSearch``
The ``<navigation-search>`` custom element instance.
This example shows how a plugin might add a button for starting a new chat:
.. code-block:: javascript
document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('agent-plugin', {
version: 0.1,
makeJumpSections: () => {
return [
{
id: 'agent-chat',
render: node => {
node.innerHTML = '<button type="button">Start a new chat</button>';
node.querySelector('button').addEventListener('click', () => {
location.href = '/-/agent/new';
});
}
}
];
}
});
});
.. _javascript_plugins_makeAboveTablePanelConfigs:
makeAboveTablePanelConfigs()

View file

@ -1881,6 +1881,106 @@ Using :ref:`internals_datasette_urls` here ensures that links in the menu will t
Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
.. _plugin_hook_jump_items_sql:
jump_items_sql(datasette, actor, request)
-----------------------------------------
``datasette`` - :ref:`internals_datasette`
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
``actor`` - dictionary or None
The currently authenticated :ref:`actor <authentication_actor>`.
``request`` - :ref:`internals_request`
The current HTTP request.
This hook allows plugins to add extra results to Datasette's ``/`` jump menu, which is powered by the ``/-/jump`` JSON endpoint.
Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and canned query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values.
``JumpSQL`` queries run against Datasette's internal database by default. To run a query against another database, pass its name as the optional ``database=`` argument. For example, ``JumpSQL(database="content", sql="...")`` runs against the ``content`` database.
Datasette groups ``JumpSQL`` queries by database and executes one ``UNION ALL`` query for each database.
The SQL query must return these columns:
``type``
A short type string for the result, for example ``"app"`` or ``"dashboard"``. The jump menu displays this above the item as a category label.
``label``
The stable name for the result. This is returned as ``name`` in the JSON API and is used for sorting.
``description``
Optional longer text describing this individual item, or ``NULL``. The jump menu displays this below the item's URL when it is present.
``url``
The URL to navigate to when the item is selected. This can be either a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods. For example, ``json_object('method', 'table', 'database', 'fixtures', 'table', 'facetable')`` calls ``datasette.urls.table(database='fixtures', table='facetable')``. Unknown methods or invalid named arguments will result in an error.
``search_text``
Text that should be searched by the ``?q=`` parameter.
``display_name``
A human-readable label for the result, or ``NULL``. Datasette returns this as ``display_name`` in the JSON API, and the jump menu shows it as the primary readable label with ``name`` shown underneath.
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before adding the SQL fragment to the per-database ``UNION ALL`` query.
This example returns a SQL fragment that searches rows from a ``dashboards`` table in the ``content`` database. The ``url`` column uses ``json_object()`` to describe a call to ``datasette.urls.row(database='content', table='dashboards', row_path=slug)``:
.. code-block:: python
from datasette import hookimpl
from datasette.jump import JumpSQL
@hookimpl
def jump_items_sql(datasette, actor, request):
if not actor:
return None
return JumpSQL(
sql="""
SELECT
'dashboard' AS type,
slug AS label,
description AS description,
json_object(
'method', 'row',
'database', 'content',
'table', 'dashboards',
'row_path', slug
) AS url,
slug || ' ' || COALESCE(title, '') || ' ' || COALESCE(description, '') AS search_text,
title AS display_name
FROM dashboards
WHERE owner_id = :actor_id
""",
params={"actor_id": actor["id"]},
database="content",
)
This example uses the ``JumpSQL.menu_item()`` shortcut to add a single "Plugin dashboard" result for signed-in users:
.. code-block:: python
from datasette import hookimpl
from datasette.jump import JumpSQL
@hookimpl
def jump_items_sql(datasette, actor, request):
if not actor:
return None
return JumpSQL.menu_item(
item_type="dashboard",
label="plugin-dashboard",
description="Review plugin status and configuration.",
url="/-/plugin-dashboard",
search_text="plugin dashboard",
display_name="Plugin dashboard",
)
``JumpSQL.menu_item(...)`` is a shortcut for adding a single jump menu item from Python code. It accepts the keyword arguments shown above.
.. _plugin_actions:
Action hooks

View file

@ -216,6 +216,24 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"register_column_types"
]
},
{
"name": "datasette.default_debug_menu",
"static": false,
"templates": false,
"version": null,
"hooks": [
"jump_items_sql"
]
},
{
"name": "datasette.default_jump_items",
"static": false,
"templates": false,
"version": null,
"hooks": [
"jump_items_sql"
]
},
{
"name": "datasette.default_magic_parameters",
"static": false,
@ -225,15 +243,6 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
"register_magic_parameters"
]
},
{
"name": "datasette.default_menu_links",
"static": false,
"templates": false,
"version": null,
"hooks": [
"menu_links"
]
},
{
"name": "datasette.default_permissions",
"static": false,

View file

@ -51,8 +51,8 @@ async def test_ds():
@pytest.mark.asyncio
async def test_tables_endpoint_global_access(test_ds):
"""Test /-/tables with global access permissions"""
async def test_tables_allowed_resources_global_access(test_ds):
"""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

@ -994,7 +994,7 @@ def test_edit_sql_link_not_shown_if_user_lacks_permission(has_permission):
[
(None, None, None),
("test", None, ["/-/permissions"]),
("root", ["/-/permissions", "/-/allow-debug"], None),
("root", None, ["/-/permissions", "/-/allow-debug"]),
],
)
async def test_navigation_menu_links(
@ -1019,6 +1019,10 @@ 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"
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"]]

465
tests/test_jump.py Normal file
View file

@ -0,0 +1,465 @@
import pytest
import pytest_asyncio
from datasette import hookimpl
from datasette.app import Datasette
from datasette.jump import JumpSQL
from datasette.plugins import pm
from datasette.views.special import JumpView
@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")]["url"]
== "/content/recent_comments"
)
@pytest.mark.asyncio
async def test_jump_uses_canned_query_names_not_titles(ds_for_jump):
response = await ds_for_jump.client.get(
"/-/jump.json?q=datasette", actor={"id": "user"}
)
assert response.status_code == 200
assert response.json()["matches"] == []
response = await ds_for_jump.client.get(
"/-/jump.json?q=release", actor={"id": "user"}
)
assert response.status_code == 200
assert response.json()["matches"] == [
{
"name": "content: release_notes",
"url": "/content/release_notes",
"type": "query",
"description": None,
}
]
@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_sql_menu_item_helper(ds_for_jump):
assert JumpSQL("SELECT 1").database is None
assert JumpSQL("SELECT 1", database="content").database == "content"
assert JumpSQL("SELECT 1", None, "content").database == "content"
fragment = JumpSQL.menu_item(
label="Plugin dashboard",
url="/-/plugin-dashboard",
description="Plugin tool",
search_text="dashboard plugin",
display_name="Plugin Dashboard",
item_type="plugin",
)
result = await ds_for_jump.get_internal_database().execute(
fragment.sql, fragment.params
)
assert dict(result.first()) == {
"type": "plugin",
"label": "Plugin dashboard",
"description": "Plugin tool",
"url": "/-/plugin-dashboard",
"search_text": "dashboard plugin",
"display_name": "Plugin Dashboard",
}
@pytest.mark.asyncio
async def test_debug_menu_items_are_in_jump_for_debug_menu_permission():
ds = Datasette(
config={
"permissions": {
"debug-menu": {"id": "debugger"},
}
}
)
await ds.invoke_startup()
response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "debugger"})
assert response.status_code == 200
debug_matches = [
match for match in response.json()["matches"] if match["type"] == "debug"
]
assert {match["name"]: match["url"] for match in debug_matches} == {
"Databases": "/-/databases",
"Installed plugins": "/-/plugins",
"Version info": "/-/versions",
"Settings": "/-/settings",
"Debug permissions": "/-/permissions",
"Debug messages": "/-/messages",
"Debug allow rules": "/-/allow-debug",
"Debug threads": "/-/threads",
"Debug actor": "/-/actor",
"Pattern portfolio": "/-/patterns",
}
descriptions_by_name = {
match["name"]: match["description"] for match in debug_matches
}
assert all(descriptions_by_name.values())
assert descriptions_by_name["Databases"] == (
"List of databases known to this Datasette instance."
)
@pytest.mark.asyncio
async def test_debug_menu_items_are_hidden_without_debug_menu_permission():
ds = Datasette()
await ds.invoke_startup()
response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "regular"})
assert response.status_code == 200
assert [
match for match in response.json()["matches"] if match["type"] == "debug"
] == []
@pytest.mark.asyncio
async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
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,
'plugin dashboard ' || :actor_id AS search_text,
'Plugin dashboard for ' || :actor_id AS display_name
""",
params={"actor_id": actor["id"] if actor else "anonymous"},
)
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_sql_unions_fragments_by_database(ds_for_jump, monkeypatch):
class JumpPlugin:
@hookimpl
def jump_items_sql(self, datasette, actor, request):
return [
JumpSQL(sql="""
SELECT
'plugin' AS type,
'first-unioned-item' AS label,
NULL AS description,
'/-/first-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
"""),
JumpSQL(sql="""
SELECT
'plugin' AS type,
'second-unioned-item' AS label,
NULL AS description,
'/-/second-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
"""),
JumpSQL(
"""
SELECT
'plugin' AS type,
'content-first-unioned-item' AS label,
NULL AS description,
'/-/content-first-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
""",
None,
"content",
),
JumpSQL(
database="content",
sql="""
SELECT
'plugin' AS type,
'content-second-unioned-item' AS label,
NULL AS description,
'/-/content-second-unioned-item' AS url,
'unioned item' AS search_text,
NULL AS display_name
""",
),
]
internal_db = ds_for_jump.get_internal_database()
original_execute = internal_db.execute
internal_jump_query_sql = []
async def internal_execute_with_recording(sql, *args, **kwargs):
if "unioned-item" in sql:
internal_jump_query_sql.append(sql)
return await original_execute(sql, *args, **kwargs)
monkeypatch.setattr(internal_db, "execute", internal_execute_with_recording)
content_db = ds_for_jump.get_database("content")
original_content_execute = content_db.execute
content_jump_query_sql = []
async def content_execute_with_recording(sql, *args, **kwargs):
if "unioned-item" in sql:
content_jump_query_sql.append(sql)
return await original_content_execute(sql, *args, **kwargs)
monkeypatch.setattr(content_db, "execute", content_execute_with_recording)
plugin = JumpPlugin()
pm.register(plugin, name="test-jump-union-plugin")
try:
response = await ds_for_jump.client.get(
"/-/jump.json?q=unioned", actor={"id": "alice"}
)
finally:
pm.unregister(name="test-jump-union-plugin")
assert response.status_code == 200
assert len(internal_jump_query_sql) == 1
assert " UNION ALL " in internal_jump_query_sql[0]
assert len(content_jump_query_sql) == 1
assert " UNION ALL " in content_jump_query_sql[0]
assert {match["name"] for match in response.json()["matches"]} == {
"content-first-unioned-item",
"content-second-unioned-item",
"first-unioned-item",
"second-unioned-item",
}
@pytest.mark.asyncio
async def test_jump_sql_can_query_named_database(ds_for_jump):
content_db = ds_for_jump.get_database("content")
await content_db.execute_write(
"INSERT INTO comments (id, body) VALUES (1001, 'Named database jump target')"
)
class JumpPlugin:
@hookimpl
def jump_items_sql(self, datasette, actor, request):
return JumpSQL(
database="content",
sql="""
SELECT
'comment' AS type,
body AS label,
'Comment from content database' AS description,
json_object(
'method', 'table',
'database', 'content',
'table', 'comments'
) AS url,
body AS search_text,
body AS display_name
FROM comments
WHERE id = :comment_id
""",
params={"comment_id": 1001},
)
plugin = JumpPlugin()
pm.register(plugin, name="test-jump-content-db-plugin")
try:
response = await ds_for_jump.client.get(
"/-/jump.json?q=named+database", actor={"id": "alice"}
)
finally:
pm.unregister(name="test-jump-content-db-plugin")
assert response.status_code == 200
plugin_matches = [
match for match in response.json()["matches"] if match["type"] == "comment"
]
assert plugin_matches == [
{
"name": "Named database jump target",
"display_name": "Named database jump target",
"url": "/content/comments",
"type": "comment",
"description": "Comment from content database",
}
]
@pytest.mark.asyncio
async def test_jump_resolves_url_descriptors_from_sql(ds_for_jump):
class JumpPlugin:
@hookimpl
def jump_items_sql(self, datasette, actor, request):
return JumpSQL(sql="""
SELECT
'plugin' AS type,
'Table descriptor' AS label,
NULL AS description,
json_object(
'method', 'table',
'database', 'content',
'table', 'comments'
) AS url,
'table descriptor comments' AS search_text,
NULL AS display_name
""")
plugin = JumpPlugin()
pm.register(plugin, name="test-jump-url-descriptor-plugin")
try:
response = await ds_for_jump.client.get(
"/-/jump.json?q=descriptor", actor={"id": "alice"}
)
finally:
pm.unregister(name="test-jump-url-descriptor-plugin")
assert response.status_code == 200
plugin_matches = [
match for match in response.json()["matches"] if match["type"] == "plugin"
]
assert plugin_matches == [
{
"name": "Table descriptor",
"url": "/content/comments",
"type": "plugin",
"description": None,
}
]
@pytest.mark.asyncio
async def test_jump_url_descriptor_errors(ds_for_jump):
view = JumpView(ds_for_jump)
with pytest.raises(AttributeError):
view._resolve_url('{"method": "not_a_url_method"}')
with pytest.raises(TypeError):
view._resolve_url(
'{"method": "table", "database_name": "content", "table_name": "comments"}'
)
@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,394 @@
import json
from pathlib import Path
import subprocess
import textwrap
REPO_ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = REPO_ROOT / "datasette" / "static"
def test_navigation_search_tracks_and_renders_recent_items():
script = textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
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(navigationSearchJs, "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));
""").replace(
"__NAVIGATION_SEARCH_JS__",
json.dumps(str(STATIC_DIR / "navigation-search.js")),
)
result = subprocess.run(
["node", "-e", script],
cwd=REPO_ROOT,
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"
def test_navigation_search_renders_jump_sections_from_javascript_plugins():
script = (
textwrap.dedent("""
const fs = require("fs");
const vm = require("vm");
const datasetteManagerJs = __DATASETTE_MANAGER_JS__;
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
const documentListeners = {};
class FakeElement {
constructor(tagName = "div", parent = null) {
this._innerHTML = "";
this.value = "";
this.dataset = {};
this.open = false;
this.parent = parent;
this.tagName = tagName.toUpperCase();
}
set textContent(value) {
this.innerHTML = String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
get innerHTML() {
return this._innerHTML;
}
set innerHTML(value) {
this._innerHTML = String(value);
if (this.parent) {
this.parent._innerHTML += this._innerHTML;
}
}
addEventListener() {}
appendChild(child) {
this._innerHTML += child.innerHTML || "";
return child;
}
close() { this.open = false; }
focus() {}
querySelector(selector) {
if (selector.startsWith("[data-jump-section-index=")) {
return new FakeElement("div", this);
}
return { scrollIntoView() {} };
}
showModal() { this.open = true; }
}
class FakeShadowRoot {
constructor() {
this.innerHTML = "";
this.dialog = new FakeElement("dialog");
this.input = new FakeElement("input");
this.results = new FakeElement("div");
}
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.type = name;
this.detail = options ? options.detail : undefined;
}
};
global.customElements = {
registry: new Map(),
define(name, cls) {
this.registry.set(name, cls);
},
};
global.document = {
addEventListener(name, callback) {
documentListeners[name] = documentListeners[name] || [];
documentListeners[name].push(callback);
},
activeElement: null,
createElement(tagName) {
return new FakeElement(tagName);
},
dispatchEvent(event) {
for (const callback of documentListeners[event.type] || []) {
callback(event);
}
},
querySelectorAll() {
return [];
},
};
global.localStorage = {
getItem() { return null; },
setItem() {},
removeItem() {},
};
global.window = { datasetteVersion: "test", location: { href: "" } };
vm.runInThisContext(
fs.readFileSync(datasetteManagerJs, "utf8"),
{ filename: "datasette-manager.js" }
);
for (const callback of documentListeners.DOMContentLoaded || []) {
callback();
}
window.__DATASETTE__.registerPlugin("agent", {
version: "0.1",
makeJumpSections() {
return [
{
id: "agent-chat",
render(node, context) {
if (!context.navigationSearch) {
throw new Error("Expected navigationSearch in render context");
}
node.innerHTML = [
'<section class="agent-jump-start">',
'<button>Start a new agent chat</button>',
'</section>',
].join('');
},
},
];
},
});
vm.runInThisContext(
fs.readFileSync(navigationSearchJs, "utf8"),
{ filename: "navigation-search.js" }
);
const Component = customElements.registry.get("navigation-search");
const element = new Component();
element.shadowRoot.input.value = "";
element.renderResults();
const html = element.shadowRoot.results.innerHTML;
if (!html.includes("Start a new agent chat")) {
throw new Error(`Missing jump section content: ${html}`);
}
process.stdout.write("ok");
""")
.replace(
"__DATASETTE_MANAGER_JS__",
json.dumps(str(STATIC_DIR / "datasette-manager.js")),
)
.replace(
"__NAVIGATION_SEARCH_JS__",
json.dumps(str(STATIC_DIR / "navigation-search.js")),
)
)
result = subprocess.run(
["node", "-e", script],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.endswith("ok")

View file

@ -430,7 +430,6 @@ async def test_permissions_debug(ds_client, filter_):
"result": True,
"actor": {"id": "root"},
},
{"action": "debug-menu", "result": False, "actor": None},
{
"action": "view-instance",
"result": True,

View file

@ -43,6 +43,11 @@ def test_plugin_hooks_have_tests(plugin_hook):
assert ok, f"Plugin hook is missing tests: {plugin_hook}"
def test_hook_jump_items_sql():
# Detailed behavior is covered in tests/test_jump.py.
assert "jump_items_sql" in dir(pm.hook)
@pytest.mark.asyncio
async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
response = await ds_client.get(

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