Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nav menu plus menu_links() hook #1065

Merged
merged 5 commits into from
Oct 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,11 +750,22 @@ async def render_template(
)
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(),
Expand Down Expand Up @@ -1161,6 +1172,7 @@ async def handle_500(self, request, send, exception):
info,
urls=self.ds.urls,
app_css_hash=self.ds.app_css_hash(),
menu_links=lambda: [],
)
),
status=status,
Expand Down
40 changes: 40 additions & 0 deletions datasette/default_menu_links.py
Original file line number Diff line number Diff line change
@@ -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"},
]
5 changes: 5 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions datasette/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"datasette.default_permissions",
"datasette.default_magic_parameters",
"datasette.blob_renderer",
"datasette.default_menu_links",
)

pm = pluggy.PluginManager("datasette")
Expand Down
31 changes: 27 additions & 4 deletions datasette/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ============================================================== */

Expand Down
48 changes: 41 additions & 7 deletions datasette/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %} &middot;
<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>
Expand All @@ -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 %}
Expand Down
32 changes: 32 additions & 0 deletions docs/plugin_hooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"extra_js_urls",
"extra_template_vars",
"forbidden",
"menu_links",
"permission_allowed",
"prepare_connection",
"prepare_jinja2_environment",
Expand All @@ -64,6 +65,7 @@
"canned_queries",
"extra_js_urls",
"extra_template_vars",
"menu_links",
"permission_allowed",
"render_cell",
"startup",
Expand Down
6 changes: 6 additions & 0 deletions tests/plugins/my_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
9 changes: 9 additions & 0 deletions tests/plugins/my_plugin_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,12 @@ async def inner():
}

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
3 changes: 1 addition & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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> &middot;",
"<strong>test</strong>",
'<form action="/-/logout" method="post">',
):
assert fragment in response.text
Expand All @@ -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> &middot;" not in response.text
assert '<form action="/-/logout" method="post">' not in response.text
17 changes: 17 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "/"},
]