add a datastar action

This commit is contained in:
Abel Luck 2026-03-30 12:48:32 +02:00
parent 33dbb143fd
commit 3fc999a69b
3 changed files with 142 additions and 2 deletions

View file

@ -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", 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"], )["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: def source_form_section() -> Renderable:
return h.section( return h.section(
class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200" class_="rounded-[2rem] bg-white/90 shadow-sm ring-1 ring-slate-200"

View file

@ -7,7 +7,8 @@ from contextlib import suppress
from typing import cast from typing import cast
import htpy as h 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 datastar_py.sse import DatastarEvent
from htpy import Renderable from htpy import Renderable
from quart import Quart, Response, request, url_for 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)) 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 return app
@ -115,3 +126,20 @@ async def _demo_refresh_loop(app: Quart) -> None:
await asyncio.sleep(1) await asyncio.sleep(1)
set_active_jobs(app, get_active_jobs(app) + 1) set_active_jobs(app, get_active_jobs(app) + 1)
trigger_refresh(app) 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

View file

@ -134,5 +134,61 @@ def test_render_dashboard_uses_active_jobs_from_app_state() -> None:
assert "27" in body assert "27" in body
assert "Temporary live demo counter for Datastar refresh testing" 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()) asyncio.run(run())