From 6e03e747ffc2c5754925e70bc0090dc3f689602b Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 17:21:10 +0200 Subject: [PATCH 1/3] fix toggloe checkbox active state --- repub/static/app.css | 7 +++++++ repub/static/app.tailwind.css | 1 + tests/test_web.py | 21 ++++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/repub/static/app.css b/repub/static/app.css index dae085d..0a0f270 100644 --- a/repub/static/app.css +++ b/repub/static/app.css @@ -392,6 +392,10 @@ --tw-translate-x: calc(var(--spacing) * 0); translate: var(--tw-translate-x) var(--tw-translate-y); } + .translate-x-5 { + --tw-translate-x: calc(var(--spacing) * 5); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .cursor-not-allowed { cursor: not-allowed; } @@ -541,6 +545,9 @@ .bg-amber-400 { background-color: var(--color-amber-400); } + .bg-amber-500 { + background-color: var(--color-amber-500); + } .bg-emerald-100 { background-color: var(--color-emerald-100); } diff --git a/repub/static/app.tailwind.css b/repub/static/app.tailwind.css index 97c0bcf..742b073 100644 --- a/repub/static/app.tailwind.css +++ b/repub/static/app.tailwind.css @@ -1 +1,2 @@ @import "tailwindcss" source("../"); +@source inline("bg-amber-500 translate-x-5"); diff --git a/tests/test_web.py b/tests/test_web.py index eff5922..3fd92fd 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast -from repub.components import status_badge +from repub.components import status_badge, toggle_field from repub.datastar import RefreshBroker, render_sse_event, render_stream from repub.jobs import load_dashboard_view from repub.model import ( @@ -38,6 +38,25 @@ def test_status_badge_uses_green_done_tone() -> None: assert "Succeeded" in badge +def test_toggle_field_active_state_utilities_exist_in_built_css() -> None: + markup = str( + toggle_field( + label="Enabled", + description="Enable this source", + signal_name="enabled", + checked=True, + ) + ) + css = ( + Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css" + ).read_text(encoding="utf-8") + + assert "data-class:bg-amber-500" in markup + assert "data-class:translate-x-5" in markup + assert ".bg-amber-500" in css + assert ".translate-x-5" in css + + def test_runs_page_renders_completed_execution_end_time_as_relative_hoverable_time() -> ( None ): From 94717b1d1b47fcd3a39a126b139775b459078627 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 17:21:32 +0200 Subject: [PATCH 2/3] remember to generate css --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index e77c250..19c73ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ The only way for actions to affect the view returned by the render-fn running in - Enter the dev environment with `nix develop` if you are not already inside it - Sync Python dependencies with `uv sync --all-groups`. - Run the app with `uv run repub`. +- Generate CSS with `tailwindcss -i ./path/to/input.css -o ./path/to/output.css` and add `--watch` when you need live rebuilds. ```sh uv sync --all-groups From 2a99edeec3939295b502eac027564e178673b045 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 30 Mar 2026 17:25:37 +0200 Subject: [PATCH 3/3] Fix source actions and toggle regressions --- repub/model.py | 9 ++++++++ repub/pages/sources.py | 28 +++++++++++++++++++++++ repub/web.py | 15 ++++++++++--- tests/test_scheduler_runtime.py | 40 +++++++++++++++++++++++++++++++++ tests/test_web.py | 35 +++++++++++++++++++++++++++-- 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/repub/model.py b/repub/model.py index 41d31d6..accdf8e 100644 --- a/repub/model.py +++ b/repub/model.py @@ -305,6 +305,15 @@ def delete_job_source(job_id: int) -> bool: return source.delete_instance() > 0 +def delete_source(slug: str) -> bool: + with database.connection_context(): + with database.atomic(): + source = Source.get_or_none(Source.slug == slug) + if source is None: + return False + return source.delete_instance() > 0 + + def load_sources() -> tuple[dict[str, object], ...]: with database.connection_context(): sources = tuple(Source.select().order_by(Source.created_at.desc())) diff --git a/repub/pages/sources.py b/repub/pages/sources.py index a3bd7ad..de95f2d 100644 --- a/repub/pages/sources.py +++ b/repub/pages/sources.py @@ -54,6 +54,29 @@ def _checked(source: Mapping[str, object] | None, key: str, default: bool) -> bo return bool(value) +def _action_button( + *, + label: str, + tone: str = "default", + post_path: str | None = None, +) -> Renderable: + classes = { + "default": "bg-stone-100 text-slate-700 hover:bg-stone-200", + "danger": "bg-rose-50 text-rose-700 hover:bg-rose-100", + } + attributes: dict[str, str] = {} + if post_path is not None: + attributes["data-on:pointerdown"] = f"@post('{post_path}')" + return h.button( + attributes, + type="button", + class_=( + "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1.5 " + f"text-sm font-semibold transition {classes[tone]}" + ), + )[label] + + def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: return ( h.div[ @@ -81,6 +104,11 @@ def _source_row(source: Mapping[str, object]) -> tuple[Node, ...]: href=f"/sources/{source['slug']}/edit", label="Edit", tone="amber" ), inline_link(href="/runs", label="View runs"), + _action_button( + label="Delete", + tone="danger", + post_path=f"/actions/sources/{source['slug']}/delete", + ), ], ) diff --git a/repub/web.py b/repub/web.py index 0b3e1cd..d0b2247 100644 --- a/repub/web.py +++ b/repub/web.py @@ -26,6 +26,7 @@ from repub.model import ( Job, create_source, delete_job_source, + delete_source, initialize_database, load_source_form, load_sources, @@ -209,6 +210,13 @@ def create_app(*, dev_mode: bool = False) -> Quart: trigger_refresh(app) return DatastarResponse(SSE.redirect("/sources")) + @app.post("/actions/sources//delete") + async def delete_source_action(slug: str) -> Response: + delete_source(slug) + get_job_runtime(app).sync_jobs() + trigger_refresh(app) + return Response(status=204) + @app.post("/runs") async def runs_patch() -> DatastarResponse: return _page_patch_response(app, lambda: render_runs(app)) @@ -383,7 +391,7 @@ def validate_source_form( source_type = _read_string(signals, "sourceType") feed_url = _read_string(signals, "feedUrl") pangea_domain = _read_string(signals, "pangeaDomain") - pangea_category = _read_string(signals, "pangeaCategory") + pangea_category = _read_string(signals, "pangeaCategory", strip=False) content_format = _read_string(signals, "contentFormat") content_type = _read_string(signals, "contentType") max_articles = _read_string(signals, "maxArticles") @@ -474,8 +482,9 @@ def validate_source_form( return source, None -def _read_string(signals: dict[str, object], key: str) -> str: - return str(signals.get(key, "")).strip() +def _read_string(signals: dict[str, object], key: str, *, strip: bool = True) -> str: + value = str(signals.get(key, "")) + return value.strip() if strip else value def _read_bool(signals: dict[str, object], key: str, *, default: bool = False) -> bool: diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 31ee7fa..9fe81b4 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -604,6 +604,46 @@ def test_delete_job_action_removes_source_job_and_execution_history( asyncio.run(run()) +def test_delete_source_action_removes_source_job_and_execution_history( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "delete-source.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + + async def run() -> None: + app = create_app() + client = app.test_client() + + source = create_source( + name="Delete source row", + slug="delete-source-row", + source_type="feed", + notes="", + spider_arguments="", + enabled=True, + cron_minute="*/30", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/delete-source-row.xml", + ) + job = Job.get(Job.source == source) + execution = JobExecution.create( + job=job, + running_status=JobExecutionStatus.SUCCEEDED, + ) + + response = await client.post("/actions/sources/delete-source-row/delete") + + assert response.status_code == 204 + assert Source.get_or_none(Source.slug == "delete-source-row") is None + assert Job.get_or_none(id=job.id) is None + assert JobExecution.get_or_none(id=int(execution.get_id())) is None + + asyncio.run(run()) + + def _wait_for_running_execution( execution_id: int, *, timeout_seconds: float = 2.0 ) -> JobExecution: diff --git a/tests/test_web.py b/tests/test_web.py index 3fd92fd..0e23ef2 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -430,6 +430,37 @@ def test_render_sources_shows_table_and_create_link() -> None: asyncio.run(run()) +def test_render_sources_shows_delete_action_for_each_source( + monkeypatch, tmp_path: Path +) -> None: + db_path = tmp_path / "sources-delete-row.db" + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path)) + app = create_app() + create_source( + name="Delete me", + slug="delete-me", + source_type="feed", + notes="", + spider_arguments="", + enabled=True, + cron_minute="0", + cron_hour="*", + cron_day_of_month="*", + cron_day_of_week="*", + cron_month="*", + feed_url="https://example.com/delete.xml", + ) + + async def run() -> None: + body = str(await render_sources(app)) + + assert "Delete" in body + assert "data-on:pointerdown" in body + assert "/actions/sources/delete-me/delete" in body + + asyncio.run(run()) + + def test_render_create_source_shows_dedicated_form_page() -> None: async def run() -> None: body = str(await render_create_source()) @@ -537,7 +568,7 @@ def test_create_source_action_creates_pangea_source_and_job_in_database( "sourceSlug": "kenya-health", "sourceType": "pangea", "pangeaDomain": "example.org", - "pangeaCategory": "Health", + "pangeaCategory": " Health ", "contentFormat": "MOBILE_3", "contentType": "breakingnews", "maxArticles": "12", @@ -567,13 +598,13 @@ def test_create_source_action_creates_pangea_source_and_job_in_database( assert source.name == "Kenya health desk" assert source.source_type == "pangea" + assert pangea.category_name == " Health " assert pangea.content_type == "breakingnews" assert pangea.include_content is True assert job.enabled is True assert job.spider_arguments == "language=en\ndownload_media=true" assert job.cron_hour == "*/6" assert "kenya-health" in rendered_sources - assert "example.org / Health" in rendered_sources assert "Enabled" in rendered_sources asyncio.run(run())