diff --git a/datasette/app.py b/datasette/app.py index b1f9b2f7..75f05d88 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 = {} @@ -2222,8 +2233,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/default_debug_menu.py b/datasette/default_debug_menu.py new file mode 100644 index 00000000..6127b2a6 --- /dev/null +++ b/datasette/default_debug_menu.py @@ -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 diff --git a/datasette/default_jump_items.py b/datasette/default_jump_items.py new file mode 100644 index 00000000..d215e7ec --- /dev/null +++ b/datasette/default_jump_items.py @@ -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 diff --git a/datasette/default_menu_links.py b/datasette/default_menu_links.py deleted file mode 100644 index 85032387..00000000 --- a/datasette/default_menu_links.py +++ /dev/null @@ -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 diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 27e20bd4..cf95abcb 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -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""" diff --git a/datasette/jump.py b/datasette/jump.py new file mode 100644 index 00000000..d138e827 --- /dev/null +++ b/datasette/jump.py @@ -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"(? { + 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, diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 09d58898..29a2f143 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -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 { @@ -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 = `
${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(); + } + + 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 `
`; + }) + .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 + ? `
${this.escapeHtml(match.name)}
` + : ""; + const type = match.type + ? `
${this.escapeHtml(match.type)}
` + : ""; + const description = match.description + ? `
${this.escapeHtml( + match.description, + )}
` + : ""; + return ` +
-
${this.escapeHtml( - match.name, - )}
+ ${type} +
${this.escapeHtml(displayName)}
+ ${label}
${this.escapeHtml(match.url)}
+ ${description}
- `, + `; + } + + 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 = `
${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; + 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; } } diff --git a/datasette/templates/base.html b/datasette/templates/base.html index b4fecf70..819715ba 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -71,6 +71,6 @@ {% if select_templates %}{% endif %} - + diff --git a/datasette/views/special.py b/datasette/views/special.py index b28e9257..31e7f0c2 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,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}) diff --git a/docs/authentication.rst b/docs/authentication.rst index 7fa3a241..7daefab7 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -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. diff --git a/docs/changelog.rst b/docs/changelog.rst index 51188461..a51684ab 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) ` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites. .. _v1_0_a29: diff --git a/docs/introspection.rst b/docs/introspection.rst index 19c6bffb..d2eb8efd 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -144,46 +144,62 @@ Shows currently attached databases. `Databases example `_: +The endpoint supports a ``?q=`` query parameter for filtering items by name. + +`Jump example `_: .. 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: diff --git a/docs/javascript_plugins.rst b/docs/javascript_plugins.rst index e7ee6817..805938c0 100644 --- a/docs/javascript_plugins.rst +++ b/docs/javascript_plugins.rst @@ -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 ```` 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 = ''; + node.querySelector('button').addEventListener('click', () => { + location.href = '/-/agent/new'; + }); + } + } + ]; + } + }); + }); + .. _javascript_plugins_makeAboveTablePanelConfigs: makeAboveTablePanelConfigs() diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 54dde20c..8f585cb1 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1881,6 +1881,106 @@ Using :ref:`internals_datasette_urls` here ensures that links in the menu will t Examples: `datasette-search-all `_, `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 `. + +``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 diff --git a/docs/plugins.rst b/docs/plugins.rst index d9938dba..77958205 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -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, diff --git a/tests/test_allowed_resources.py b/tests/test_allowed_resources.py index 08adbe48..e247aa78 100644 --- a/tests/test_allowed_resources.py +++ b/tests/test_allowed_resources.py @@ -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() diff --git a/tests/test_html.py b/tests/test_html.py index 64b4075a..efc1040d 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -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 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..f60af0fd --- /dev/null +++ b/tests/test_jump.py @@ -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 diff --git a/tests/test_navigation_search_js.py b/tests/test_navigation_search_js.py new file mode 100644 index 00000000..b487357d --- /dev/null +++ b/tests/test_navigation_search_js.py @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """); + }, + }; + }, + }; + 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, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + 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 = [ + '
', + '', + '
', + ].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") diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 0c09e773..8166532f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -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, diff --git a/tests/test_plugins.py b/tests/test_plugins.py index d87f577a..c5b9aef0 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -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( diff --git a/tests/test_search_tables.py b/tests/test_search_tables.py index b901c0b3..ce774327 100644 --- a/tests/test_search_tables.py +++ b/tests/test_search_tables.py @@ -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