mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 09:14:34 +02:00
Refactor edit/delete tools to work on row pages too
Refs https://github.com/simonw/datasette/pull/2781#issuecomment-4703303274 Refs #2780
This commit is contained in:
parent
4ce2888e79
commit
82c95a1a13
8 changed files with 2261 additions and 1948 deletions
|
|
@ -2320,6 +2320,7 @@ class Datasette:
|
|||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"app_css_hash": self.app_css_hash(),
|
||||
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
|
||||
"table_js_hash": self.static_hash("table.js"),
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
|
|
|
|||
1972
datasette/static/edit-tools.js
Normal file
1972
datasette/static/edit-tools.js
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% if row_mutation_ui %}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from datasette.utils import (
|
|||
await_me_maybe,
|
||||
CustomRow,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
to_css_class,
|
||||
escape_sqlite,
|
||||
)
|
||||
|
|
@ -15,7 +16,11 @@ import json
|
|||
import markupsafe
|
||||
import sqlite_utils
|
||||
from datasette.extras import extra_names_from_request
|
||||
from .table import display_columns_and_rows
|
||||
from .table import (
|
||||
display_columns_and_rows,
|
||||
_table_page_data,
|
||||
row_label_from_label_column,
|
||||
)
|
||||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||
|
||||
|
||||
|
|
@ -49,6 +54,7 @@ class RowView(DataView):
|
|||
pks = resolved.pks
|
||||
|
||||
async def template_data():
|
||||
is_table = await db.table_exists(table)
|
||||
# Reorder columns so primary keys come first
|
||||
pk_set = set(pks)
|
||||
pk_cols = [d for d in results.description if d[0] in pk_set]
|
||||
|
|
@ -117,7 +123,60 @@ class RowView(DataView):
|
|||
"<strong>{}</strong>".format(cell["value"])
|
||||
)
|
||||
|
||||
label_column = await db.label_column_for_table(table) if is_table else None
|
||||
row_path = path_from_row_pks(rows[0], pks, False)
|
||||
pk_path = path_from_row_pks(rows[0], pks, False, False)
|
||||
row_label = row_label_from_label_column(expanded_rows[0], label_column)
|
||||
for display_row in display_rows:
|
||||
display_row.pk_path = pk_path
|
||||
display_row.row_path = row_path
|
||||
display_row.row_label = row_label
|
||||
|
||||
row_action_label = pk_path
|
||||
if row_label and row_label != pk_path:
|
||||
row_action_label = "{} {}".format(pk_path, row_label)
|
||||
|
||||
row_action_permissions = {}
|
||||
if is_table and db.is_mutable:
|
||||
row_action_permissions = await self.ds.allowed_many(
|
||||
actions=["update-row", "delete-row"],
|
||||
resource=TableResource(database=database, table=table),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
row_actions = []
|
||||
if row_action_permissions.get("update-row"):
|
||||
attrs = {
|
||||
"aria-label": "Edit row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "edit",
|
||||
}
|
||||
if row_label:
|
||||
attrs["data-row-label"] = row_label
|
||||
row_actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Edit row",
|
||||
"description": "Open a dialog to edit this row.",
|
||||
"attrs": attrs,
|
||||
}
|
||||
)
|
||||
if row_action_permissions.get("delete-row"):
|
||||
attrs = {
|
||||
"aria-label": "Delete row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "delete",
|
||||
}
|
||||
if row_label:
|
||||
attrs["data-row-label"] = row_label
|
||||
row_actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Delete row",
|
||||
"description": "Open a confirmation dialog to delete this row.",
|
||||
"attrs": attrs,
|
||||
}
|
||||
)
|
||||
for hook in pm.hook.row_actions(
|
||||
datasette=self.ds,
|
||||
actor=request.actor,
|
||||
|
|
@ -144,6 +203,16 @@ class RowView(DataView):
|
|||
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
||||
"_table.html",
|
||||
],
|
||||
"row_mutation_ui": any(row_action_permissions.values()),
|
||||
"table_page_data": await _table_page_data(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
database,
|
||||
table,
|
||||
not is_table,
|
||||
None,
|
||||
),
|
||||
"row_actions": row_actions,
|
||||
"top_row": make_slot_function(
|
||||
"top_row",
|
||||
|
|
@ -249,6 +318,27 @@ class RowError(Exception):
|
|||
self.error = error
|
||||
|
||||
|
||||
ROW_FLASH_LABEL_MAX_LENGTH = 80
|
||||
|
||||
|
||||
def _truncated_row_flash_label(label):
|
||||
label = " ".join(str(label).split())
|
||||
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
|
||||
return label
|
||||
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
|
||||
|
||||
|
||||
async def _row_flash_message(db, action, resolved, row=None):
|
||||
pk_label = ", ".join(resolved.pk_values)
|
||||
label_column = await db.label_column_for_table(resolved.table)
|
||||
label = row_label_from_label_column(row or resolved.row, label_column)
|
||||
if label:
|
||||
label = _truncated_row_flash_label(label)
|
||||
if label and label != pk_label:
|
||||
return "{} row {} ({})".format(action, pk_label, label)
|
||||
return "{} row {}".format(action, pk_label)
|
||||
|
||||
|
||||
async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
||||
|
||||
|
|
@ -303,6 +393,15 @@ class RowDeleteView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
if request.args.get("_redirect_to_table"):
|
||||
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
|
||||
self.ds.add_message(
|
||||
request,
|
||||
await _row_flash_message(resolved.db, "Deleted", resolved),
|
||||
self.ds.INFO,
|
||||
)
|
||||
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
|
||||
|
||||
return Response.json({"ok": True}, status=200)
|
||||
|
||||
|
||||
|
|
@ -364,11 +463,13 @@ class RowUpdateView(BaseView):
|
|||
return _error([str(e)], 400)
|
||||
|
||||
result = {"ok": True}
|
||||
returned_row = None
|
||||
if data.get("return"):
|
||||
results = await resolved.db.execute(
|
||||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
result["row"] = results.dicts()[0]
|
||||
returned_row = results.dicts()[0]
|
||||
result["row"] = returned_row
|
||||
|
||||
await self.ds.track_event(
|
||||
UpdateRowEvent(
|
||||
|
|
@ -379,4 +480,19 @@ class RowUpdateView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
if request.args.get("_message"):
|
||||
message_row = returned_row
|
||||
if message_row is None:
|
||||
results = await resolved.db.execute(
|
||||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
message_row = results.first()
|
||||
self.ds.add_message(
|
||||
request,
|
||||
await _row_flash_message(
|
||||
resolved.db, "Updated", resolved, row=message_row
|
||||
),
|
||||
self.ds.INFO,
|
||||
)
|
||||
|
||||
return Response.json(result, status=200)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ def test_table_plugin_column_field_api():
|
|||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const tableJs = __TABLE_JS__;
|
||||
const editToolsJs = __EDIT_TOOLS_JS__;
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, options) {
|
||||
|
|
@ -180,8 +180,8 @@ def test_table_plugin_column_field_api():
|
|||
},
|
||||
};
|
||||
|
||||
vm.runInThisContext(fs.readFileSync(tableJs, "utf8"), {
|
||||
filename: "table.js",
|
||||
vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), {
|
||||
filename: "edit-tools.js",
|
||||
});
|
||||
|
||||
const context = columnFormControlContext(
|
||||
|
|
@ -452,7 +452,7 @@ def test_table_plugin_column_field_api():
|
|||
}
|
||||
|
||||
process.stdout.write("ok");
|
||||
""").replace("__TABLE_JS__", json.dumps(str(STATIC_DIR / "table.js")))
|
||||
""").replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js")))
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
text=True,
|
||||
|
|
@ -467,7 +467,7 @@ def test_builtin_json_column_field_validation():
|
|||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const tableJs = __TABLE_JS__;
|
||||
const editToolsJs = __EDIT_TOOLS_JS__;
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, options) {
|
||||
|
|
@ -547,8 +547,8 @@ def test_builtin_json_column_field_validation():
|
|||
},
|
||||
};
|
||||
|
||||
vm.runInThisContext(fs.readFileSync(tableJs, "utf8"), {
|
||||
filename: "table.js",
|
||||
vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), {
|
||||
filename: "edit-tools.js",
|
||||
});
|
||||
|
||||
const plugins = [];
|
||||
|
|
@ -648,7 +648,7 @@ def test_builtin_json_column_field_validation():
|
|||
}
|
||||
|
||||
process.stdout.write("ok");
|
||||
""").replace("__TABLE_JS__", json.dumps(str(STATIC_DIR / "table.js")))
|
||||
""").replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js")))
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
text=True,
|
||||
|
|
|
|||
|
|
@ -1151,6 +1151,160 @@ async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
|
|||
assert rows[0]["data-row-label"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_page_edit_delete_action_menu_buttons():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"update-row": {"id": "root"},
|
||||
"delete-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_page_edit_delete_actions"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text, score integer);
|
||||
insert into items (id, name, score) values (1, 'One', 5);
|
||||
""")
|
||||
response = await ds.client.get("/data/items/1", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
assert table_data_from_soup(soup) == {
|
||||
"database": "data",
|
||||
"table": "items",
|
||||
"tableUrl": "/data/items",
|
||||
}
|
||||
script_srcs = [script.get("src") or "" for script in soup.find_all("script")]
|
||||
assert any("edit-tools.js" in src for src in script_srcs)
|
||||
assert not any("table.js" in src for src in script_srcs)
|
||||
|
||||
row = soup.select_one("table.rows-and-columns tbody tr")
|
||||
assert row["data-row"] == "1"
|
||||
assert row["data-row-label"] == "One"
|
||||
|
||||
edit_button = soup.select_one(
|
||||
'details.actions-menu-links button.action-menu-button[data-row-action="edit"]'
|
||||
)
|
||||
assert edit_button is not None
|
||||
assert edit_button["aria-label"] == "Edit row 1 One"
|
||||
assert edit_button["data-row"] == "1"
|
||||
assert edit_button["data-row-label"] == "One"
|
||||
assert edit_button["role"] == "menuitem"
|
||||
assert edit_button.find("span", class_="dropdown-description").text.strip() == (
|
||||
"Open a dialog to edit this row."
|
||||
)
|
||||
edit_button.find("span").extract()
|
||||
assert edit_button.text.strip() == "Edit row"
|
||||
|
||||
delete_button = soup.select_one(
|
||||
'details.actions-menu-links button.action-menu-button[data-row-action="delete"]'
|
||||
)
|
||||
assert delete_button is not None
|
||||
assert delete_button["aria-label"] == "Delete row 1 One"
|
||||
assert delete_button["data-row"] == "1"
|
||||
assert delete_button["data-row-label"] == "One"
|
||||
assert delete_button["role"] == "menuitem"
|
||||
assert delete_button.find(
|
||||
"span", class_="dropdown-description"
|
||||
).text.strip() == ("Open a confirmation dialog to delete this row.")
|
||||
delete_button.find("span").extract()
|
||||
assert delete_button.text.strip() == "Delete row"
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_delete_redirect_to_table_sets_message():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"delete-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_delete_redirect"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text);
|
||||
insert into items (id, name) values (1, 'One');
|
||||
""")
|
||||
response = await ds.client.post(
|
||||
"/data/items/1/-/delete?_redirect_to_table=1", actor={"id": "root"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "redirect": "/data/items"}
|
||||
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
|
||||
["Deleted row 1 (One)", ds.INFO]
|
||||
]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_update_sets_message():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"update-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_update_message"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text);
|
||||
insert into items (id, name) values (1, 'One');
|
||||
""")
|
||||
long_name = "Two " + ("long label " * 12)
|
||||
truncated_name = long_name[:79] + "\u2026"
|
||||
response = await ds.client.post(
|
||||
"/data/items/1/-/update?_message=1",
|
||||
actor={"id": "root"},
|
||||
json={"update": {"name": long_name}, "return": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["row"]["name"] == long_name
|
||||
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
|
||||
["Updated row 1 ({})".format(truncated_name), ds.INFO]
|
||||
]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
def test_table_data_uses_base_url(app_client_base_url_prefix):
|
||||
response = app_client_base_url_prefix.get("/prefix/fixtures/simple_primary_key")
|
||||
assert response.status_code == 200
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue