From 3fc999a69b0b473fbaded191a4543d0e303c7bb8 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 12:48:32 +0200 Subject: [PATCH] add a datastar action --- repub/pages/dashboard.py | 58 +++++++++++++++++++++++++++++++++++++++- repub/web.py | 30 ++++++++++++++++++++- tests/test_web.py | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/repub/pages/dashboard.py b/repub/pages/dashboard.py index 3748438..88d162e 100644 --- a/repub/pages/dashboard.py +++ b/repub/pages/dashboard.py @@ -87,7 +87,8 @@ def page_header() -> Renderable: class_="rounded-full border border-white/15 bg-white/5 px-4 py-2.5 text-sm font-semibold text-white hover:bg-white/10", )["Run scheduler health check"], ], - ] + ], + demo_action_panel(), ] @@ -121,6 +122,61 @@ def overview_section(*, active_jobs: str) -> Renderable: ] +def demo_action_panel() -> Renderable: + return h.div( + {"data-signals": "{decrementAmount: '1', decrementError: ''}"}, + id="demo-decrement-panel", + class_="mt-6 rounded-[1.75rem] border border-white/10 bg-white/5 p-5 ring-1 ring-white/10", + )[ + h.div(class_="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between")[ + h.div(class_="max-w-2xl")[ + h.p( + class_="text-xs font-semibold uppercase tracking-[0.22em] text-amber-300" + )["Demo action"], + h.h2(class_="mt-2 text-lg font-semibold text-white")[ + "Decrement active jobs" + ], + h.p(class_="mt-2 text-sm text-slate-300")[ + "Uses Datastar signals plus a server action. Enter an odd number and the server will validate it before mutating state." + ], + ], + h.div(class_="flex flex-col gap-3 sm:flex-row sm:items-end")[ + h.div(class_="min-w-40")[ + h.label( + for_="decrement-amount", + class_="block text-xs font-semibold uppercase tracking-[0.18em] text-slate-300", + )["Odd decrement"], + h.input( + { + "data-bind:decrement-amount": True, + "data-preserve-attr:value": True, + }, + id="decrement-amount", + name="decrement-amount", + type="number", + min="1", + step="1", + inputmode="numeric", + class_="mt-2 block w-full rounded-2xl border border-white/10 bg-slate-950/70 px-3.5 py-2.5 text-sm text-white shadow-sm placeholder:text-slate-500 focus:outline-hidden focus:ring-2 focus:ring-amber-400", + ), + ], + h.button( + {"data-on:click": "@post('/demo/decrement')"}, + type="button", + class_="cursor-pointer rounded-full bg-amber-400 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-sm hover:bg-amber-300", + )["Decrement"], + ], + ], + h.p( + { + "data-show": "$decrementError !== ''", + "data-text": "$decrementError", + }, + class_="mt-3 text-sm font-medium text-rose-300", + ), + ] + + def source_form_section() -> Renderable: return h.section( class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200" diff --git a/repub/web.py b/repub/web.py index 6291759..bc85141 100644 --- a/repub/web.py +++ b/repub/web.py @@ -7,7 +7,8 @@ from contextlib import suppress from typing import cast import htpy as h -from datastar_py.quart import DatastarResponse +from datastar_py import ServerSentEventGenerator as SSE +from datastar_py.quart import DatastarResponse, read_signals from datastar_py.sse import DatastarEvent from htpy import Renderable from quart import Quart, Response, request, url_for @@ -77,6 +78,16 @@ def create_app(*, enable_demo_refresh: bool = True) -> Quart: ) return DatastarResponse(_unsubscribe_on_close(queue, stream, app)) + @app.post("/demo/decrement") + async def demo_decrement() -> DatastarResponse: + amount, error = _validated_decrement_amount(await read_signals()) + if error is not None: + return DatastarResponse(SSE.patch_signals({"decrementError": error})) + + set_active_jobs(app, max(0, get_active_jobs(app) - amount)) + trigger_refresh(app) + return DatastarResponse(SSE.patch_signals({"decrementError": ""})) + return app @@ -115,3 +126,20 @@ async def _demo_refresh_loop(app: Quart) -> None: await asyncio.sleep(1) set_active_jobs(app, get_active_jobs(app) + 1) trigger_refresh(app) + + +def _validated_decrement_amount( + signals: dict[str, object] | None, +) -> tuple[int, str | None]: + raw_amount = ( + "" if signals is None else str(signals.get("decrementAmount", "")).strip() + ) + try: + amount = int(raw_amount) + except ValueError: + return 0, "Decrement amount must be an odd integer." + + if amount < 1 or amount % 2 == 0: + return 0, "Decrement amount must be an odd integer." + + return amount, None diff --git a/tests/test_web.py b/tests/test_web.py index 225bb8c..8e8ff8d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -134,5 +134,61 @@ def test_render_dashboard_uses_active_jobs_from_app_state() -> None: assert "27" in body assert "Temporary live demo counter for Datastar refresh testing" in body + assert "/demo/decrement" in body + assert "data-bind:decrement-amount" in body + + asyncio.run(run()) + + +def test_demo_decrement_action_decrements_active_jobs() -> None: + async def run() -> None: + app = create_app(enable_demo_refresh=False) + broker = get_refresh_broker(app) + queue = broker.subscribe() + client = app.test_client() + + response = await client.post( + "/demo/decrement", + headers={"Datastar-Request": "true"}, + json={"decrementAmount": "3"}, + ) + body = await response.get_data(as_text=True) + event = await asyncio.wait_for(queue.get(), timeout=1) + + assert response.status_code == 200 + assert get_active_jobs(app) == 9 + assert event == "refresh-event" + assert 'data: signals {"decrementError":""}' in body + broker.unsubscribe(queue) + + asyncio.run(run()) + + +def test_demo_decrement_action_validates_odd_amount() -> None: + async def run() -> None: + app = create_app(enable_demo_refresh=False) + broker = get_refresh_broker(app) + queue = broker.subscribe() + client = app.test_client() + + response = await client.post( + "/demo/decrement", + headers={"Datastar-Request": "true"}, + json={"decrementAmount": "2"}, + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert get_active_jobs(app) == 12 + assert "odd integer" in body + + try: + await asyncio.wait_for(queue.get(), timeout=0.1) + except TimeoutError: + pass + else: + raise AssertionError("invalid decrement should not publish a refresh") + finally: + broker.unsubscribe(queue) asyncio.run(run())