Add a "Clear all" button to the Layout tab's widget canvas
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 54s

One request (DELETE /api/frames/{id}/widgets) removes every widget on
the frame in a single locked transaction, cascading their configs and
button-action bindings the same way single-widget delete already does.
Gated behind confirm() like the existing per-widget remove button, and
disabled when there's nothing to clear. Covered by the same owner/
unrelated-user/linked-but-not-controlling permission shape used
elsewhere (test_widget_placement.py).

Also fixes a real, pre-existing mobile bug this surfaced: the Layout
page's .layout grid used a bare `1fr` track on the <860px breakpoint
instead of `minmax(0, 1fr)` like the desktop rule already does, so a
wide enough descendant (previously nothing hit this; the new title-row
button did) would force the whole page into horizontal scroll on phone
widths. Verified before/after with the run-server driver's new
`viewport` command.
This commit is contained in:
Thomas Faour
2026-07-25 01:32:25 +00:00
parent 0e35735a2a
commit 9f3f4b6f62
6 changed files with 110 additions and 3 deletions
+59 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
from app.models import Frame, FrameButtonAction, Widget
from .conftest import csrf_headers
from .conftest import csrf_headers, link_user, login, make_user
def _widget_id(db_session, widget_type="photos") -> int:
@@ -162,6 +162,64 @@ def test_delete_unknown_widget_404s(client, db_session):
assert resp.status_code == 404
def test_clear_all_removes_every_widget_and_cascades_button_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
assert create_resp.status_code == 200, create_resp.text
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=photos_id, action="advance", sort_order=0))
db_session.commit()
resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json() == {"status": "cleared", "count": 2}
assert db_session.query(Widget).filter_by(frame_id=1).all() == []
assert db_session.query(FrameButtonAction).filter_by(frame_id=1).all() == []
list_resp = client.get("/api/frames/1/widgets")
assert list_resp.json()["widgets"] == []
def test_clear_all_on_an_already_empty_frame_is_a_no_op(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
assert resp.status_code == 200
assert resp.json() == {"status": "cleared", "count": 0}
def test_clear_all_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.cookies.clear()
login(client, "mallory")
resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
assert resp.status_code == 404
# Nothing was touched -- alice's widget is still there.
assert db_session.query(Widget).filter_by(frame_id=1).count() == 1
def test_clear_all_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
# alice (via /setup) already holds control -- bob is linked (can view)
# but not the controller.
client.cookies.clear()
login(client, "bob")
resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
assert db_session.query(Widget).filter_by(frame_id=1).count() == 1
def test_widgets_scoped_to_their_own_frame(client, db_session):
"""A widget id from a different frame must 404, not silently operate
cross-frame -- same posture as photo_widget_config_or_404 and every