mirror of
https://github.com/simonw/datasette.git
synced 2026-06-04 16:16:59 +02:00
Replace jump_start() hook with JavaScript makeJumpSections() hook
This commit is contained in:
parent
6057c76165
commit
8568320a23
14 changed files with 288 additions and 112 deletions
|
|
@ -2039,22 +2039,6 @@ 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,
|
||||
**{
|
||||
|
|
@ -2063,7 +2047,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -159,12 +159,7 @@ def menu_links(datasette, actor, request):
|
|||
|
||||
@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"""
|
||||
"""SQL fragments for extra items in the jump menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from typing import Any
|
|||
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_]*)")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -447,9 +447,46 @@ class NavigationSearch extends HTMLElement {
|
|||
this.renderResults();
|
||||
}
|
||||
|
||||
startContentHtml() {
|
||||
const template = this.querySelector("template[data-jump-start]");
|
||||
return template ? template.innerHTML.trim() : "";
|
||||
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) {
|
||||
|
|
@ -484,9 +521,9 @@ class NavigationSearch extends HTMLElement {
|
|||
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 jumpSections = showStartContent ? this.jumpSections() : [];
|
||||
const startBlock = showStartContent
|
||||
? this.jumpSectionsHtml(jumpSections)
|
||||
: "";
|
||||
const recentItems = showStartContent ? this.loadRecentItems() : [];
|
||||
const defaultMatches = showStartContent ? [] : this.matches;
|
||||
|
|
@ -507,6 +544,7 @@ class NavigationSearch extends HTMLElement {
|
|||
if (renderedMatches.length === 0) {
|
||||
if (startBlock) {
|
||||
container.innerHTML = startBlock;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
} else if (showStartContent) {
|
||||
container.innerHTML = "";
|
||||
} else {
|
||||
|
|
@ -529,6 +567,7 @@ class NavigationSearch extends HTMLElement {
|
|||
)
|
||||
.join("");
|
||||
container.innerHTML = startBlock + recentHtml + defaultHtml;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
|
|
|
|||
|
|
@ -71,13 +71,6 @@
|
|||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<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>
|
||||
<navigation-search url="/-/jump"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -980,7 +980,6 @@ class JumpView(BaseView):
|
|||
FROM allowed_databases
|
||||
""",
|
||||
params=database_params,
|
||||
has_display_name=True,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
|
|
@ -1004,7 +1003,6 @@ class JumpView(BaseView):
|
|||
AND catalog_views.view_name = allowed_tables.child
|
||||
""",
|
||||
params=table_params,
|
||||
has_display_name=True,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
|
|
@ -1031,7 +1029,6 @@ class JumpView(BaseView):
|
|||
AND query_display_names.query_name = allowed_queries.child
|
||||
""",
|
||||
params={**query_params, **query_display_names_params},
|
||||
has_display_name=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -1095,7 +1092,7 @@ class JumpView(BaseView):
|
|||
search_text,
|
||||
sort_key,
|
||||
source,
|
||||
{"display_name" if fragment.has_display_name else "NULL AS display_name"}
|
||||
display_name
|
||||
FROM (
|
||||
{fragment_sql}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ Other changes
|
|||
~~~~~~~~~~~~~
|
||||
|
||||
- The internal ``catalog_views`` table now tracks SQLite views alongside tables in the introspection database. (:issue:`2495`)
|
||||
- Hitting the ``/`` brings up a search interface for navigating to tables that the current user can view. A new ``/-/tables`` endpoint supports this functionality. (:issue:`2523`)
|
||||
- Hitting the ``/`` brings up a search interface for navigating to databases, tables, views, canned queries and plugin-provided items that the current user can view. A new ``/-/jump`` endpoint supports this functionality, and JavaScript plugins can add custom blank-state sections using ``makeJumpSections()``. (:issue:`2523`)
|
||||
- Datasette attempts to detect some configuration errors on startup.
|
||||
- Datasette now supports Python 3.14 and no longer tests against Python 3.9.
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ 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)``.
|
||||
column.
|
||||
|
||||
`Jump example <https://latest.datasette.io/-/jump>`_:
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1928,7 +1928,8 @@ The SQL query must return these columns:
|
|||
``source``
|
||||
A string identifying the plugin that supplied the result.
|
||||
|
||||
If the SQL query also returns a ``display_name`` column, set ``has_display_name=True`` on the ``JumpSQL`` object. Datasette will return that value as ``display_name`` in the JSON API, and the jump menu will show it as the primary readable label with ``name`` shown underneath.
|
||||
``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.
|
||||
|
||||
This example adds a "Plugin dashboard" result for signed-in users:
|
||||
|
||||
|
|
@ -1955,46 +1956,11 @@ This example adds a "Plugin dashboard" result for signed-in users:
|
|||
80 AS sort_key,
|
||||
'my-plugin' AS source,
|
||||
'Plugin dashboard' AS display_name
|
||||
""",
|
||||
has_display_name=True,
|
||||
"""
|
||||
)
|
||||
|
||||
Use ``params=`` to pass SQL parameters. Datasette will automatically namespace those parameters before combining SQL fragments from different plugins.
|
||||
|
||||
.. _plugin_hook_jump_start:
|
||||
|
||||
jump_start(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` or None
|
||||
The current HTTP request. This can be ``None`` if the request object is not available.
|
||||
|
||||
This hook allows plugins to add custom HTML to the default blank state of Datasette's ``/`` jump menu, before the user starts typing a search.
|
||||
|
||||
The hook can return a string, a ``markupsafe.Markup`` object, or an awaitable function that returns either of those. Return ``None`` to add nothing.
|
||||
|
||||
This example shows a link for starting a new chat if the user is signed in:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_start(actor):
|
||||
if not actor:
|
||||
return None
|
||||
return Markup(
|
||||
'<p><a href="/-/agent/new">Start a new chat</a></p>'
|
||||
)
|
||||
|
||||
.. _plugin_actions:
|
||||
|
||||
Action hooks
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
from markupsafe import Markup
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.app import Datasette
|
||||
|
|
@ -163,7 +162,6 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
|
|||
'Plugin dashboard for ' || :actor_id AS display_name
|
||||
""",
|
||||
params={"actor_id": actor["id"] if actor else "anonymous"},
|
||||
has_display_name=True,
|
||||
)
|
||||
|
||||
plugin = JumpPlugin()
|
||||
|
|
@ -190,34 +188,6 @@ async def test_jump_uses_plugin_sql_with_namespaced_parameters(ds_for_jump):
|
|||
]
|
||||
|
||||
|
||||
@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")
|
||||
|
|
|
|||
|
|
@ -197,3 +197,178 @@ def test_navigation_search_tracks_and_renders_recent_items():
|
|||
"/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 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("datasette/static/datasette-manager.js", "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("datasette/static/navigation-search.js", "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");
|
||||
""")
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=".",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.endswith("ok")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue