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.
This commit is contained in:
@@ -48,6 +48,13 @@ page = browser.new_page(viewport={"width": 1280, "height": 900})
|
|||||||
console_errors: list[str] = []
|
console_errors: list[str] = []
|
||||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
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)))
|
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):
|
def cmd_nav(arg):
|
||||||
|
|||||||
@@ -222,6 +222,19 @@ def api_widget_delete(
|
|||||||
return {"status": "deleted"}
|
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) --------
|
# --- Per-widget-type config save (the gear-icon dialog's Save button) --------
|
||||||
|
|
||||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
|
||||||
|
|||||||
@@ -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) {
|
async function addWidget(widgetType) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/widgets`, {
|
const resp = await fetch(`${window.FRAME_API}/widgets`, {
|
||||||
@@ -180,6 +197,7 @@ function renderCanvas() {
|
|||||||
const canvas = document.getElementById('widget-canvas');
|
const canvas = document.getElementById('widget-canvas');
|
||||||
canvas.innerHTML = '';
|
canvas.innerHTML = '';
|
||||||
document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : '';
|
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) {
|
for (const widget of gridState.widgets) {
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ h2.card-title, summary.card-title {
|
|||||||
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||||
details.card .sub { margin-top: 8px; }
|
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-wrap { overflow-x: auto; margin-top: 14px; }
|
||||||
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
@@ -312,6 +314,7 @@ button {
|
|||||||
}
|
}
|
||||||
button:hover { background: var(--accent-hover); }
|
button:hover { background: var(--accent-hover); }
|
||||||
button:active { transform: translateY(1px); }
|
button:active { transform: translateY(1px); }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
|
||||||
button.btn-inline {
|
button.btn-inline {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
@@ -514,7 +517,12 @@ code {
|
|||||||
}
|
}
|
||||||
.main-col, .side-col { display: flex; flex-direction: column; }
|
.main-col, .side-col { display: flex; flex-direction: column; }
|
||||||
@media (max-width: 860px) {
|
@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. */
|
/* App shell: left sidebar (frame list + account nav) + main content. */
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="main-col">
|
<div class="main-col">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card-title">Widgets</h2>
|
<div class="card-title-row">
|
||||||
|
<h2 class="card-title">Widgets</h2>
|
||||||
|
<button type="button" id="clear-all-widgets" class="secondary btn-inline">Clear all</button>
|
||||||
|
</div>
|
||||||
<p class="sub">Drag a widget to move it, drag its bottom-right corner
|
<p class="sub">Drag a widget to move it, drag its bottom-right corner
|
||||||
to resize it -- like arranging widgets on a phone's home screen.
|
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
|
Widgets can't overlap. Click a widget's gear icon for its own
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from app.models import Frame, FrameButtonAction, Widget
|
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:
|
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
|
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):
|
def test_widgets_scoped_to_their_own_frame(client, db_session):
|
||||||
"""A widget id from a different frame must 404, not silently operate
|
"""A widget id from a different frame must 404, not silently operate
|
||||||
cross-frame -- same posture as photo_widget_config_or_404 and every
|
cross-frame -- same posture as photo_widget_config_or_404 and every
|
||||||
|
|||||||
Reference in New Issue
Block a user