mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 05:26:59 +02:00
Support for <button> items in action menus
Closes #2782 Animated demo: https://github.com/simonw/datasette/pull/2781#issuecomment-4703303274
This commit is contained in:
parent
3f7d389caf
commit
4ce2888e79
7 changed files with 179 additions and 20 deletions
|
|
@ -159,32 +159,32 @@ def jump_items_sql(datasette, actor, request):
|
|||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
"""Items for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Links for the table actions menu"""
|
||||
"""Items for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Links for the view actions menu"""
|
||||
"""Items for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Links for the query and stored query actions menu"""
|
||||
"""Items for the query and stored query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Links for the database actions menu"""
|
||||
"""Items for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Links for the homepage actions menu"""
|
||||
"""Items for the homepage actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
|
|||
|
|
@ -2061,18 +2061,32 @@ svg.dropdown-menu-icon {
|
|||
.dropdown-menu a:link,
|
||||
.dropdown-menu a:visited,
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus
|
||||
.dropdown-menu a:active {
|
||||
.dropdown-menu a:focus,
|
||||
.dropdown-menu a:active,
|
||||
.dropdown-menu button.action-menu-button {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 8px 2px 8px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropdown-menu a:hover {
|
||||
.dropdown-menu button.action-menu-button {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu button.action-menu-button:hover,
|
||||
.dropdown-menu button.action-menu-button:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
.dropdown-menu .dropdown-description {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
|
|
|
|||
|
|
@ -15,14 +15,22 @@
|
|||
<div class="hook"></div>
|
||||
<ul role="menu">
|
||||
{% for link in action_links %}
|
||||
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
<li role="none">
|
||||
{% if link.get("type") == "button" %}
|
||||
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</button>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -288,6 +288,14 @@ element that wraps the HTML for that row. Datasette uses this attribute to find
|
|||
the element to remove after a delete, or replace after an edit. Any edit or
|
||||
delete controls should be rendered inside that same element.
|
||||
|
||||
The ``_action_menu.html`` template renders the action menus used by database,
|
||||
table, query and row pages. Plugin-provided actions can be link dictionaries
|
||||
with ``href`` and ``label`` keys, or button dictionaries using ``{"type":
|
||||
"button", "label": "...", "attrs": {...}}`` for JavaScript-backed interactions.
|
||||
Both shapes can include an optional ``description`` key. Custom
|
||||
``_action_menu.html`` templates should preserve support for both link and button
|
||||
action items.
|
||||
|
||||
.. _custom_pages:
|
||||
|
||||
Custom pages
|
||||
|
|
|
|||
|
|
@ -1909,7 +1909,80 @@ Action hooks
|
|||
|
||||
Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing.
|
||||
|
||||
Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail.
|
||||
Each of these hooks should return a list of menu items, with optional ``"description": "..."`` keys describing each action in more detail.
|
||||
|
||||
The most common action item is a link to another page:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
"href": datasette.urls.path("/-/custom-action"),
|
||||
"label": "Custom action",
|
||||
"description": "Run this action on a separate page.",
|
||||
}
|
||||
|
||||
Plugins can also return button actions for JavaScript-backed interactions:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Open custom dialog",
|
||||
"description": "Show a dialog without leaving this page.",
|
||||
"attrs": {
|
||||
"aria-label": "Open custom dialog",
|
||||
"data-plugin-action": "open-custom-dialog",
|
||||
},
|
||||
}
|
||||
|
||||
These are rendered as ``<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1">``. The optional ``attrs`` dictionary is added to the button, and is useful for ``data-*`` attributes that your plugin's JavaScript can use to attach event handlers.
|
||||
|
||||
Here is a minimal plugin example that adds a button to a table page and loads JavaScript to handle clicks on that button:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl
|
||||
def table_actions(datasette, database, table):
|
||||
return [
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Show table name",
|
||||
"description": "Open a JavaScript-powered plugin action.",
|
||||
"attrs": {
|
||||
"aria-label": "Show table name",
|
||||
"data-plugin-action": "show-table-name",
|
||||
"data-database": database,
|
||||
"data-table": table,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@hookimpl
|
||||
def extra_js_urls(datasette):
|
||||
return [
|
||||
datasette.urls.static_plugins(
|
||||
"datasette_show_table",
|
||||
"show-table.js",
|
||||
)
|
||||
]
|
||||
|
||||
The ``static/show-table.js`` file in that plugin could look like this:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(
|
||||
"button[data-plugin-action='show-table-name']"
|
||||
);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
alert(`${button.dataset.database}.${button.dataset.table}`);
|
||||
});
|
||||
|
||||
They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items.
|
||||
|
||||
|
|
|
|||
|
|
@ -357,15 +357,30 @@ def menu_links(datasette, actor, request):
|
|||
|
||||
|
||||
@hookimpl
|
||||
def table_actions(datasette, database, table, actor):
|
||||
def table_actions(datasette, database, table, actor, request):
|
||||
if actor:
|
||||
return [
|
||||
actions = [
|
||||
{
|
||||
"href": datasette.urls.instance(),
|
||||
"label": f"Database: {database}",
|
||||
},
|
||||
{"href": datasette.urls.instance(), "label": f"Table: {table}"},
|
||||
]
|
||||
if request.args.get("_button"):
|
||||
actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Plugin button",
|
||||
"description": "Runs JavaScript from a plugin",
|
||||
"attrs": {
|
||||
"aria-label": "Plugin button for {}".format(table),
|
||||
"data-plugin-action": "plugin-button",
|
||||
"data-database": database,
|
||||
"data-table": table,
|
||||
},
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
|
|||
|
|
@ -1062,6 +1062,7 @@ async def test_hook_menu_links(ds_client):
|
|||
async def test_hook_table_actions(ds_client):
|
||||
response = await ds_client.get("/fixtures/facetable")
|
||||
assert get_actions_links(response.text) == []
|
||||
assert get_actions_buttons(response.text) == []
|
||||
response_2 = await ds_client.get("/fixtures/facetable?_bot=1&_hello=BOB")
|
||||
assert ">Table actions<" in response_2.text
|
||||
assert sorted(
|
||||
|
|
@ -1071,6 +1072,23 @@ async def test_hook_table_actions(ds_client):
|
|||
{"label": "From async BOB", "href": "/", "description": None},
|
||||
{"label": "Table: facetable", "href": "/", "description": None},
|
||||
]
|
||||
response_3 = await ds_client.get("/fixtures/facetable?_bot=1&_button=1")
|
||||
assert get_actions_buttons(response_3.text) == [
|
||||
{
|
||||
"label": "Plugin button",
|
||||
"description": "Runs JavaScript from a plugin",
|
||||
"attrs": {
|
||||
"aria-label": "Plugin button for facetable",
|
||||
"class": ["button-as-link", "action-menu-button"],
|
||||
"data-database": "fixtures",
|
||||
"data-plugin-action": "plugin-button",
|
||||
"data-table": "facetable",
|
||||
"role": "menuitem",
|
||||
"tabindex": "-1",
|
||||
"type": "button",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1098,15 +1116,38 @@ def get_actions_links(html):
|
|||
links = []
|
||||
for a_el in details.select("a"):
|
||||
description = None
|
||||
if a_el.find("p") is not None:
|
||||
description = a_el.find("p").text.strip()
|
||||
a_el.find("p").extract()
|
||||
description_el = a_el.find(class_="dropdown-description")
|
||||
if description_el is not None:
|
||||
description = description_el.text.strip()
|
||||
description_el.extract()
|
||||
label = a_el.text.strip()
|
||||
href = a_el["href"]
|
||||
links.append({"label": label, "href": href, "description": description})
|
||||
return links
|
||||
|
||||
|
||||
def get_actions_buttons(html):
|
||||
soup = Soup(html, "html.parser")
|
||||
details = soup.find("details", {"class": "actions-menu-links"})
|
||||
if details is None:
|
||||
return []
|
||||
buttons = []
|
||||
for button_el in details.select("button.action-menu-button"):
|
||||
description = None
|
||||
description_el = button_el.find(class_="dropdown-description")
|
||||
if description_el is not None:
|
||||
description = description_el.text.strip()
|
||||
description_el.extract()
|
||||
buttons.append(
|
||||
{
|
||||
"label": button_el.text.strip(),
|
||||
"description": description,
|
||||
"attrs": dict(button_el.attrs),
|
||||
}
|
||||
)
|
||||
return buttons
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_url",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue