table_actions() plugin hook plus menu, closes #1066

Refs #690
pull/1069/head
Simon Willison 2020-10-29 22:16:41 -07:00
rodzic 8a4639bc43
commit 2f7731e9e5
10 zmienionych plików z 166 dodań i 14 usunięć

Wyświetl plik

@ -102,3 +102,8 @@ def forbidden(datasette, request, message):
@hookspec
def menu_links(datasette, actor):
"Links for the navigation menu"
@hookspec
def table_actions(datasette, actor, database, table):
"Links for the table actions menu"

Wyświetl plik

@ -118,7 +118,7 @@ h6,
.header3,
.header4,
.header5,
.header6 {
.header6 {
font-weight: 700;
font-size: 1rem;
margin: 0;
@ -162,6 +162,29 @@ h6,
text-decoration: underline;
}
.page-header {
padding-left: 10px;
border-left: 10px solid #666;
margin-bottom: 0.75rem;
margin-top: 1rem;
}
.page-header h1 {
display: inline;
margin: 0;
font-size: 2rem;
padding-right: 0.2em;
}
.page-header details {
display: inline;
}
.page-header details > summary {
list-style: none;
display: inline;
}
.page-header details > summary::-webkit-details-marker {
display: none;
}
div,
section,
article,
@ -335,6 +358,15 @@ details .nav-menu-inner {
display: block;
}
/* Table actions menu */
.table-menu-links {
position: relative;
}
.table-menu-links .dropdown-menu {
position: absolute;
top: 2rem;
right: 0;
}
/* Components ============================================================== */

Wyświetl plik

@ -60,19 +60,19 @@
<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;
}
/* Close any open details elements that this click is outside of */
var target = ev.target;
while (target && target != menuDetails) {
var detailsClickedWithin = null;
while (target && target.tagName != 'DETAILS') {
target = target.parentNode;
}
if (!target) {
menuDetails.removeAttribute('open');
if (target && target.tagName == 'DETAILS') {
detailsClickedWithin = target;
}
Array.from(document.getElementsByTagName('details')).filter(
(details) => details.open && details != detailsClickedWithin
).forEach(details => details.open = false);
});
</script>
{% for body_script in body_scripts %}

Wyświetl plik

@ -25,8 +25,29 @@
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
<div class="page-header" style="border-color: #{{ database_color(database) }}">
<h1>{{ metadata.title or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
{% set links = table_actions() %}{% if links %}
<details class="table-menu-links">
<summary><svg aria-labelledby="table-menu-links-title" role="img"
style="color: #666" xmlns="http://www.w3.org/2000/svg"
width="28" height="28" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="table-menu-links-title">Table actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg></summary>
<div class="dropdown-menu">
{% if links %}
<ul>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</details>{% endif %}
</div>
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}

Wyświetl plik

@ -7,6 +7,7 @@ import jinja2
from datasette.plugins import pm
from datasette.database import QueryInterrupted
from datasette.utils import (
await_me_maybe,
CustomRow,
MultiParams,
append_querystring,
@ -840,7 +841,21 @@ class TableView(RowTableShared):
elif use_rowid:
sort = "rowid"
async def table_actions():
links = []
for hook in pm.hook.table_actions(
datasette=self.ds,
table=table,
database=database,
actor=request.actor,
):
extra_links = await await_me_maybe(hook)
if extra_links:
links.extend(extra_links)
return links
return {
"table_actions": table_actions,
"supports_search": bool(fts_table),
"search": search or "",
"use_rowid": use_rowid,
@ -959,6 +974,7 @@ class RowView(RowTableShared):
)
for column in display_columns:
column["sortable"] = False
return {
"foreign_key_tables": await self.foreign_key_tables(
database, table, pk_values

Wyświetl plik

@ -998,10 +998,10 @@ 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`.
``actor`` - dictionary or None
The currently authenticated :ref:`actor <authentication_actor>`.
This hook provides items to be included in the menu displayed by Datasette's top right menu icon.
This hook allows additional 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.
@ -1021,3 +1021,39 @@ This example adds a new menu item but only if the signed in user is ``"root"``:
]
Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`config_base_url` setting into account.
.. _plugin_hook_table_actions:
table_actions(datasette, actor, database, table)
------------------------------------------------
``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>`.
``database`` - string
The name of the database.
``table`` - string
The name of the table.
This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items.
It can alternatively return an ``async def`` awaitable function which returns a list of menu items.
This example adds a new table action if the signed in user is ``"root"``:
.. code-block:: python
from datasette import hookimpl
@hookimpl
def table_actions(datasette, actor):
if actor and actor.get("id") == "root":
return [{
"href": datasette.urls.path("/-/edit-schema/{}/{}".format(database, table)),
"label": "Edit schema for this table",
}]

Wyświetl plik

@ -52,6 +52,7 @@ EXPECTED_PLUGINS = [
"register_routes",
"render_cell",
"startup",
"table_actions",
],
},
{
@ -69,6 +70,7 @@ EXPECTED_PLUGINS = [
"permission_allowed",
"render_cell",
"startup",
"table_actions",
],
},
{

Wyświetl plik

@ -296,3 +296,15 @@ def forbidden(datasette, request, message):
def menu_links(datasette, actor):
if actor:
return [{"href": datasette.urls.instance(), "label": "Hello"}]
@hookimpl
def table_actions(datasette, database, table, actor):
if actor:
return [
{
"href": datasette.urls.instance(),
"label": "Database: {}".format(database),
},
{"href": datasette.urls.instance(), "label": "Table: {}".format(table)},
]

Wyświetl plik

@ -155,3 +155,12 @@ def menu_links(datasette, actor):
return [{"href": datasette.urls.instance(), "label": "Hello 2"}]
return inner
@hookimpl
def table_actions(datasette, database, table, actor):
async def inner():
if actor:
return [{"href": datasette.urls.instance(), "label": "From async"}]
return inner

Wyświetl plik

@ -782,3 +782,22 @@ def test_hook_menu_links(app_client):
{"label": "Hello", "href": "/"},
{"label": "Hello 2", "href": "/"},
]
def test_hook_table_actions(app_client):
def get_table_actions_links(html):
soup = Soup(html, "html.parser")
details = soup.find("details", {"class": "table-menu-links"})
if details is None:
return []
return [{"label": a.text, "href": a["href"]} for a in details.select("a")]
response = app_client.get("/fixtures/facetable")
assert get_table_actions_links(response.text) == []
response_2 = app_client.get("/fixtures/facetable?_bot=1")
assert get_table_actions_links(response_2.text) == [
{"label": "From async", "href": "/"},
{"label": "Database: fixtures", "href": "/"},
{"label": "Table: facetable", "href": "/"},
]