mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
parent
1a861be19e
commit
18a64fbb29
12 changed files with 193 additions and 13 deletions
|
|
@ -750,11 +750,22 @@ class Datasette:
|
|||
)
|
||||
extra_template_vars.update(extra_vars)
|
||||
|
||||
async def menu_links():
|
||||
links = []
|
||||
for hook in pm.hook.menu_links(
|
||||
datasette=self, actor=request.actor if request else None
|
||||
):
|
||||
extra_links = await await_me_maybe(hook)
|
||||
if extra_links:
|
||||
links.extend(extra_links)
|
||||
return links
|
||||
|
||||
template_context = {
|
||||
**context,
|
||||
**{
|
||||
"urls": self.urls,
|
||||
"actor": request.actor if request else None,
|
||||
"menu_links": menu_links,
|
||||
"display_actor": display_actor,
|
||||
"show_logout": request is not None and "ds_actor" in request.cookies,
|
||||
"app_css_hash": self.app_css_hash(),
|
||||
|
|
@ -1161,6 +1172,7 @@ class DatasetteRouter:
|
|||
info,
|
||||
urls=self.ds.urls,
|
||||
app_css_hash=self.ds.app_css_hash(),
|
||||
menu_links=lambda: [],
|
||||
)
|
||||
),
|
||||
status=status,
|
||||
|
|
|
|||
40
datasette/default_menu_links.py
Normal file
40
datasette/default_menu_links.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
if actor and actor.get("id") == "root":
|
||||
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("/-/metadata"),
|
||||
"label": "Metadata",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/config"),
|
||||
"label": "Config",
|
||||
},
|
||||
{
|
||||
"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"},
|
||||
]
|
||||
|
|
@ -97,3 +97,8 @@ def register_magic_parameters(datasette):
|
|||
@hookspec
|
||||
def forbidden(datasette, request, message):
|
||||
"Custom response for a 403 forbidden error"
|
||||
|
||||
|
||||
@hookspec
|
||||
def menu_links(datasette, actor):
|
||||
"Links for the navigation menu"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.default_permissions",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_menu_links",
|
||||
)
|
||||
|
||||
pm = pluggy.PluginManager("datasette")
|
||||
|
|
|
|||
|
|
@ -261,13 +261,13 @@ footer p {
|
|||
header .crumbs {
|
||||
float: left;
|
||||
}
|
||||
header .logout {
|
||||
header .actor {
|
||||
float: right;
|
||||
text-align: right;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
header .logout form {
|
||||
display: inline;
|
||||
padding-right: 1rem;
|
||||
position: relative;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
footer a:link,
|
||||
|
|
@ -312,6 +312,29 @@ footer {
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Navigation menu */
|
||||
details.nav-menu > summary {
|
||||
list-style: none;
|
||||
display: inline;
|
||||
float: right;
|
||||
position: relative;
|
||||
}
|
||||
details.nav-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
details .nav-menu-inner {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 10px;
|
||||
width: 180px;
|
||||
background-color: #276890;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
.nav-menu-inner a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* Components ============================================================== */
|
||||
|
||||
|
|
|
|||
|
|
@ -13,15 +13,33 @@
|
|||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
|
||||
<header><nav>{% block nav %}
|
||||
{% set links = menu_links() %}{% if links or show_logout %}
|
||||
<details class="nav-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16" width="16" height="16">
|
||||
<title id="nav-menu-svg-title">Menu</title>
|
||||
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
||||
</svg></summary>
|
||||
<div class="nav-menu-inner">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
{% if actor %}
|
||||
<div class="logout">
|
||||
<strong>{{ display_actor(actor) }}</strong>{% if show_logout %} ·
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>{% endif %}
|
||||
<div class="actor">
|
||||
<strong>{{ display_actor(actor) }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}</nav></header>
|
||||
|
|
@ -41,6 +59,22 @@
|
|||
|
||||
<footer class="ft">{% block footer %}{% include "_footer.html" %}{% endblock %}</footer>
|
||||
|
||||
<script>
|
||||
var menuDetails = document.querySelector('.nav-menu');
|
||||
document.body.addEventListener('click', (ev) => {
|
||||
/* was this click outside the menu? */
|
||||
if (menuDetails.getAttribute('open') !== "") {
|
||||
return;
|
||||
}
|
||||
var target = ev.target;
|
||||
while (target && target != menuDetails) {
|
||||
target = target.parentNode;
|
||||
}
|
||||
if (!target) {
|
||||
menuDetails.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% for body_script in body_scripts %}
|
||||
<script>{{ body_script }}</script>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -989,3 +989,35 @@ The function can alternatively return an awaitable function if it needs to make
|
|||
return Response.html(await datasette.render_template("forbidden.html"))
|
||||
|
||||
return inner
|
||||
|
||||
.. _plugin_hook_menu_links:
|
||||
|
||||
menu_links(datasette, actor)
|
||||
----------------------------
|
||||
|
||||
``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.
|
||||
|
||||
``request`` - object
|
||||
The current HTTP :ref:`internals_request`.
|
||||
|
||||
This hook provides items to be included in the menu displayed by Datasette's top right menu icon.
|
||||
|
||||
The hook should return a list of ``{"href": "...", "label": "..."}`` menu items. These will be added to the menu.
|
||||
|
||||
It can alternatively return an ``async def`` awaitable function which returns a list of menu items.
|
||||
|
||||
This example adds a new menu item but only if the signed in user is ``"root"``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
if actor and actor.get("id") == "root":
|
||||
return [
|
||||
{"href": datasette.urls.path("/-/edit-schema"), "label": "Edit schema"},
|
||||
]
|
||||
|
||||
Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`config_base_url` setting into account.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ EXPECTED_PLUGINS = [
|
|||
"extra_js_urls",
|
||||
"extra_template_vars",
|
||||
"forbidden",
|
||||
"menu_links",
|
||||
"permission_allowed",
|
||||
"prepare_connection",
|
||||
"prepare_jinja2_environment",
|
||||
|
|
@ -64,6 +65,7 @@ EXPECTED_PLUGINS = [
|
|||
"canned_queries",
|
||||
"extra_js_urls",
|
||||
"extra_template_vars",
|
||||
"menu_links",
|
||||
"permission_allowed",
|
||||
"render_cell",
|
||||
"startup",
|
||||
|
|
|
|||
|
|
@ -290,3 +290,9 @@ def forbidden(datasette, request, message):
|
|||
datasette._last_forbidden_message = message
|
||||
if request.path == "/data2":
|
||||
return Response.redirect("/login?message=" + message)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
if actor:
|
||||
return [{"href": datasette.urls.instance(), "label": "Hello"}]
|
||||
|
|
|
|||
|
|
@ -146,3 +146,12 @@ def canned_queries(datasette, database):
|
|||
}
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
@hookimpl(trylast=True)
|
||||
def menu_links(datasette, actor):
|
||||
async def inner():
|
||||
if actor:
|
||||
return [{"href": datasette.urls.instance(), "label": "Hello 2"}]
|
||||
|
||||
return inner
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ def test_logout_button_in_navigation(app_client, path):
|
|||
)
|
||||
anon_response = app_client.get(path)
|
||||
for fragment in (
|
||||
"<strong>test</strong> ·",
|
||||
"<strong>test</strong>",
|
||||
'<form action="/-/logout" method="post">',
|
||||
):
|
||||
assert fragment in response.text
|
||||
|
|
@ -112,5 +112,4 @@ def test_logout_button_in_navigation(app_client, path):
|
|||
def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(app_client, path):
|
||||
response = app_client.get(path + "?_bot=1")
|
||||
assert "<strong>bot</strong>" in response.text
|
||||
assert "<strong>bot</strong> ·" not in response.text
|
||||
assert '<form action="/-/logout" method="post">' not in response.text
|
||||
|
|
|
|||
|
|
@ -765,3 +765,20 @@ def test_hook_forbidden(restore_working_directory):
|
|||
assert 302 == response2.status
|
||||
assert "/login?message=view-database" == response2.headers["Location"]
|
||||
assert "view-database" == client.ds._last_forbidden_message
|
||||
|
||||
|
||||
def test_hook_menu_links(app_client):
|
||||
def get_menu_links(html):
|
||||
soup = Soup(html, "html.parser")
|
||||
return [
|
||||
{"label": a.text, "href": a["href"]} for a in soup.find("nav").select("a")
|
||||
]
|
||||
|
||||
response = app_client.get("/")
|
||||
assert get_menu_links(response.text) == []
|
||||
|
||||
response_2 = app_client.get("/?_bot=1")
|
||||
assert get_menu_links(response_2.text) == [
|
||||
{"label": "Hello", "href": "/"},
|
||||
{"label": "Hello 2", "href": "/"},
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue