From 9f3f4b6f629769fc7c23e73c6e85cccb4e3774cf Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 25 Jul 2026 01:32:25 +0000 Subject: [PATCH] Add a "Clear all" button to the Layout tab's widget canvas 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. --- server/.claude/skills/run-server/driver.py | 7 +++ server/app/routers/api_widgets.py | 13 +++++ server/app/static/frame_layout.js | 18 +++++++ server/app/static/theme.css | 10 +++- server/app/templates/frame_layout.html | 5 +- server/tests/test_widget_placement.py | 60 +++++++++++++++++++++- 6 files changed, 110 insertions(+), 3 deletions(-) diff --git a/server/.claude/skills/run-server/driver.py b/server/.claude/skills/run-server/driver.py index b45ba9d..0aeadb9 100644 --- a/server/.claude/skills/run-server/driver.py +++ b/server/.claude/skills/run-server/driver.py @@ -48,6 +48,13 @@ page = browser.new_page(viewport={"width": 1280, "height": 900}) console_errors: list[str] = [] page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) page.on("pageerror", lambda exc: console_errors.append(str(exc))) +# Playwright auto-DISMISSES native confirm()/alert() dialogs by default +# (returns false) -- several destructive actions in this app (remove +# widget, clear all widgets) gate on `confirm()`, so without this a +# `click` on one of those buttons would silently no-op. Auto-accept +# instead, since a driver testing a "yes, do the destructive thing" +# flow needs the confirm to actually go through. +page.on("dialog", lambda dialog: dialog.accept()) def cmd_nav(arg): diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 1ff548d..8a179c7 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -222,6 +222,19 @@ def api_widget_delete( return {"status": "deleted"} +@router.delete("/api/frames/{frame_id}/widgets") +def api_widgets_clear(frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)): + """The Layout tab's "Clear all" button -- same per-widget cascade as + api_widget_delete, just every widget on this frame in one locked + transaction instead of one request per widget.""" + with frame_locked(db, frame.id): + widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() + for widget in widgets: + db.delete(widget) + db.commit() + return {"status": "cleared", "count": len(widgets)} + + # --- Per-widget-type config save (the gear-icon dialog's Save button) -------- @router.post("/api/frames/{frame_id}/widgets/{widget_id}/config") diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index a836f56..fe15fc6 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -160,6 +160,23 @@ async function removeWidget(id) { } } +async function clearAllWidgets() { + const count = gridState ? gridState.widgets.length : 0; + if (!count) return; + const noun = count === 1 ? 'widget' : 'widgets'; + if (!confirm(`Remove all ${count} ${noun} from this frame and start over? Their settings will be lost.`)) return; + try { + const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'DELETE' }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Cleared.'); + } catch (e) { + showStatus(false, e.message); + } finally { + loadWidgets(); + } +} +document.getElementById('clear-all-widgets').addEventListener('click', clearAllWidgets); + async function addWidget(widgetType) { try { const resp = await fetch(`${window.FRAME_API}/widgets`, { @@ -180,6 +197,7 @@ function renderCanvas() { const canvas = document.getElementById('widget-canvas'); canvas.innerHTML = ''; document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : ''; + document.getElementById('clear-all-widgets').disabled = !gridState.widgets.length; for (const widget of gridState.widgets) { const box = document.createElement('div'); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 4741103..3c3a24e 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -167,6 +167,8 @@ h2.card-title, summary.card-title { summary.card-title { cursor: pointer; margin-bottom: 0; } details.card[open] summary.card-title { margin-bottom: 14px; } details.card .sub { margin-top: 8px; } +.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; } +.card-title-row .card-title { margin-bottom: 0; } .palette-table-wrap { overflow-x: auto; margin-top: 14px; } .palette-table { width: 100%; border-collapse: collapse; font-size: 13px; } @@ -312,6 +314,7 @@ button { } button:hover { background: var(--accent-hover); } button:active { transform: translateY(1px); } +button:disabled { opacity: 0.5; cursor: default; pointer-events: none; } button.btn-inline { margin-top: 0; padding: 3px 10px; @@ -514,7 +517,12 @@ code { } .main-col, .side-col { display: flex; flex-direction: column; } @media (max-width: 860px) { - .layout { grid-template-columns: 1fr; } + /* minmax(0, 1fr), not bare 1fr -- bare 1fr is minmax(auto, 1fr), whose + "auto" minimum lets the track (and everything in it) grow to fit its + widest descendant's min-content size instead of actually shrinking + to the viewport, causing page-wide horizontal overflow/scroll on + narrow screens. Same fix the desktop rule above already applies. */ + .layout { grid-template-columns: minmax(0, 1fr); } } /* ------------------------------------------------------------------ */ /* App shell: left sidebar (frame list + account nav) + main content. */ diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index 132a410..5414b85 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -15,7 +15,10 @@
-

Widgets

+
+

Widgets

+ +

Drag a widget to move it, drag its bottom-right corner to resize it -- like arranging widgets on a phone's home screen. Widgets can't overlap. Click a widget's gear icon for its own diff --git a/server/tests/test_widget_placement.py b/server/tests/test_widget_placement.py index 1b36162..2f1cde0 100644 --- a/server/tests/test_widget_placement.py +++ b/server/tests/test_widget_placement.py @@ -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