From 0403d5212e1a8d3057b13404970e18035293f76f Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Tue, 30 Jun 2026 14:14:17 +0200 Subject: [PATCH] feat: composants d'affichage de la roadmap #94 --- src/roadmap/ui.py | 85 ++++++++++++++++++++++++++++++++++++++++ tests/roadmap/test_ui.py | 50 +++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/roadmap/ui.py create mode 100644 tests/roadmap/test_ui.py diff --git a/src/roadmap/ui.py b/src/roadmap/ui.py new file mode 100644 index 0000000..cea6704 --- /dev/null +++ b/src/roadmap/ui.py @@ -0,0 +1,85 @@ +from pathlib import Path + +import dash_bootstrap_components as dbc +from dash import dcc, html + +from src.roadmap import db as roadmap_db +from src.roadmap import github + +_CHANGELOG_PATH = Path(__file__).resolve().parents[2] / "CHANGELOG.md" + + +def balance_text(balance: int) -> str: + mot = "vote" if balance == 1 else "votes" + return f"Il te reste {balance} {mot}." + + +def changelog_markdown() -> dcc.Markdown: + try: + text = _CHANGELOG_PATH.read_text(encoding="utf-8") + except OSError: + text = "_Changelog indisponible._" + return dcc.Markdown(text) + + +def _vote_item(issue: dict, count: int, editable: bool): + mot = "vote" if count == 1 else "votes" + children = [ + html.A(issue["title"], href=issue["html_url"], target="_blank"), + html.Span(f" — {count} {mot}", className="text-muted ms-1"), + ] + if editable: + children.append( + dbc.Button( + "Voter", + id={"type": "roadmap-vote", "index": issue["number"]}, + size="sm", + color="primary", + className="ms-2", + ) + ) + return dbc.ListGroupItem(children) + + +def vote_items(au_vote: list[dict], counts: dict[int, int], editable: bool) -> list: + ordered = sorted(au_vote, key=lambda i: counts.get(i["number"], 0), reverse=True) + if not ordered: + return [dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")] + return [_vote_item(i, counts.get(i["number"], 0), editable) for i in ordered] + + +def _en_cours_items(en_cours: list[dict]) -> list: + if not en_cours: + return [dbc.ListGroupItem("Rien en cours pour le moment.")] + return [ + dbc.ListGroupItem(html.A(i["title"], href=i["html_url"], target="_blank")) + for i in en_cours + ] + + +def roadmap_content(editable: bool, balance: int | None = None) -> html.Div: + try: + issues = github.fetch_roadmap_issues() + except Exception: + issues = {"en_cours": [], "au_vote": []} + counts = roadmap_db.vote_counts() + + body: list = [] + if editable and balance is not None: + body.append( + dbc.Alert(balance_text(balance), color="info", id="roadmap-balance") + ) + body.append(html.H3("En cours", className="mt-3")) + body.append(dbc.ListGroup(_en_cours_items(issues["en_cours"]), className="mb-4")) + body.append(html.H3("Au vote")) + body.append( + dbc.ListGroup( + vote_items(issues["au_vote"], counts, editable), + id="roadmap-vote-list", + className="mb-4", + ) + ) + body.append(html.Hr()) + body.append(html.H3("Changelog")) + body.append(changelog_markdown()) + return html.Div(body) diff --git a/tests/roadmap/test_ui.py b/tests/roadmap/test_ui.py new file mode 100644 index 0000000..1e4565e --- /dev/null +++ b/tests/roadmap/test_ui.py @@ -0,0 +1,50 @@ +from dash import dcc, html + +from src.roadmap import ui + + +def test_balance_text_singular_plural(): + assert ui.balance_text(1) == "Il te reste 1 vote." + assert ui.balance_text(3) == "Il te reste 3 votes." + + +def test_vote_items_sorted_by_count_desc(): + au_vote = [ + {"number": 1, "title": "A", "html_url": "u1"}, + {"number": 2, "title": "B", "html_url": "u2"}, + ] + counts = {1: 1, 2: 5} + items = ui.vote_items(au_vote, counts, editable=False) + # le plus voté (numéro 2) vient en premier + assert "B" in str(items[0]) + assert "A" in str(items[1]) + + +def test_vote_items_buttons_only_when_editable(): + au_vote = [{"number": 1, "title": "A", "html_url": "u1"}] + assert "Voter" in str(ui.vote_items(au_vote, {1: 0}, editable=True)) + assert "Voter" not in str(ui.vote_items(au_vote, {1: 0}, editable=False)) + + +def test_changelog_markdown_returns_component(): + comp = ui.changelog_markdown() + assert isinstance(comp, dcc.Markdown) + assert "##" in comp.children # le CHANGELOG.md contient des titres markdown + + +def test_roadmap_content_renders(monkeypatch): + monkeypatch.setattr( + ui.github, + "fetch_roadmap_issues", + lambda: { + "en_cours": [{"number": 9, "title": "En cours X", "html_url": "u9"}], + "au_vote": [{"number": 5, "title": "Au vote Y", "html_url": "u5"}], + }, + ) + monkeypatch.setattr(ui.roadmap_db, "vote_counts", lambda: {5: 3}) + content = ui.roadmap_content(editable=True, balance=2) + s = str(content) + assert isinstance(content, html.Div) + assert "En cours X" in s + assert "Au vote Y" in s + assert "Il te reste 2 votes." in s