mirror of
https://github.com/simonw/datasette.git
synced 2026-05-27 12:34:37 +02:00
Prototype of new /-/jump menu plus plugin hook
This commit is contained in:
parent
d3330695fa
commit
fae847ac10
15 changed files with 1007 additions and 132 deletions
|
|
@ -58,7 +58,7 @@ from .views.special import (
|
|||
AllowedResourcesView,
|
||||
PermissionRulesView,
|
||||
PermissionCheckView,
|
||||
TablesView,
|
||||
JumpView,
|
||||
InstanceSchemaView,
|
||||
DatabaseSchemaView,
|
||||
TableSchemaView,
|
||||
|
|
@ -1219,13 +1219,24 @@ class Datasette:
|
|||
|
||||
return db_plugin_config
|
||||
|
||||
def static_hash(self, filename):
|
||||
if not hasattr(self, "_static_hashes"):
|
||||
self._static_hashes = {}
|
||||
path = os.path.join(str(app_root), "datasette/static", filename)
|
||||
signature = (os.path.getmtime(path), os.path.getsize(path))
|
||||
cached = self._static_hashes.get(filename)
|
||||
if cached and cached["signature"] == signature:
|
||||
return cached["hash"]
|
||||
with open(path) as fp:
|
||||
static_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[:6]
|
||||
self._static_hashes[filename] = {
|
||||
"signature": signature,
|
||||
"hash": static_hash,
|
||||
}
|
||||
return static_hash
|
||||
|
||||
def app_css_hash(self):
|
||||
if not hasattr(self, "_app_css_hash"):
|
||||
with open(os.path.join(str(app_root), "datasette/static/app.css")) as fp:
|
||||
self._app_css_hash = hashlib.sha1(fp.read().encode("utf8")).hexdigest()[
|
||||
:6
|
||||
]
|
||||
return self._app_css_hash
|
||||
return self.static_hash("app.css")
|
||||
|
||||
async def get_canned_queries(self, database_name, actor):
|
||||
queries = {}
|
||||
|
|
@ -2028,6 +2039,22 @@ class Datasette:
|
|||
links.extend(extra_links)
|
||||
return links
|
||||
|
||||
async def jump_start():
|
||||
html_bits = []
|
||||
for hook in pm.hook.jump_start(
|
||||
datasette=self,
|
||||
actor=request.actor if request else None,
|
||||
request=request or None,
|
||||
):
|
||||
extra_html = await await_me_maybe(hook)
|
||||
if not extra_html:
|
||||
continue
|
||||
if isinstance(extra_html, (list, tuple)):
|
||||
html_bits.extend(extra_html)
|
||||
else:
|
||||
html_bits.append(extra_html)
|
||||
return Markup("").join(Markup(html) for html in html_bits)
|
||||
|
||||
template_context = {
|
||||
**context,
|
||||
**{
|
||||
|
|
@ -2036,11 +2063,13 @@ class Datasette:
|
|||
"urls": self.urls,
|
||||
"actor": request.actor if request else None,
|
||||
"menu_links": menu_links,
|
||||
"jump_start": jump_start,
|
||||
"display_actor": display_actor,
|
||||
"show_logout": request is not None
|
||||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"app_css_hash": self.app_css_hash(),
|
||||
"navigation_search_js_hash": self.static_hash("navigation-search.js"),
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
"format_bytes": format_bytes,
|
||||
|
|
@ -2222,8 +2251,8 @@ class Datasette:
|
|||
r"/-/api$",
|
||||
)
|
||||
add_route(
|
||||
TablesView.as_view(self),
|
||||
r"/-/tables(\.(?P<format>json))?$",
|
||||
JumpView.as_view(self),
|
||||
r"/-/jump(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
InstanceSchemaView.as_view(self),
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -157,6 +157,16 @@ def menu_links(datasette, actor, request):
|
|||
"""Links for the navigation menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
"""SQL fragments for extra items in the jump menu, optionally with display_name"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jump_start(datasette, actor, request):
|
||||
"""HTML to display in the jump menu before the user types"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
|
|
|
|||
33
datasette/jump.py
Normal file
33
datasette/jump.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class JumpSQL:
|
||||
sql: str
|
||||
params: dict[str, Any] | None = None
|
||||
has_display_name: bool = False
|
||||
|
||||
|
||||
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
|
||||
"""Rename named SQL parameters so UNION fragments cannot collide."""
|
||||
if not params:
|
||||
return sql, {}
|
||||
|
||||
renamed = {key: f"{prefix}_{key}" for key in params}
|
||||
|
||||
def replace(match):
|
||||
key = match.group(1)
|
||||
if key not in renamed:
|
||||
return match.group(0)
|
||||
return f":{renamed[key]}"
|
||||
|
||||
return _PARAM_RE.sub(replace, sql), {
|
||||
renamed[key]: value for key, value in params.items()
|
||||
}
|
||||
|
|
@ -100,16 +100,65 @@ class NavigationSearch extends HTMLElement {
|
|||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.jump-start-content {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.jump-start-content:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.result-description {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.recent-actions {
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.clear-recent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clear-recent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
|
|
@ -168,8 +217,8 @@ class NavigationSearch extends HTMLElement {
|
|||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
aria-label="Search navigation"
|
||||
placeholder="Jump to..."
|
||||
aria-label="Jump to"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
|
|
@ -231,6 +280,13 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
// Click on result item
|
||||
resultsContainer.addEventListener("click", (e) => {
|
||||
const clearRecent = e.target.closest("[data-clear-recent-items]");
|
||||
if (clearRecent) {
|
||||
e.preventDefault();
|
||||
this.clearRecentItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = e.target.closest(".result-item");
|
||||
if (item) {
|
||||
const index = parseInt(item.dataset.index);
|
||||
|
|
@ -306,12 +362,13 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
filterLocalItems(query) {
|
||||
if (!query.trim()) {
|
||||
this.matches = [];
|
||||
this.matches = this.allItems || [];
|
||||
} else {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.matches = (this.allItems || []).filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
|
||||
item.url.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
|
|
@ -319,43 +376,165 @@ class NavigationSearch extends HTMLElement {
|
|||
this.renderResults();
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
recentItemsStorageKey() {
|
||||
return "datasette.navigationSearch.recentItems";
|
||||
}
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
loadRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(this.recentItemsStorageKey());
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.filter((item) => item && item.name && item.url)
|
||||
.map((item) => ({
|
||||
name: String(item.name),
|
||||
display_name: item.display_name ? String(item.display_name) : "",
|
||||
url: String(item.url),
|
||||
type: item.type ? String(item.type) : "",
|
||||
description: item.description ? String(item.description) : "",
|
||||
}))
|
||||
.slice(0, 5);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
saveRecentItem(match) {
|
||||
if (typeof localStorage === "undefined" || !match || !match.name || !match.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.matches
|
||||
.map(
|
||||
(match, index) => `
|
||||
<div
|
||||
class="result-item ${
|
||||
index === this.selectedIndex ? "selected" : ""
|
||||
}"
|
||||
try {
|
||||
const item = {
|
||||
name: String(match.name),
|
||||
display_name: match.display_name ? String(match.display_name) : "",
|
||||
url: String(match.url),
|
||||
type: match.type ? String(match.type) : "",
|
||||
description: match.description ? String(match.description) : "",
|
||||
};
|
||||
const recentItems = this.loadRecentItems().filter(
|
||||
(recentItem) => recentItem.url !== item.url,
|
||||
);
|
||||
localStorage.setItem(
|
||||
this.recentItemsStorageKey(),
|
||||
JSON.stringify([item, ...recentItems].slice(0, 5)),
|
||||
);
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable, full, or disabled.
|
||||
}
|
||||
}
|
||||
|
||||
clearRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.recentItemsStorageKey());
|
||||
} catch (e) {
|
||||
localStorage.setItem(this.recentItemsStorageKey(), "[]");
|
||||
}
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
startContentHtml() {
|
||||
const template = this.querySelector("template[data-jump-start]");
|
||||
return template ? template.innerHTML.trim() : "";
|
||||
}
|
||||
|
||||
resultItemHtml(match, index) {
|
||||
const displayName = match.display_name || match.name;
|
||||
const label =
|
||||
match.display_name && match.display_name !== match.name
|
||||
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
|
||||
: "";
|
||||
const description = match.description
|
||||
? `<div class="result-description">${this.escapeHtml(
|
||||
match.description,
|
||||
)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<div
|
||||
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
<div class="result-name">${this.escapeHtml(
|
||||
match.name,
|
||||
)}</div>
|
||||
${description}
|
||||
<div class="result-name">${this.escapeHtml(displayName)}</div>
|
||||
${label}
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
`;
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const showStartContent = !input.value.trim();
|
||||
const startContent = showStartContent ? this.startContentHtml() : "";
|
||||
const startBlock = startContent
|
||||
? `<div class="jump-start-content">${startContent}</div>`
|
||||
: "";
|
||||
const recentItems = showStartContent ? this.loadRecentItems() : [];
|
||||
const defaultMatches = showStartContent ? [] : this.matches;
|
||||
const renderedMatches = [...recentItems, ...defaultMatches];
|
||||
this.renderedMatches = renderedMatches;
|
||||
|
||||
if (renderedMatches.length) {
|
||||
if (
|
||||
this.selectedIndex < 0 ||
|
||||
this.selectedIndex >= renderedMatches.length
|
||||
) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
} else {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
if (renderedMatches.length === 0) {
|
||||
if (startBlock) {
|
||||
container.innerHTML = startBlock;
|
||||
} else if (showStartContent) {
|
||||
container.innerHTML = "";
|
||||
} else {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const recentHtml = recentItems.length
|
||||
? `<div class="results-heading">Recent</div>${recentItems
|
||||
.map((match, index) => this.resultItemHtml(match, index))
|
||||
.join("")}<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
|
||||
: "";
|
||||
const defaultHtml = defaultMatches
|
||||
.map((match, index) =>
|
||||
this.resultItemHtml(match, recentItems.length + index),
|
||||
)
|
||||
.join("");
|
||||
container.innerHTML = startBlock + recentHtml + defaultHtml;
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
const selectedItem = container.children[this.selectedIndex];
|
||||
const selectedItem = container.querySelector(
|
||||
`.result-item[data-index="${this.selectedIndex}"]`,
|
||||
);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
|
@ -363,22 +542,27 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < this.matches.length) {
|
||||
if (newIndex >= 0 && newIndex < matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const match = this.matches[index];
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const match = matches[index];
|
||||
if (match) {
|
||||
this.saveRecentItem(match);
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
|
|
@ -405,7 +589,7 @@ class NavigationSearch extends HTMLElement {
|
|||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state - start with no items shown
|
||||
// Reset state, then populate the default jump list.
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
|
|
@ -418,7 +602,7 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/tables"></navigation-search>
|
||||
<script src="{{ urls.static('navigation-search.js') }}{% if navigation_search_js_hash is defined %}?{{ navigation_search_js_hash }}{% endif %}" defer></script>
|
||||
{% if jump_start is defined %}
|
||||
{% set jump_start_html = jump_start() %}
|
||||
{% else %}
|
||||
{% set jump_start_html = "" %}
|
||||
{% endif %}
|
||||
<navigation-search url="/-/jump">{% if jump_start_html %}
|
||||
<template data-jump-start>{{ jump_start_html }}</template>
|
||||
{% endif %}</navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -144,46 +144,65 @@ Shows currently attached databases. `Databases example <https://latest.datasette
|
|||
}
|
||||
]
|
||||
|
||||
.. _TablesView:
|
||||
.. _JumpView:
|
||||
|
||||
/-/tables
|
||||
---------
|
||||
/-/jump
|
||||
-------
|
||||
|
||||
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
|
||||
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and canned queries, and plugins can contribute additional items.
|
||||
|
||||
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
|
||||
The endpoint supports a ``?q=`` query parameter for filtering items by name.
|
||||
Canned queries with a configured ``title`` also include a ``display_name`` in
|
||||
their results, and can be found by searching for that title. Plugins can provide
|
||||
the same extra field from ``jump_items_sql`` by returning a ``display_name``
|
||||
column and setting ``JumpSQL(..., has_display_name=True)``.
|
||||
|
||||
`Tables example <https://latest.datasette.io/-/tables>`_:
|
||||
`Jump example <https://latest.datasette.io/-/jump>`_:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
"name": "fixtures",
|
||||
"url": "/fixtures",
|
||||
"type": "database",
|
||||
"description": "Database"
|
||||
},
|
||||
{
|
||||
"name": "fixtures/searchable",
|
||||
"url": "/fixtures/searchable"
|
||||
"name": "fixtures: facetable",
|
||||
"url": "/fixtures/facetable",
|
||||
"type": "table",
|
||||
"description": "Table"
|
||||
},
|
||||
{
|
||||
"name": "fixtures: recent_releases",
|
||||
"display_name": "Recent Datasette releases",
|
||||
"url": "/fixtures/recent_releases",
|
||||
"type": "query",
|
||||
"description": "Canned query"
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
|
||||
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
|
||||
Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
"name": "fixtures: facetable",
|
||||
"url": "/fixtures/facetable",
|
||||
"type": "table",
|
||||
"description": "Table"
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
|
||||
|
||||
.. _JsonDataView_threads:
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ async def test_skip_permission_checks_with_admin_actor(datasette_with_permission
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_permission_checks_shows_denied_tables():
|
||||
"""Test that skip_permission_checks=True shows tables from denied databases in /-/tables.json"""
|
||||
"""Test that skip_permission_checks=True shows tables from denied databases in /-/jump.json"""
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
|
|
@ -211,8 +211,8 @@ async def test_skip_permission_checks_shows_denied_tables():
|
|||
await db.execute_write("INSERT INTO test_table (id, name) VALUES (1, 'Alice')")
|
||||
await ds._refresh_schemas()
|
||||
|
||||
# Without skip_permission_checks, tables from denied database should not appear in /-/tables.json
|
||||
response = await ds.client.get("/-/tables.json")
|
||||
# Without skip_permission_checks, tables from denied database should not appear in /-/jump.json
|
||||
response = await ds.client.get("/-/jump.json")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
table_names = [match["name"] for match in data["matches"]]
|
||||
|
|
@ -221,7 +221,7 @@ async def test_skip_permission_checks_shows_denied_tables():
|
|||
assert len(fixtures_tables) == 0
|
||||
|
||||
# With skip_permission_checks=True, tables from denied database SHOULD appear
|
||||
response = await ds.client.get("/-/tables.json", skip_permission_checks=True)
|
||||
response = await ds.client.get("/-/jump.json", skip_permission_checks=True)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
table_names = [match["name"] for match in data["matches"]]
|
||||
|
|
|
|||
224
tests/test_jump.py
Normal file
224
tests/test_jump.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
from markupsafe import Markup
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.app import Datasette
|
||||
from datasette.plugins import pm
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ds_for_jump():
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
"content": {
|
||||
"allow": {"id": "*"},
|
||||
"tables": {
|
||||
"articles": {"allow": {"id": "editor"}},
|
||||
"comments": {"allow": True},
|
||||
},
|
||||
"queries": {
|
||||
"recent_comments": {
|
||||
"sql": "select * from comments",
|
||||
"allow": {"id": "*"},
|
||||
"title": "Recent comments",
|
||||
},
|
||||
"release_notes": {
|
||||
"sql": "select 1",
|
||||
"allow": {"id": "*"},
|
||||
"title": "Recent Datasette releases",
|
||||
},
|
||||
"editor_report": {
|
||||
"sql": "select * from articles",
|
||||
"allow": {"id": "editor"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"private": {
|
||||
"allow": False,
|
||||
"queries": {
|
||||
"private_report": "select 1",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
await ds.invoke_startup()
|
||||
|
||||
content_db = ds.add_memory_database("jump_test_content", name="content")
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE VIEW IF NOT EXISTS comment_summary AS SELECT body FROM comments"
|
||||
)
|
||||
|
||||
private_db = ds.add_memory_database("jump_test_private", name="private")
|
||||
await private_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
|
||||
)
|
||||
|
||||
public_db = ds.add_memory_database("jump_test_public", name="public")
|
||||
await public_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
|
||||
)
|
||||
|
||||
await ds._refresh_schemas()
|
||||
return ds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=content", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
matches_by_type_and_name = {
|
||||
(match["type"], match["name"]): match for match in data["matches"]
|
||||
}
|
||||
assert ("database", "content") in matches_by_type_and_name
|
||||
assert ("table", "content: comments") in matches_by_type_and_name
|
||||
assert ("view", "content: comment_summary") in matches_by_type_and_name
|
||||
assert ("query", "content: recent_comments") in matches_by_type_and_name
|
||||
assert matches_by_type_and_name[("database", "content")]["url"] == "/content"
|
||||
assert (
|
||||
matches_by_type_and_name[("query", "content: recent_comments")]["display_name"]
|
||||
== "Recent comments"
|
||||
)
|
||||
assert (
|
||||
matches_by_type_and_name[("query", "content: recent_comments")]["url"]
|
||||
== "/content/recent_comments"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_searches_and_displays_canned_query_titles(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=datasette", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["matches"] == [
|
||||
{
|
||||
"name": "content: release_notes",
|
||||
"display_name": "Recent Datasette releases",
|
||||
"url": "/content/release_notes",
|
||||
"type": "query",
|
||||
"description": "Canned query",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_respects_resource_permissions(ds_for_jump):
|
||||
regular = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=articles", actor={"id": "regular"}
|
||||
)
|
||||
editor = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=articles", actor={"id": "editor"}
|
||||
)
|
||||
private = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=secrets", actor={"id": "editor"}
|
||||
)
|
||||
|
||||
assert {match["name"] for match in regular.json()["matches"]} == {
|
||||
"public: articles"
|
||||
}
|
||||
assert {match["name"] for match in editor.json()["matches"]} == {
|
||||
"content: articles",
|
||||
"public: articles",
|
||||
}
|
||||
assert private.json()["matches"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
class JumpPlugin:
|
||||
@hookimpl
|
||||
def jump_items_sql(self, datasette, actor, request):
|
||||
return JumpSQL(
|
||||
sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'plugin-dashboard: ' || :actor_id AS label,
|
||||
'Plugin supplied item' AS description,
|
||||
'/-/plugin-dashboard' AS url,
|
||||
NULL AS database_name,
|
||||
NULL AS resource_name,
|
||||
'plugin dashboard ' || :actor_id AS search_text,
|
||||
80 AS sort_key,
|
||||
'test-plugin' AS source,
|
||||
'Plugin dashboard for ' || :actor_id AS display_name
|
||||
""",
|
||||
params={"actor_id": actor["id"] if actor else "anonymous"},
|
||||
has_display_name=True,
|
||||
)
|
||||
|
||||
plugin = JumpPlugin()
|
||||
pm.register(plugin, name="test-jump-plugin")
|
||||
try:
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=dashboard", actor={"id": "alice"}
|
||||
)
|
||||
finally:
|
||||
pm.unregister(name="test-jump-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
plugin_matches = [
|
||||
match for match in response.json()["matches"] if match["type"] == "plugin"
|
||||
]
|
||||
assert plugin_matches == [
|
||||
{
|
||||
"name": "plugin-dashboard: alice",
|
||||
"display_name": "Plugin dashboard for alice",
|
||||
"url": "/-/plugin-dashboard",
|
||||
"type": "plugin",
|
||||
"description": "Plugin supplied item",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_start_hook_renders_empty_state_template(ds_for_jump):
|
||||
class JumpStartPlugin:
|
||||
@hookimpl
|
||||
def jump_start(self, datasette, actor, request):
|
||||
if not actor:
|
||||
return None
|
||||
return Markup(
|
||||
'<section class="agent-jump-start">'
|
||||
"<h3>Agent chat</h3>"
|
||||
'<a href="/-/agent/new">Start a new agent chat</a>'
|
||||
"</section>"
|
||||
)
|
||||
|
||||
plugin = JumpStartPlugin()
|
||||
pm.register(plugin, name="test-jump-start-plugin")
|
||||
try:
|
||||
anonymous = await ds_for_jump.client.get("/")
|
||||
authenticated = await ds_for_jump.client.get("/", actor={"id": "alice"})
|
||||
finally:
|
||||
pm.unregister(name="test-jump-start-plugin")
|
||||
|
||||
assert 'url="/-/jump"' in authenticated.text
|
||||
assert "<template data-jump-start>" not in anonymous.text
|
||||
assert "<template data-jump-start>" in authenticated.text
|
||||
assert "Start a new agent chat" in authenticated.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_removed(ds_for_jump):
|
||||
response = await ds_for_jump.client.get("/-/tables.json")
|
||||
assert response.status_code == 404
|
||||
199
tests/test_navigation_search_js.py
Normal file
199
tests/test_navigation_search_js.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
|
||||
def test_navigation_search_tracks_and_renders_recent_items():
|
||||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.innerHTML = "";
|
||||
this.value = "";
|
||||
this.dataset = {};
|
||||
this.open = false;
|
||||
}
|
||||
addEventListener() {}
|
||||
close() { this.open = false; }
|
||||
focus() {}
|
||||
querySelector() {
|
||||
return { scrollIntoView() {} };
|
||||
}
|
||||
showModal() { this.open = true; }
|
||||
}
|
||||
|
||||
class FakeShadowRoot {
|
||||
constructor() {
|
||||
this.innerHTML = "";
|
||||
this.dialog = new FakeElement();
|
||||
this.input = new FakeElement();
|
||||
this.results = new FakeElement();
|
||||
}
|
||||
querySelector(selector) {
|
||||
if (selector == "dialog") return this.dialog;
|
||||
if (selector == ".search-input") return this.input;
|
||||
if (selector == ".results-container") return this.results;
|
||||
return new FakeElement();
|
||||
}
|
||||
}
|
||||
|
||||
global.HTMLElement = class {
|
||||
constructor() {
|
||||
this.attributes = {};
|
||||
}
|
||||
attachShadow() {
|
||||
this.shadowRoot = new FakeShadowRoot();
|
||||
return this.shadowRoot;
|
||||
}
|
||||
dispatchEvent() {}
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] || null;
|
||||
}
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
}
|
||||
};
|
||||
global.CustomEvent = class {
|
||||
constructor(name, options) {
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
}
|
||||
};
|
||||
global.customElements = {
|
||||
registry: new Map(),
|
||||
define(name, cls) {
|
||||
this.registry.set(name, cls);
|
||||
},
|
||||
};
|
||||
global.document = {
|
||||
addEventListener() {},
|
||||
activeElement: null,
|
||||
createElement() {
|
||||
return {
|
||||
set textContent(value) {
|
||||
this.innerHTML = String(value)
|
||||
.replace(/&/g, "&")
|
||||
.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("datasette/static/navigation-search.js", "utf8"),
|
||||
{ filename: "navigation-search.js" }
|
||||
);
|
||||
|
||||
const Component = customElements.registry.get("navigation-search");
|
||||
const element = new Component();
|
||||
const items = Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `Item ${index + 1}`,
|
||||
url: `/item-${index + 1}`,
|
||||
type: "table",
|
||||
description: "Table",
|
||||
}));
|
||||
items[5].name = "content: recent_datasette_releases";
|
||||
items[5].display_name = "Recent Datasette releases";
|
||||
|
||||
for (const item of items) {
|
||||
element.matches = [item];
|
||||
element.renderedMatches = [item];
|
||||
element.selectedIndex = 0;
|
||||
element.selectCurrentItem();
|
||||
}
|
||||
|
||||
const stored = JSON.parse(
|
||||
Object.values(localStorage.store).find((value) => value.includes("/item-6"))
|
||||
);
|
||||
if (stored.length !== 5) {
|
||||
throw new Error(`Expected 5 recent items, got ${stored.length}`);
|
||||
}
|
||||
if (stored[0].url !== "/item-6" || stored[4].url !== "/item-2") {
|
||||
throw new Error(`Unexpected recent order: ${JSON.stringify(stored)}`);
|
||||
}
|
||||
if (stored[0].display_name !== "Recent Datasette releases") {
|
||||
throw new Error(`Missing display_name in recent item: ${JSON.stringify(stored[0])}`);
|
||||
}
|
||||
|
||||
element.matches = [
|
||||
items[5],
|
||||
items[4],
|
||||
{
|
||||
name: "Other",
|
||||
url: "/other",
|
||||
type: "database",
|
||||
description: "Database",
|
||||
},
|
||||
];
|
||||
element.shadowRoot.input.value = "";
|
||||
element.renderResults();
|
||||
|
||||
const html = element.shadowRoot.results.innerHTML;
|
||||
if (!html.includes("Recent")) {
|
||||
throw new Error(`Missing Recent heading: ${html}`);
|
||||
}
|
||||
if (!html.includes("Recent Datasette releases") || !html.includes("Item 5")) {
|
||||
throw new Error(`Missing recent items: ${html}`);
|
||||
}
|
||||
if (!html.includes("content: recent_datasette_releases")) {
|
||||
throw new Error(`Missing canonical item name for display_name item: ${html}`);
|
||||
}
|
||||
if (!html.includes("Item 4") || !html.includes("Item 2")) {
|
||||
throw new Error(`Expected all stored recent items in empty state: ${html}`);
|
||||
}
|
||||
if (html.includes("Other")) {
|
||||
throw new Error(`Rendered non-recent item in empty state: ${html}`);
|
||||
}
|
||||
if (!html.includes("Clear recent")) {
|
||||
throw new Error(`Missing Clear recent control: ${html}`);
|
||||
}
|
||||
|
||||
element.clearRecentItems();
|
||||
if (localStorage.getItem(element.recentItemsStorageKey()) !== null) {
|
||||
throw new Error("Expected recent items to be cleared");
|
||||
}
|
||||
element.renderResults();
|
||||
if (element.shadowRoot.results.innerHTML.includes("Clear recent")) {
|
||||
throw new Error("Clear recent should disappear after clearing");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(stored));
|
||||
""")
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=".",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert [item["url"] for item in json.loads(result.stdout)] == [
|
||||
"/item-6",
|
||||
"/item-5",
|
||||
"/item-4",
|
||||
"/item-3",
|
||||
"/item-2",
|
||||
]
|
||||
assert json.loads(result.stdout)[0]["display_name"] == "Recent Datasette releases"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue