mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
parent
8a4639bc43
commit
2f7731e9e5
10 changed files with 166 additions and 14 deletions
|
|
@ -102,3 +102,8 @@ def forbidden(datasette, request, message):
|
||||||
@hookspec
|
@hookspec
|
||||||
def menu_links(datasette, actor):
|
def menu_links(datasette, actor):
|
||||||
"Links for the navigation menu"
|
"Links for the navigation menu"
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def table_actions(datasette, actor, database, table):
|
||||||
|
"Links for the table actions menu"
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ h6,
|
||||||
.header3,
|
.header3,
|
||||||
.header4,
|
.header4,
|
||||||
.header5,
|
.header5,
|
||||||
.header6 {
|
.header6 {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -162,6 +162,29 @@ h6,
|
||||||
text-decoration: underline;
|
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,
|
div,
|
||||||
section,
|
section,
|
||||||
article,
|
article,
|
||||||
|
|
@ -335,6 +358,15 @@ details .nav-menu-inner {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table actions menu */
|
||||||
|
.table-menu-links {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.table-menu-links .dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rem;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Components ============================================================== */
|
/* Components ============================================================== */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,19 +60,19 @@
|
||||||
<footer class="ft">{% block footer %}{% include "_footer.html" %}{% endblock %}</footer>
|
<footer class="ft">{% block footer %}{% include "_footer.html" %}{% endblock %}</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var menuDetails = document.querySelector('.nav-menu');
|
|
||||||
document.body.addEventListener('click', (ev) => {
|
document.body.addEventListener('click', (ev) => {
|
||||||
/* was this click outside the menu? */
|
/* Close any open details elements that this click is outside of */
|
||||||
if (menuDetails.getAttribute('open') !== "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var target = ev.target;
|
var target = ev.target;
|
||||||
while (target && target != menuDetails) {
|
var detailsClickedWithin = null;
|
||||||
|
while (target && target.tagName != 'DETAILS') {
|
||||||
target = target.parentNode;
|
target = target.parentNode;
|
||||||
}
|
}
|
||||||
if (!target) {
|
if (target && target.tagName == 'DETAILS') {
|
||||||
menuDetails.removeAttribute('open');
|
detailsClickedWithin = target;
|
||||||
}
|
}
|
||||||
|
Array.from(document.getElementsByTagName('details')).filter(
|
||||||
|
(details) => details.open && details != detailsClickedWithin
|
||||||
|
).forEach(details => details.open = false);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% for body_script in body_scripts %}
|
{% for body_script in body_scripts %}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,29 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||||
<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>
|
<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 %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import jinja2
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
await_me_maybe,
|
||||||
CustomRow,
|
CustomRow,
|
||||||
MultiParams,
|
MultiParams,
|
||||||
append_querystring,
|
append_querystring,
|
||||||
|
|
@ -840,7 +841,21 @@ class TableView(RowTableShared):
|
||||||
elif use_rowid:
|
elif use_rowid:
|
||||||
sort = "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 {
|
return {
|
||||||
|
"table_actions": table_actions,
|
||||||
"supports_search": bool(fts_table),
|
"supports_search": bool(fts_table),
|
||||||
"search": search or "",
|
"search": search or "",
|
||||||
"use_rowid": use_rowid,
|
"use_rowid": use_rowid,
|
||||||
|
|
@ -959,6 +974,7 @@ class RowView(RowTableShared):
|
||||||
)
|
)
|
||||||
for column in display_columns:
|
for column in display_columns:
|
||||||
column["sortable"] = False
|
column["sortable"] = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"foreign_key_tables": await self.foreign_key_tables(
|
"foreign_key_tables": await self.foreign_key_tables(
|
||||||
database, table, pk_values
|
database, table, pk_values
|
||||||
|
|
|
||||||
|
|
@ -998,10 +998,10 @@ menu_links(datasette, actor)
|
||||||
``datasette`` - :ref:`internals_datasette`
|
``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.
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
``request`` - object
|
``actor`` - dictionary or None
|
||||||
The current HTTP :ref:`internals_request`.
|
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.
|
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.
|
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",
|
||||||
|
}]
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ EXPECTED_PLUGINS = [
|
||||||
"register_routes",
|
"register_routes",
|
||||||
"render_cell",
|
"render_cell",
|
||||||
"startup",
|
"startup",
|
||||||
|
"table_actions",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -69,6 +70,7 @@ EXPECTED_PLUGINS = [
|
||||||
"permission_allowed",
|
"permission_allowed",
|
||||||
"render_cell",
|
"render_cell",
|
||||||
"startup",
|
"startup",
|
||||||
|
"table_actions",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -296,3 +296,15 @@ def forbidden(datasette, request, message):
|
||||||
def menu_links(datasette, actor):
|
def menu_links(datasette, actor):
|
||||||
if actor:
|
if actor:
|
||||||
return [{"href": datasette.urls.instance(), "label": "Hello"}]
|
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)},
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -155,3 +155,12 @@ def menu_links(datasette, actor):
|
||||||
return [{"href": datasette.urls.instance(), "label": "Hello 2"}]
|
return [{"href": datasette.urls.instance(), "label": "Hello 2"}]
|
||||||
|
|
||||||
return inner
|
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
|
||||||
|
|
|
||||||
|
|
@ -782,3 +782,22 @@ def test_hook_menu_links(app_client):
|
||||||
{"label": "Hello", "href": "/"},
|
{"label": "Hello", "href": "/"},
|
||||||
{"label": "Hello 2", "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": "/"},
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue