From fae847ac1017bb76044fd3aad703d5b9725c12ed Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 21 May 2026 15:02:14 -0700 Subject: [PATCH] Prototype of new /-/jump menu plus plugin hook --- datasette/app.py | 47 +++- datasette/handle_exception.py | 3 + datasette/hookspecs.py | 10 + datasette/jump.py | 33 +++ datasette/static/navigation-search.js | 240 ++++++++++++++++++--- datasette/templates/base.html | 11 +- datasette/views/special.py | 259 ++++++++++++++++++----- docs/introspection.rst | 51 +++-- tests/test_allowed_resources.py | 20 +- tests/test_html.py | 7 + tests/test_internal_db.py | 2 +- tests/test_internals_datasette_client.py | 8 +- tests/test_jump.py | 224 ++++++++++++++++++++ tests/test_navigation_search_js.py | 199 +++++++++++++++++ tests/test_search_tables.py | 21 +- 15 files changed, 1005 insertions(+), 130 deletions(-) create mode 100644 datasette/jump.py create mode 100644 tests/test_jump.py create mode 100644 tests/test_navigation_search_js.py diff --git a/datasette/app.py b/datasette/app.py index b1f9b2f7..c9605af3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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(\.(?Pjson))?$", + JumpView.as_view(self), + r"/-/jump(\.(?Pjson))?$", ) add_route( InstanceSchemaView.as_view(self), diff --git a/datasette/handle_exception.py b/datasette/handle_exception.py index 96398a4c..25ec26d9 100644 --- a/datasette/handle_exception.py +++ b/datasette/handle_exception.py @@ -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: [], ) ), diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 27e20bd4..a4d8143b 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -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""" diff --git a/datasette/jump.py b/datasette/jump.py new file mode 100644 index 00000000..96e18547 --- /dev/null +++ b/datasette/jump.py @@ -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"(? @@ -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 = `
${message}
`; + 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) => ` -
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 + ? `
${this.escapeHtml(match.name)}
` + : ""; + const description = match.description + ? `
${this.escapeHtml( + match.description, + )}
` + : ""; + return ` +
-
${this.escapeHtml( - match.name, - )}
+ ${description} +
${this.escapeHtml(displayName)}
+ ${label}
${this.escapeHtml(match.url)}
- `, + `; + } + + 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 + ? `
${startContent}
` + : ""; + 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 = `
${message}
`; + } + return; + } + + const recentHtml = recentItems.length + ? `
Recent
${recentItems + .map((match, index) => this.resultItemHtml(match, index)) + .join("")}
` + : ""; + 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; } } diff --git a/datasette/templates/base.html b/datasette/templates/base.html index b4fecf70..6a55bd1f 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -70,7 +70,14 @@ {% endfor %} {% if select_templates %}{% endif %} - - + +{% if jump_start is defined %} +{% set jump_start_html = jump_start() %} +{% else %} +{% set jump_start_html = "" %} +{% endif %} +{% if jump_start_html %} + +{% endif %} diff --git a/datasette/views/special.py b/datasette/views/special.py index b28e9257..990714cf 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -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}) diff --git a/docs/introspection.rst b/docs/introspection.rst index 19c6bffb..9f0358ac 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -144,46 +144,65 @@ Shows currently attached databases. `Databases example `_: +`Jump example `_: .. 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: diff --git a/tests/test_allowed_resources.py b/tests/test_allowed_resources.py index 08adbe48..8048ae2c 100644 --- a/tests/test_allowed_resources.py +++ b/tests/test_allowed_resources.py @@ -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() diff --git a/tests/test_html.py b/tests/test_html.py index 64b4075a..ac77f10f 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -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 diff --git a/tests/test_internal_db.py b/tests/test_internal_db.py index dcf14126..26d63a92 100644 --- a/tests/test_internal_db.py +++ b/tests/test_internal_db.py @@ -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() diff --git a/tests/test_internals_datasette_client.py b/tests/test_internals_datasette_client.py index ccac280b..543077a5 100644 --- a/tests/test_internals_datasette_client.py +++ b/tests/test_internals_datasette_client.py @@ -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"]] diff --git a/tests/test_jump.py b/tests/test_jump.py new file mode 100644 index 00000000..55325b95 --- /dev/null +++ b/tests/test_jump.py @@ -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( + '
' + "

Agent chat

" + 'Start a new agent chat' + "
" + ) + + 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 "