mirror of
https://github.com/simonw/datasette.git
synced 2026-05-27 12:34:37 +02:00
jump_items_sql() and makeJumpSections() plugin hooks (#2732)
* /-/tables is now /-/jump * `jump_items_sql()` plugin hook for contributing to that menu * JavaScript `makeJumpSections()` hook for populating blank slate * Menu now stores up to five recently visited items in localStorage
This commit is contained in:
commit
a75c9f2401
26 changed files with 1802 additions and 187 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 = {}
|
||||
|
|
@ -2222,8 +2233,8 @@ class Datasette:
|
|||
r"/-/api$",
|
||||
)
|
||||
add_route(
|
||||
TablesView.as_view(self),
|
||||
r"/-/tables(\.(?P<format>json))?$",
|
||||
JumpView.as_view(self),
|
||||
r"/-/jump(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
InstanceSchemaView.as_view(self),
|
||||
|
|
|
|||
75
datasette/default_debug_menu.py
Normal file
75
datasette/default_debug_menu.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
DEBUG_MENU_ITEMS = (
|
||||
(
|
||||
"/-/databases",
|
||||
"Databases",
|
||||
"List of databases known to this Datasette instance.",
|
||||
),
|
||||
(
|
||||
"/-/plugins",
|
||||
"Installed plugins",
|
||||
"Review loaded plugins, their versions and their registered hooks.",
|
||||
),
|
||||
(
|
||||
"/-/versions",
|
||||
"Version info",
|
||||
"Check the Python, SQLite and dependency versions used by this server.",
|
||||
),
|
||||
(
|
||||
"/-/settings",
|
||||
"Settings",
|
||||
"Inspect the active Datasette settings and configuration values.",
|
||||
),
|
||||
(
|
||||
"/-/permissions",
|
||||
"Debug permissions",
|
||||
"Test permission checks for actors, actions and resources.",
|
||||
),
|
||||
(
|
||||
"/-/messages",
|
||||
"Debug messages",
|
||||
"Try out temporary flash messages shown to users.",
|
||||
),
|
||||
(
|
||||
"/-/allow-debug",
|
||||
"Debug allow rules",
|
||||
"Explore how allow blocks match actors against permission rules.",
|
||||
),
|
||||
(
|
||||
"/-/threads",
|
||||
"Debug threads",
|
||||
"Inspect worker threads and database tasks.",
|
||||
),
|
||||
(
|
||||
"/-/actor",
|
||||
"Debug actor",
|
||||
"View the actor object for the current signed-in user.",
|
||||
),
|
||||
(
|
||||
"/-/patterns",
|
||||
"Pattern portfolio",
|
||||
"Browse Datasette UI patterns.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
||||
return []
|
||||
|
||||
return [
|
||||
JumpSQL.menu_item(
|
||||
label=label,
|
||||
url=datasette.urls.path(path),
|
||||
description=description,
|
||||
search_text=f"debug {label} {description}",
|
||||
item_type="debug",
|
||||
)
|
||||
for path, label, description in DEBUG_MENU_ITEMS
|
||||
]
|
||||
|
||||
return inner
|
||||
82
datasette/default_jump_items.py
Normal file
82
datasette/default_jump_items.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
database_sql, database_params = await datasette.allowed_resources_sql(
|
||||
action="view-database", actor=actor
|
||||
)
|
||||
table_sql, table_params = await datasette.allowed_resources_sql(
|
||||
action="view-table", actor=actor
|
||||
)
|
||||
query_sql, query_params = await datasette.allowed_resources_sql(
|
||||
action="view-query", actor=actor
|
||||
)
|
||||
return [
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_databases AS (
|
||||
{database_sql}
|
||||
)
|
||||
SELECT
|
||||
'database' AS type,
|
||||
parent AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'database',
|
||||
'database', parent
|
||||
) AS url,
|
||||
parent AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_databases
|
||||
""",
|
||||
params=database_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_tables AS (
|
||||
{table_sql}
|
||||
)
|
||||
SELECT
|
||||
CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type,
|
||||
allowed_tables.parent || ': ' || allowed_tables.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'table',
|
||||
'database', allowed_tables.parent,
|
||||
'table', allowed_tables.child
|
||||
) AS url,
|
||||
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_tables
|
||||
LEFT JOIN catalog_views
|
||||
ON catalog_views.database_name = allowed_tables.parent
|
||||
AND catalog_views.view_name = allowed_tables.child
|
||||
""",
|
||||
params=table_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_queries AS (
|
||||
{query_sql}
|
||||
)
|
||||
SELECT
|
||||
'query' AS type,
|
||||
allowed_queries.parent || ': ' || allowed_queries.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'query',
|
||||
'database', allowed_queries.parent,
|
||||
'query', allowed_queries.child
|
||||
) AS url,
|
||||
allowed_queries.parent || ' ' || allowed_queries.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_queries
|
||||
""",
|
||||
params=query_params,
|
||||
),
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -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
|
||||
|
|
@ -157,6 +157,11 @@ def menu_links(datasette, actor, request):
|
|||
"""Links for the navigation menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
"""SQL fragments for extra items in the jump menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
|
|
|
|||
68
datasette/jump.py
Normal file
68
datasette/jump.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class JumpSQL:
|
||||
sql: str
|
||||
params: dict[str, Any] | None = None
|
||||
database: str | None = None
|
||||
|
||||
@classmethod
|
||||
def menu_item(
|
||||
cls,
|
||||
*,
|
||||
label: str,
|
||||
url: str,
|
||||
description: str = "Menu item",
|
||||
search_text: str | None = None,
|
||||
display_name: str | None = None,
|
||||
item_type: str = "menu",
|
||||
) -> "JumpSQL":
|
||||
if search_text is None:
|
||||
search_text = " ".join(
|
||||
text for text in (label, display_name, description) if text is not None
|
||||
)
|
||||
return cls(
|
||||
sql="""
|
||||
SELECT
|
||||
:type AS type,
|
||||
:label AS label,
|
||||
:description AS description,
|
||||
:url AS url,
|
||||
:search_text AS search_text,
|
||||
:display_name AS display_name
|
||||
""",
|
||||
params={
|
||||
"type": item_type,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"search_text": search_text,
|
||||
"display_name": display_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
|
||||
"""Rename named SQL parameters so UNION query parameters cannot collide."""
|
||||
if not params:
|
||||
return sql, {}
|
||||
|
||||
renamed = {key: f"{prefix}_{key}" for key in params}
|
||||
|
||||
def replace(match):
|
||||
key = match.group(1)
|
||||
if key not in renamed:
|
||||
return match.group(0)
|
||||
return f":{renamed[key]}"
|
||||
|
||||
return _PARAM_RE.sub(replace, sql), {
|
||||
renamed[key]: value for key, value in params.items()
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.default_column_types",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_menu_links",
|
||||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,19 @@ const datasetteManager = {
|
|||
return columnActions;
|
||||
},
|
||||
|
||||
makeJumpSections: (context) => {
|
||||
let jumpSections = [];
|
||||
|
||||
datasetteManager.plugins.forEach((plugin) => {
|
||||
if (plugin.makeJumpSections) {
|
||||
const sections = plugin.makeJumpSections(context) || [];
|
||||
jumpSections.push(...sections);
|
||||
}
|
||||
});
|
||||
|
||||
return jumpSections;
|
||||
},
|
||||
|
||||
/**
|
||||
* In MVP, each plugin can only have 1 instance.
|
||||
* In future, panels could be repeated. We omit that for now since so many plugins depend on
|
||||
|
|
@ -192,7 +205,6 @@ const initializeDatasette = () => {
|
|||
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||
|
||||
window.__DATASETTE__ = datasetteManager;
|
||||
console.debug("Datasette Manager Created!");
|
||||
|
||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||
detail: datasetteManager,
|
||||
|
|
|
|||
|
|
@ -100,16 +100,81 @@ class NavigationSearch extends HTMLElement {
|
|||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.result-item > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-start-content {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.jump-start-content:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.result-description {
|
||||
color: #374151;
|
||||
display: -webkit-box;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
margin-top: 0.35rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.recent-actions {
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.clear-recent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clear-recent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
|
|
@ -168,8 +233,8 @@ class NavigationSearch extends HTMLElement {
|
|||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
aria-label="Search navigation"
|
||||
placeholder="Jump to..."
|
||||
aria-label="Jump to"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
|
|
@ -231,6 +296,13 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
// Click on result item
|
||||
resultsContainer.addEventListener("click", (e) => {
|
||||
const clearRecent = e.target.closest("[data-clear-recent-items]");
|
||||
if (clearRecent) {
|
||||
e.preventDefault();
|
||||
this.clearRecentItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = e.target.closest(".result-item");
|
||||
if (item) {
|
||||
const index = parseInt(item.dataset.index);
|
||||
|
|
@ -306,12 +378,13 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
filterLocalItems(query) {
|
||||
if (!query.trim()) {
|
||||
this.matches = [];
|
||||
this.matches = this.allItems || [];
|
||||
} else {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.matches = (this.allItems || []).filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
|
||||
item.url.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
|
|
@ -319,43 +392,215 @@ class NavigationSearch extends HTMLElement {
|
|||
this.renderResults();
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
recentItemsStorageKey() {
|
||||
return "datasette.navigationSearch.recentItems";
|
||||
}
|
||||
|
||||
if (this.matches.length === 0) {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
loadRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(this.recentItemsStorageKey());
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.filter((item) => item && item.name && item.url)
|
||||
.map((item) => ({
|
||||
name: String(item.name),
|
||||
display_name: item.display_name ? String(item.display_name) : "",
|
||||
url: String(item.url),
|
||||
type: item.type ? String(item.type) : "",
|
||||
description: item.description ? String(item.description) : "",
|
||||
}))
|
||||
.slice(0, 5);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
saveRecentItem(match) {
|
||||
if (
|
||||
typeof localStorage === "undefined" ||
|
||||
!match ||
|
||||
!match.name ||
|
||||
!match.url
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.matches
|
||||
.map(
|
||||
(match, index) => `
|
||||
<div
|
||||
class="result-item ${
|
||||
index === this.selectedIndex ? "selected" : ""
|
||||
}"
|
||||
try {
|
||||
const item = {
|
||||
name: String(match.name),
|
||||
display_name: match.display_name ? String(match.display_name) : "",
|
||||
url: String(match.url),
|
||||
type: match.type ? String(match.type) : "",
|
||||
description: match.description ? String(match.description) : "",
|
||||
};
|
||||
const recentItems = this.loadRecentItems().filter(
|
||||
(recentItem) => recentItem.url !== item.url,
|
||||
);
|
||||
localStorage.setItem(
|
||||
this.recentItemsStorageKey(),
|
||||
JSON.stringify([item, ...recentItems].slice(0, 5)),
|
||||
);
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable, full, or disabled.
|
||||
}
|
||||
}
|
||||
|
||||
clearRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.recentItemsStorageKey());
|
||||
} catch (e) {
|
||||
localStorage.setItem(this.recentItemsStorageKey(), "[]");
|
||||
}
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
jumpSections() {
|
||||
const manager = window.__DATASETTE__;
|
||||
if (!manager || typeof manager.makeJumpSections !== "function") {
|
||||
return [];
|
||||
}
|
||||
const sections = manager.makeJumpSections({
|
||||
navigationSearch: this,
|
||||
});
|
||||
return Array.isArray(sections)
|
||||
? sections.filter(
|
||||
(section) => section && typeof section.render === "function",
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
jumpSectionsHtml(jumpSections) {
|
||||
return jumpSections
|
||||
.map((section, index) => {
|
||||
const id = section.id
|
||||
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
|
||||
: "";
|
||||
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
renderJumpSections(container, jumpSections) {
|
||||
jumpSections.forEach((section, index) => {
|
||||
const node = container.querySelector(
|
||||
`[data-jump-section-index="${index}"]`,
|
||||
);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
section.render(node, {
|
||||
navigationSearch: this,
|
||||
container,
|
||||
input: this.shadowRoot.querySelector(".search-input"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resultItemHtml(match, index) {
|
||||
const displayName = match.display_name || match.name;
|
||||
const label =
|
||||
match.display_name && match.display_name !== match.name
|
||||
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
|
||||
: "";
|
||||
const type = match.type
|
||||
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
|
||||
: "";
|
||||
const description = match.description
|
||||
? `<div class="result-description">${this.escapeHtml(
|
||||
match.description,
|
||||
)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<div
|
||||
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
<div class="result-name">${this.escapeHtml(
|
||||
match.name,
|
||||
)}</div>
|
||||
${type}
|
||||
<div class="result-name">${this.escapeHtml(displayName)}</div>
|
||||
${label}
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
${description}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
`;
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const showStartContent = !input.value.trim();
|
||||
const jumpSections = showStartContent ? this.jumpSections() : [];
|
||||
const startBlock = showStartContent
|
||||
? this.jumpSectionsHtml(jumpSections)
|
||||
: "";
|
||||
const recentItems = showStartContent ? this.loadRecentItems() : [];
|
||||
const defaultMatches = showStartContent ? [] : this.matches;
|
||||
const renderedMatches = [...recentItems, ...defaultMatches];
|
||||
this.renderedMatches = renderedMatches;
|
||||
|
||||
if (renderedMatches.length) {
|
||||
if (
|
||||
this.selectedIndex < 0 ||
|
||||
this.selectedIndex >= renderedMatches.length
|
||||
) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
} else {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
if (renderedMatches.length === 0) {
|
||||
if (startBlock) {
|
||||
container.innerHTML = startBlock;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
} else if (showStartContent) {
|
||||
container.innerHTML = "";
|
||||
} else {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `<div class="no-results">${message}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const recentHtml = recentItems.length
|
||||
? `<div class="results-heading">Recent</div>${recentItems
|
||||
.map((match, index) => this.resultItemHtml(match, index))
|
||||
.join(
|
||||
"",
|
||||
)}<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
|
||||
: "";
|
||||
const defaultHtml = defaultMatches
|
||||
.map((match, index) =>
|
||||
this.resultItemHtml(match, recentItems.length + index),
|
||||
)
|
||||
.join("");
|
||||
container.innerHTML = startBlock + recentHtml + defaultHtml;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
const selectedItem = container.children[this.selectedIndex];
|
||||
const selectedItem = container.querySelector(
|
||||
`.result-item[data-index="${this.selectedIndex}"]`,
|
||||
);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
|
@ -363,22 +608,27 @@ class NavigationSearch extends HTMLElement {
|
|||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < this.matches.length) {
|
||||
if (newIndex >= 0 && newIndex < matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.matches.length) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const match = this.matches[index];
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const match = matches[index];
|
||||
if (match) {
|
||||
this.saveRecentItem(match);
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
|
|
@ -405,7 +655,7 @@ class NavigationSearch extends HTMLElement {
|
|||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state - start with no items shown
|
||||
// Reset state, then populate the default jump list.
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
|
|
@ -418,7 +668,7 @@ class NavigationSearch extends HTMLElement {
|
|||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,6 @@
|
|||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/tables"></navigation-search>
|
||||
<navigation-search url="/-/jump"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ Unreleased
|
|||
- Fixed a bug where stale tables and other related resources were not removed from ``catalog_*`` tables when a database was removed. (:issue:`2723`)
|
||||
- Fixed a Safari bug with the table search mechanism triggered by pressing ``/``. (:issue:`2724`)
|
||||
- New "Jump to..." menu item, always visible, for triggering the previously undocumented ``/`` menu. (:issue:`2725`)
|
||||
- The ``/`` jump-to search interface now covers databases, views, canned queries and plugin-provided items in addition to tables. The endpoint backing it has been renamed from ``/-/tables`` to ``/-/jump``.
|
||||
- New :ref:`plugin_hook_jump_items_sql` plugin hook, allowing plugins to contribute additional items to the jump-to menu by returning SQL. ``JumpSQL`` queries run against Datasette's internal database by default, or can target another database using the optional ``database=`` argument. Datasette groups these queries by database and executes one ``UNION ALL`` query for each database. Each row returned by this hook includes a ``url`` value, which can be a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods.
|
||||
- ``datasette.jump.JumpSQL.menu_item()`` is a shortcut for adding individual jump menu items that are not backed by resources in the internal catalog.
|
||||
- New :ref:`javascript_plugins_makeJumpSections` JavaScript plugin hook, allowing plugins to add custom blank-state sections to the jump-to menu before the user has typed a query.
|
||||
- Jump menu results now show their ``type`` as a category label, and can show optional longer ``description`` text for individual results.
|
||||
- Debug menu links now appear in the jump-to menu instead of the top-right app menu, with descriptions for each debug item.
|
||||
- New documented :ref:`datasette.fixtures.populate_fixture_database(conn) <datasette_fixtures_populate_fixture_database>` helper for creating the fixture database tables used by Datasette's own tests, intended for plugin test suites.
|
||||
|
||||
.. _v1_0_a29:
|
||||
|
|
|
|||
|
|
@ -144,46 +144,62 @@ Shows currently attached databases. `Databases example <https://latest.datasette
|
|||
}
|
||||
]
|
||||
|
||||
.. _TablesView:
|
||||
.. _JumpView:
|
||||
|
||||
/-/tables
|
||||
---------
|
||||
/-/jump
|
||||
-------
|
||||
|
||||
Returns a JSON list of all tables that the current actor has permission to view. This endpoint uses the resource-based permission system and respects database and table-level access controls.
|
||||
Returns a JSON list of items that the current actor has permission to view for Datasette's jump menu. By default this includes visible databases, tables, views and canned queries, and plugins can contribute additional items.
|
||||
|
||||
The endpoint supports a ``?q=`` query parameter for filtering tables by name using case-insensitive regex matching.
|
||||
Each item includes a ``type`` string used as a category label in the menu. Items can also include an optional ``description`` with longer text describing that individual result.
|
||||
|
||||
`Tables example <https://latest.datasette.io/-/tables>`_:
|
||||
The endpoint supports a ``?q=`` query parameter for filtering items by name.
|
||||
|
||||
`Jump example <https://latest.datasette.io/-/jump>`_:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
"name": "fixtures",
|
||||
"url": "/fixtures",
|
||||
"type": "database",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"name": "fixtures/searchable",
|
||||
"url": "/fixtures/searchable"
|
||||
"name": "fixtures: facetable",
|
||||
"url": "/fixtures/facetable",
|
||||
"type": "table",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"name": "fixtures: recent_releases",
|
||||
"url": "/fixtures/recent_releases",
|
||||
"type": "query",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
|
||||
Search example with ``?q=facet`` returns only tables matching ``.*facet.*``:
|
||||
Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"name": "fixtures/facetable",
|
||||
"url": "/fixtures/facetable"
|
||||
"name": "fixtures: facetable",
|
||||
"url": "/fixtures/facetable",
|
||||
"type": "table",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), tables must match the pattern ``.*user.*profile.*``. Results are ordered by shortest table name first.
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
|
||||
|
||||
.. _JsonDataView_threads:
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,48 @@ JavaScript plugins are blocks of code that can be registered with Datasette usin
|
|||
|
||||
The ``implementation`` object passed to this method should include a ``version`` key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
|
||||
|
||||
.. _javascript_plugins_makeJumpSections:
|
||||
|
||||
makeJumpSections()
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the ``/`` jump menu, before the user starts typing a search.
|
||||
|
||||
Each object should have the following:
|
||||
|
||||
``id`` - string
|
||||
A unique string ID for the section, for example ``agent-chat``
|
||||
``render(node, context)`` - function
|
||||
A function that will be called with a DOM node to render the section into
|
||||
|
||||
The ``context`` object has the following keys:
|
||||
|
||||
``navigationSearch``
|
||||
The ``<navigation-search>`` custom element instance.
|
||||
|
||||
This example shows how a plugin might add a button for starting a new chat:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener('datasette_init', function(ev) {
|
||||
ev.detail.registerPlugin('agent-plugin', {
|
||||
version: 0.1,
|
||||
makeJumpSections: () => {
|
||||
return [
|
||||
{
|
||||
id: 'agent-chat',
|
||||
render: node => {
|
||||
node.innerHTML = '<button type="button">Start a new chat</button>';
|
||||
node.querySelector('button').addEventListener('click', () => {
|
||||
location.href = '/-/agent/new';
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
.. _javascript_plugins_makeAboveTablePanelConfigs:
|
||||
|
||||
makeAboveTablePanelConfigs()
|
||||
|
|
|
|||
|
|
@ -1881,6 +1881,106 @@ Using :ref:`internals_datasette_urls` here ensures that links in the menu will t
|
|||
|
||||
Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
||||
|
||||
.. _plugin_hook_jump_items_sql:
|
||||
|
||||
jump_items_sql(datasette, actor, request)
|
||||
-----------------------------------------
|
||||
|
||||
``datasette`` - :ref:`internals_datasette`
|
||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||
|
||||
``actor`` - dictionary or None
|
||||
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||
|
||||
``request`` - :ref:`internals_request`
|
||||
The current HTTP request.
|
||||
|
||||
This hook allows plugins to add extra results to Datasette's ``/`` jump menu, which is powered by the ``/-/jump`` JSON endpoint.
|
||||
|
||||
Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and canned query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values.
|
||||
|
||||
``JumpSQL`` queries run against Datasette's internal database by default. To run a query against another database, pass its name as the optional ``database=`` argument. For example, ``JumpSQL(database="content", sql="...")`` runs against the ``content`` database.
|
||||
|
||||
Datasette groups ``JumpSQL`` queries by database and executes one ``UNION ALL`` query for each database.
|
||||
|
||||
The SQL query must return these columns:
|
||||
|
||||
``type``
|
||||
A short type string for the result, for example ``"app"`` or ``"dashboard"``. The jump menu displays this above the item as a category label.
|
||||
|
||||
``label``
|
||||
The stable name for the result. This is returned as ``name`` in the JSON API and is used for sorting.
|
||||
|
||||
``description``
|
||||
Optional longer text describing this individual item, or ``NULL``. The jump menu displays this below the item's URL when it is present.
|
||||
|
||||
``url``
|
||||
The URL to navigate to when the item is selected. This can be either a string starting with ``/`` or a JSON object describing a call to one of the :ref:`internals_datasette_urls` methods. For example, ``json_object('method', 'table', 'database', 'fixtures', 'table', 'facetable')`` calls ``datasette.urls.table(database='fixtures', table='facetable')``. Unknown methods or invalid named arguments will result in an error.
|
||||
|
||||
``search_text``
|
||||
Text that should be searched by the ``?q=`` parameter.
|
||||
|
||||
``display_name``
|
||||
A human-readable label for the result, or ``NULL``. Datasette returns this as ``display_name`` in the JSON API, and the jump menu shows it as the primary readable label with ``name`` shown underneath.
|
||||
|
||||
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before adding the SQL fragment to the per-database ``UNION ALL`` query.
|
||||
|
||||
This example returns a SQL fragment that searches rows from a ``dashboards`` table in the ``content`` database. The ``url`` column uses ``json_object()`` to describe a call to ``datasette.urls.row(database='content', table='dashboards', row_path=slug)``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
if not actor:
|
||||
return None
|
||||
return JumpSQL(
|
||||
sql="""
|
||||
SELECT
|
||||
'dashboard' AS type,
|
||||
slug AS label,
|
||||
description AS description,
|
||||
json_object(
|
||||
'method', 'row',
|
||||
'database', 'content',
|
||||
'table', 'dashboards',
|
||||
'row_path', slug
|
||||
) AS url,
|
||||
slug || ' ' || COALESCE(title, '') || ' ' || COALESCE(description, '') AS search_text,
|
||||
title AS display_name
|
||||
FROM dashboards
|
||||
WHERE owner_id = :actor_id
|
||||
""",
|
||||
params={"actor_id": actor["id"]},
|
||||
database="content",
|
||||
)
|
||||
|
||||
This example uses the ``JumpSQL.menu_item()`` shortcut to add a single "Plugin dashboard" result for signed-in users:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
if not actor:
|
||||
return None
|
||||
return JumpSQL.menu_item(
|
||||
item_type="dashboard",
|
||||
label="plugin-dashboard",
|
||||
description="Review plugin status and configuration.",
|
||||
url="/-/plugin-dashboard",
|
||||
search_text="plugin dashboard",
|
||||
display_name="Plugin dashboard",
|
||||
)
|
||||
|
||||
``JumpSQL.menu_item(...)`` is a shortcut for adding a single jump menu item from Python code. It accepts the keyword arguments shown above.
|
||||
|
||||
.. _plugin_actions:
|
||||
|
||||
Action hooks
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
|
|
|
|||
465
tests/test_jump.py
Normal file
465
tests/test_jump.py
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.app import Datasette
|
||||
from datasette.jump import JumpSQL
|
||||
from datasette.plugins import pm
|
||||
from datasette.views.special import JumpView
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ds_for_jump():
|
||||
ds = Datasette(
|
||||
config={
|
||||
"databases": {
|
||||
"content": {
|
||||
"allow": {"id": "*"},
|
||||
"tables": {
|
||||
"articles": {"allow": {"id": "editor"}},
|
||||
"comments": {"allow": True},
|
||||
},
|
||||
"queries": {
|
||||
"recent_comments": {
|
||||
"sql": "select * from comments",
|
||||
"allow": {"id": "*"},
|
||||
"title": "Recent comments",
|
||||
},
|
||||
"release_notes": {
|
||||
"sql": "select 1",
|
||||
"allow": {"id": "*"},
|
||||
"title": "Recent Datasette releases",
|
||||
},
|
||||
"editor_report": {
|
||||
"sql": "select * from articles",
|
||||
"allow": {"id": "editor"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"private": {
|
||||
"allow": False,
|
||||
"queries": {
|
||||
"private_report": "select 1",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
await ds.invoke_startup()
|
||||
|
||||
content_db = ds.add_memory_database("jump_test_content", name="content")
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, title TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY, body TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
|
||||
)
|
||||
await content_db.execute_write(
|
||||
"CREATE VIEW IF NOT EXISTS comment_summary AS SELECT body FROM comments"
|
||||
)
|
||||
|
||||
private_db = ds.add_memory_database("jump_test_private", name="private")
|
||||
await private_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, data TEXT)"
|
||||
)
|
||||
|
||||
public_db = ds.add_memory_database("jump_test_public", name="public")
|
||||
await public_db.execute_write(
|
||||
"CREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY, content TEXT)"
|
||||
)
|
||||
|
||||
await ds._refresh_schemas()
|
||||
return ds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_searches_tables_databases_views_and_canned_queries(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=content", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
matches_by_type_and_name = {
|
||||
(match["type"], match["name"]): match for match in data["matches"]
|
||||
}
|
||||
assert ("database", "content") in matches_by_type_and_name
|
||||
assert ("table", "content: comments") in matches_by_type_and_name
|
||||
assert ("view", "content: comment_summary") in matches_by_type_and_name
|
||||
assert ("query", "content: recent_comments") in matches_by_type_and_name
|
||||
assert matches_by_type_and_name[("database", "content")]["url"] == "/content"
|
||||
assert (
|
||||
matches_by_type_and_name[("query", "content: recent_comments")]["url"]
|
||||
== "/content/recent_comments"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_uses_canned_query_names_not_titles(ds_for_jump):
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=datasette", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["matches"] == []
|
||||
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=release", actor={"id": "user"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["matches"] == [
|
||||
{
|
||||
"name": "content: release_notes",
|
||||
"url": "/content/release_notes",
|
||||
"type": "query",
|
||||
"description": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_respects_resource_permissions(ds_for_jump):
|
||||
regular = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=articles", actor={"id": "regular"}
|
||||
)
|
||||
editor = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=articles", actor={"id": "editor"}
|
||||
)
|
||||
private = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=secrets", actor={"id": "editor"}
|
||||
)
|
||||
|
||||
assert {match["name"] for match in regular.json()["matches"]} == {
|
||||
"public: articles"
|
||||
}
|
||||
assert {match["name"] for match in editor.json()["matches"]} == {
|
||||
"content: articles",
|
||||
"public: articles",
|
||||
}
|
||||
assert private.json()["matches"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_sql_menu_item_helper(ds_for_jump):
|
||||
assert JumpSQL("SELECT 1").database is None
|
||||
assert JumpSQL("SELECT 1", database="content").database == "content"
|
||||
assert JumpSQL("SELECT 1", None, "content").database == "content"
|
||||
|
||||
fragment = JumpSQL.menu_item(
|
||||
label="Plugin dashboard",
|
||||
url="/-/plugin-dashboard",
|
||||
description="Plugin tool",
|
||||
search_text="dashboard plugin",
|
||||
display_name="Plugin Dashboard",
|
||||
item_type="plugin",
|
||||
)
|
||||
result = await ds_for_jump.get_internal_database().execute(
|
||||
fragment.sql, fragment.params
|
||||
)
|
||||
assert dict(result.first()) == {
|
||||
"type": "plugin",
|
||||
"label": "Plugin dashboard",
|
||||
"description": "Plugin tool",
|
||||
"url": "/-/plugin-dashboard",
|
||||
"search_text": "dashboard plugin",
|
||||
"display_name": "Plugin Dashboard",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_menu_items_are_in_jump_for_debug_menu_permission():
|
||||
ds = Datasette(
|
||||
config={
|
||||
"permissions": {
|
||||
"debug-menu": {"id": "debugger"},
|
||||
}
|
||||
}
|
||||
)
|
||||
await ds.invoke_startup()
|
||||
response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "debugger"})
|
||||
assert response.status_code == 200
|
||||
debug_matches = [
|
||||
match for match in response.json()["matches"] if match["type"] == "debug"
|
||||
]
|
||||
assert {match["name"]: match["url"] for match in debug_matches} == {
|
||||
"Databases": "/-/databases",
|
||||
"Installed plugins": "/-/plugins",
|
||||
"Version info": "/-/versions",
|
||||
"Settings": "/-/settings",
|
||||
"Debug permissions": "/-/permissions",
|
||||
"Debug messages": "/-/messages",
|
||||
"Debug allow rules": "/-/allow-debug",
|
||||
"Debug threads": "/-/threads",
|
||||
"Debug actor": "/-/actor",
|
||||
"Pattern portfolio": "/-/patterns",
|
||||
}
|
||||
descriptions_by_name = {
|
||||
match["name"]: match["description"] for match in debug_matches
|
||||
}
|
||||
assert all(descriptions_by_name.values())
|
||||
assert descriptions_by_name["Databases"] == (
|
||||
"List of databases known to this Datasette instance."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_menu_items_are_hidden_without_debug_menu_permission():
|
||||
ds = Datasette()
|
||||
await ds.invoke_startup()
|
||||
response = await ds.client.get("/-/jump.json?q=debug", actor={"id": "regular"})
|
||||
assert response.status_code == 200
|
||||
assert [
|
||||
match for match in response.json()["matches"] if match["type"] == "debug"
|
||||
] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
|
||||
class JumpPlugin:
|
||||
@hookimpl
|
||||
def jump_items_sql(self, datasette, actor, request):
|
||||
return JumpSQL(
|
||||
sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'plugin-dashboard: ' || :actor_id AS label,
|
||||
'Plugin supplied item' AS description,
|
||||
'/-/plugin-dashboard' AS url,
|
||||
'plugin dashboard ' || :actor_id AS search_text,
|
||||
'Plugin dashboard for ' || :actor_id AS display_name
|
||||
""",
|
||||
params={"actor_id": actor["id"] if actor else "anonymous"},
|
||||
)
|
||||
|
||||
plugin = JumpPlugin()
|
||||
pm.register(plugin, name="test-jump-plugin")
|
||||
try:
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=dashboard", actor={"id": "alice"}
|
||||
)
|
||||
finally:
|
||||
pm.unregister(name="test-jump-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
plugin_matches = [
|
||||
match for match in response.json()["matches"] if match["type"] == "plugin"
|
||||
]
|
||||
assert plugin_matches == [
|
||||
{
|
||||
"name": "plugin-dashboard: alice",
|
||||
"display_name": "Plugin dashboard for alice",
|
||||
"url": "/-/plugin-dashboard",
|
||||
"type": "plugin",
|
||||
"description": "Plugin supplied item",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_sql_unions_fragments_by_database(ds_for_jump, monkeypatch):
|
||||
class JumpPlugin:
|
||||
@hookimpl
|
||||
def jump_items_sql(self, datasette, actor, request):
|
||||
return [
|
||||
JumpSQL(sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'first-unioned-item' AS label,
|
||||
NULL AS description,
|
||||
'/-/first-unioned-item' AS url,
|
||||
'unioned item' AS search_text,
|
||||
NULL AS display_name
|
||||
"""),
|
||||
JumpSQL(sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'second-unioned-item' AS label,
|
||||
NULL AS description,
|
||||
'/-/second-unioned-item' AS url,
|
||||
'unioned item' AS search_text,
|
||||
NULL AS display_name
|
||||
"""),
|
||||
JumpSQL(
|
||||
"""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'content-first-unioned-item' AS label,
|
||||
NULL AS description,
|
||||
'/-/content-first-unioned-item' AS url,
|
||||
'unioned item' AS search_text,
|
||||
NULL AS display_name
|
||||
""",
|
||||
None,
|
||||
"content",
|
||||
),
|
||||
JumpSQL(
|
||||
database="content",
|
||||
sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'content-second-unioned-item' AS label,
|
||||
NULL AS description,
|
||||
'/-/content-second-unioned-item' AS url,
|
||||
'unioned item' AS search_text,
|
||||
NULL AS display_name
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
internal_db = ds_for_jump.get_internal_database()
|
||||
original_execute = internal_db.execute
|
||||
internal_jump_query_sql = []
|
||||
|
||||
async def internal_execute_with_recording(sql, *args, **kwargs):
|
||||
if "unioned-item" in sql:
|
||||
internal_jump_query_sql.append(sql)
|
||||
return await original_execute(sql, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(internal_db, "execute", internal_execute_with_recording)
|
||||
|
||||
content_db = ds_for_jump.get_database("content")
|
||||
original_content_execute = content_db.execute
|
||||
content_jump_query_sql = []
|
||||
|
||||
async def content_execute_with_recording(sql, *args, **kwargs):
|
||||
if "unioned-item" in sql:
|
||||
content_jump_query_sql.append(sql)
|
||||
return await original_content_execute(sql, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(content_db, "execute", content_execute_with_recording)
|
||||
|
||||
plugin = JumpPlugin()
|
||||
pm.register(plugin, name="test-jump-union-plugin")
|
||||
try:
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=unioned", actor={"id": "alice"}
|
||||
)
|
||||
finally:
|
||||
pm.unregister(name="test-jump-union-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(internal_jump_query_sql) == 1
|
||||
assert " UNION ALL " in internal_jump_query_sql[0]
|
||||
assert len(content_jump_query_sql) == 1
|
||||
assert " UNION ALL " in content_jump_query_sql[0]
|
||||
assert {match["name"] for match in response.json()["matches"]} == {
|
||||
"content-first-unioned-item",
|
||||
"content-second-unioned-item",
|
||||
"first-unioned-item",
|
||||
"second-unioned-item",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_sql_can_query_named_database(ds_for_jump):
|
||||
content_db = ds_for_jump.get_database("content")
|
||||
await content_db.execute_write(
|
||||
"INSERT INTO comments (id, body) VALUES (1001, 'Named database jump target')"
|
||||
)
|
||||
|
||||
class JumpPlugin:
|
||||
@hookimpl
|
||||
def jump_items_sql(self, datasette, actor, request):
|
||||
return JumpSQL(
|
||||
database="content",
|
||||
sql="""
|
||||
SELECT
|
||||
'comment' AS type,
|
||||
body AS label,
|
||||
'Comment from content database' AS description,
|
||||
json_object(
|
||||
'method', 'table',
|
||||
'database', 'content',
|
||||
'table', 'comments'
|
||||
) AS url,
|
||||
body AS search_text,
|
||||
body AS display_name
|
||||
FROM comments
|
||||
WHERE id = :comment_id
|
||||
""",
|
||||
params={"comment_id": 1001},
|
||||
)
|
||||
|
||||
plugin = JumpPlugin()
|
||||
pm.register(plugin, name="test-jump-content-db-plugin")
|
||||
try:
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=named+database", actor={"id": "alice"}
|
||||
)
|
||||
finally:
|
||||
pm.unregister(name="test-jump-content-db-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
plugin_matches = [
|
||||
match for match in response.json()["matches"] if match["type"] == "comment"
|
||||
]
|
||||
assert plugin_matches == [
|
||||
{
|
||||
"name": "Named database jump target",
|
||||
"display_name": "Named database jump target",
|
||||
"url": "/content/comments",
|
||||
"type": "comment",
|
||||
"description": "Comment from content database",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_resolves_url_descriptors_from_sql(ds_for_jump):
|
||||
class JumpPlugin:
|
||||
@hookimpl
|
||||
def jump_items_sql(self, datasette, actor, request):
|
||||
return JumpSQL(sql="""
|
||||
SELECT
|
||||
'plugin' AS type,
|
||||
'Table descriptor' AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'table',
|
||||
'database', 'content',
|
||||
'table', 'comments'
|
||||
) AS url,
|
||||
'table descriptor comments' AS search_text,
|
||||
NULL AS display_name
|
||||
""")
|
||||
|
||||
plugin = JumpPlugin()
|
||||
pm.register(plugin, name="test-jump-url-descriptor-plugin")
|
||||
try:
|
||||
response = await ds_for_jump.client.get(
|
||||
"/-/jump.json?q=descriptor", actor={"id": "alice"}
|
||||
)
|
||||
finally:
|
||||
pm.unregister(name="test-jump-url-descriptor-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
plugin_matches = [
|
||||
match for match in response.json()["matches"] if match["type"] == "plugin"
|
||||
]
|
||||
assert plugin_matches == [
|
||||
{
|
||||
"name": "Table descriptor",
|
||||
"url": "/content/comments",
|
||||
"type": "plugin",
|
||||
"description": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jump_url_descriptor_errors(ds_for_jump):
|
||||
view = JumpView(ds_for_jump)
|
||||
with pytest.raises(AttributeError):
|
||||
view._resolve_url('{"method": "not_a_url_method"}')
|
||||
with pytest.raises(TypeError):
|
||||
view._resolve_url(
|
||||
'{"method": "table", "database_name": "content", "table_name": "comments"}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tables_endpoint_removed(ds_for_jump):
|
||||
response = await ds_for_jump.client.get("/-/tables.json")
|
||||
assert response.status_code == 404
|
||||
394
tests/test_navigation_search_js.py
Normal file
394
tests/test_navigation_search_js.py
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC_DIR = REPO_ROOT / "datasette" / "static"
|
||||
|
||||
|
||||
def test_navigation_search_tracks_and_renders_recent_items():
|
||||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const navigationSearchJs = __NAVIGATION_SEARCH_JS__;
|
||||
|
||||
class FakeElement {
|
||||
constructor() {
|
||||
this.innerHTML = "";
|
||||
this.value = "";
|
||||
this.dataset = {};
|
||||
this.open = false;
|
||||
}
|
||||
addEventListener() {}
|
||||
close() { this.open = false; }
|
||||
focus() {}
|
||||
querySelector() {
|
||||
return { scrollIntoView() {} };
|
||||
}
|
||||
showModal() { this.open = true; }
|
||||
}
|
||||
|
||||
class FakeShadowRoot {
|
||||
constructor() {
|
||||
this.innerHTML = "";
|
||||
this.dialog = new FakeElement();
|
||||
this.input = new FakeElement();
|
||||
this.results = new FakeElement();
|
||||
}
|
||||
querySelector(selector) {
|
||||
if (selector == "dialog") return this.dialog;
|
||||
if (selector == ".search-input") return this.input;
|
||||
if (selector == ".results-container") return this.results;
|
||||
return new FakeElement();
|
||||
}
|
||||
}
|
||||
|
||||
global.HTMLElement = class {
|
||||
constructor() {
|
||||
this.attributes = {};
|
||||
}
|
||||
attachShadow() {
|
||||
this.shadowRoot = new FakeShadowRoot();
|
||||
return this.shadowRoot;
|
||||
}
|
||||
dispatchEvent() {}
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] || null;
|
||||
}
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
}
|
||||
};
|
||||
global.CustomEvent = class {
|
||||
constructor(name, options) {
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
}
|
||||
};
|
||||
global.customElements = {
|
||||
registry: new Map(),
|
||||
define(name, cls) {
|
||||
this.registry.set(name, cls);
|
||||
},
|
||||
};
|
||||
global.document = {
|
||||
addEventListener() {},
|
||||
activeElement: null,
|
||||
createElement() {
|
||||
return {
|
||||
set textContent(value) {
|
||||
this.innerHTML = String(value)
|
||||
.replace(/&/g, "&")
|
||||
.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, ">")
|
||||
.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 = [
|
||||
'<section class="agent-jump-start">',
|
||||
'<button>Start a new agent chat</button>',
|
||||
'</section>',
|
||||
].join('');
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
vm.runInThisContext(
|
||||
fs.readFileSync(navigationSearchJs, "utf8"),
|
||||
{ filename: "navigation-search.js" }
|
||||
);
|
||||
|
||||
const Component = customElements.registry.get("navigation-search");
|
||||
const element = new Component();
|
||||
element.shadowRoot.input.value = "";
|
||||
element.renderResults();
|
||||
|
||||
const html = element.shadowRoot.results.innerHTML;
|
||||
if (!html.includes("Start a new agent chat")) {
|
||||
throw new Error(`Missing jump section content: ${html}`);
|
||||
}
|
||||
process.stdout.write("ok");
|
||||
""")
|
||||
.replace(
|
||||
"__DATASETTE_MANAGER_JS__",
|
||||
json.dumps(str(STATIC_DIR / "datasette-manager.js")),
|
||||
)
|
||||
.replace(
|
||||
"__NAVIGATION_SEARCH_JS__",
|
||||
json.dumps(str(STATIC_DIR / "navigation-search.js")),
|
||||
)
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.endswith("ok")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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