feat: v0.5.0→v0.8.0 — multi-vues, filtres, tri, modale, checklists
v0.5.0 (Kanban complet):
- Card detail modal avec checklists + commentaires Gitea
- Création d'issues inline (formulaire + POST /api/issues)
- Édition inline (contenteditable)
v0.6.0 (Table view):
- table_view.html: colonnes Name/Status/Assign/Deadline/Team/Keywords
- Tri par colonne (clic en-tête, asc/desc)
- Groupes rétractables (Design/Engineering)
v0.7.0 (Filtres & Tri):
- Filtres cumulables AND (backend _apply_filters + UI pastilles)
- Status dropdown avec checkboxes + dots colorés
- Sort panel multi-critères (+ Add sort / Delete sort)
- Backend _apply_sorts avec asc/desc
v0.8.0 (Vues spéciales):
- status_overview.html: SVG donut chart + légende + pourcentages
- team_load.html: barres empilées par avatar
- detailed_board.html: cartes avec toutes propriétés
Backend:
- /board/{o}/{r}/view/{view} — 5 fragments HTMX
- /api/issues/{o}/{r}/{id}?format=html — modal HTML
- _map_issue_to_card, STATUS_COLORS/LABELS exportés
This commit is contained in:
+31
-3
@@ -7,10 +7,11 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.board import _issue_column
|
||||
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -379,8 +380,9 @@ async def update_issue_api(
|
||||
# ── v0.5.0: Card detail ──
|
||||
|
||||
@router.get("/issues/{owner}/{repo}/{issue_id}")
|
||||
async def get_issue_detail(owner: str, repo: str, issue_id: int):
|
||||
"""Get full issue details + comments."""
|
||||
@router.get("/issues/{owner}/{repo}/{issue_id}/html")
|
||||
async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Query(default="json")):
|
||||
"""Get full issue details + comments. Set format=html for HTML response."""
|
||||
try:
|
||||
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||
comments = await gitea.get_issue_comments(owner, repo, issue_id)
|
||||
@@ -417,6 +419,32 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int):
|
||||
(board_id, issue_id),
|
||||
).fetchone() if board_id else None
|
||||
|
||||
# HTML format for modal
|
||||
if format == "html":
|
||||
card_data = _map_issue_to_card(issue)
|
||||
ctx = {
|
||||
"issue": {
|
||||
"id": issue_id,
|
||||
"title": issue.get("title", ""),
|
||||
"body": issue.get("body", ""),
|
||||
"icon": card_data.get("icon", "📄"),
|
||||
"author": issue.get("user", {}).get("login", "unknown") if issue.get("user") else "unknown",
|
||||
"created": issue.get("created_at", "")[:10] if issue.get("created_at") else "",
|
||||
"assignee": (issue.get("assignee") or {}).get("login", ""),
|
||||
"due_date": issue.get("due_date", "") or "",
|
||||
"status": card_data.get("status", "todo"),
|
||||
"status_color": STATUS_COLORS.get(card_data.get("status", ""), "var(--gray)"),
|
||||
"status_label": STATUS_LABELS.get(card_data.get("status", ""), "To-do"),
|
||||
"labels": issue.get("labels", []),
|
||||
},
|
||||
"comments": comments,
|
||||
"checklists": checklists,
|
||||
}
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
template = env.get_template("card_detail.html")
|
||||
return HTMLResponse(template.render(**ctx))
|
||||
|
||||
return {
|
||||
"issue": issue,
|
||||
"comments": comments,
|
||||
|
||||
+149
-127
@@ -1,10 +1,10 @@
|
||||
"""FlowDeck — Board Kanban Notion-style."""
|
||||
"""FlowDeck — Board Kanban Notion-style + multi-vues."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.db import get_conn
|
||||
@@ -14,9 +14,20 @@ from app.auth.session import SessionManager
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
STATUS_COLORS = {
|
||||
"todo": "var(--gray)",
|
||||
"progress": "var(--blue)",
|
||||
"done": "var(--green)",
|
||||
}
|
||||
|
||||
STATUS_LABELS = {
|
||||
"todo": "To-do",
|
||||
"progress": "In progress",
|
||||
"done": "Complete",
|
||||
}
|
||||
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
"""Determine which column an issue belongs to (used by api.py)."""
|
||||
if issue.get("state") == "closed":
|
||||
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||
@@ -31,75 +42,47 @@ def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
return columns[0] if columns else "Backlog"
|
||||
|
||||
|
||||
STATUS_MAP = {
|
||||
"todo": {"label": "To-do", "color": "var(--gray)", "css": "card-status-todo"},
|
||||
"progress": {"label": "In progress", "color": "var(--blue)", "css": "card-status-progress"},
|
||||
"done": {"label": "Complete", "color": "var(--green)", "css": "card-status-complete"},
|
||||
}
|
||||
|
||||
DEFAULT_GROUPS = ["Design", "Engineering", "No Team"]
|
||||
|
||||
|
||||
def _ensure_board(owner: str, repo: str) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo),
|
||||
).fetchone()
|
||||
if not row:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO boards (project_owner, project_name) VALUES (?, ?)",
|
||||
(owner, repo),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"]}
|
||||
return {"id": row["id"], "columns": json.loads(row["columns_json"])}
|
||||
|
||||
|
||||
def _map_issue_to_card(issue: dict) -> dict:
|
||||
"""Convert a Gitea issue to a Notion-style card."""
|
||||
title = issue.get("title", "Untitled")
|
||||
status = "todo"
|
||||
if issue.get("state") == "closed":
|
||||
status = "done"
|
||||
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
name = lbl.get("name", "").lower()
|
||||
if "progress" in name or "doing" in name or "in progress" in name:
|
||||
if "progress" in name or "doing" in name:
|
||||
status = "progress"
|
||||
elif "done" in name or "complete" in name or "terminé" in name:
|
||||
status = "done"
|
||||
|
||||
assignee = issue.get("assignee", {})
|
||||
assignee_name = assignee.get("login", "") if assignee else ""
|
||||
|
||||
assignee = issue.get("assignee", {}) or {}
|
||||
assignee_name = assignee.get("login", "")
|
||||
tag = labels[0].get("name", "") if labels else ""
|
||||
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
|
||||
if tag_color and not tag_color.startswith("#"):
|
||||
tag_color = f"#{tag_color}"
|
||||
|
||||
icon_map = {
|
||||
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
|
||||
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
|
||||
}
|
||||
icon = "📄"
|
||||
for lbl in labels:
|
||||
for keyword, emoji in icon_map.items():
|
||||
if keyword in lbl.get("name", "").lower():
|
||||
for kw, emoji in icon_map.items():
|
||||
if kw in lbl.get("name", "").lower():
|
||||
icon = emoji
|
||||
break
|
||||
|
||||
return {
|
||||
"id": str(issue.get("number", 0)),
|
||||
"title": title,
|
||||
"status": status,
|
||||
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
|
||||
"status_label": STATUS_LABELS.get(status, "To-do"),
|
||||
"icon": icon,
|
||||
"assignee": assignee_name,
|
||||
"tag": tag if tag else None,
|
||||
"tag_color": tag_color,
|
||||
"due_date": issue.get("due_date", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"keywords": [lbl.get("name", "") for lbl in labels if lbl.get("name") != tag],
|
||||
}
|
||||
|
||||
|
||||
@@ -107,107 +90,146 @@ def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
workspace_name = user.get("login", "Bruno") if user else "Bruno"
|
||||
workspace_initial = workspace_name[0].upper() if workspace_name else "B"
|
||||
|
||||
recent_pages = [{
|
||||
"id": f"{owner}/{repo}",
|
||||
"name": repo,
|
||||
"icon": "📁",
|
||||
"url": f"/board/{owner}/{repo}",
|
||||
"active": True,
|
||||
"indent": 0,
|
||||
"id": f"{owner}/{repo}", "name": repo, "icon": "📁",
|
||||
"url": f"/board/{owner}/{repo}", "active": True, "indent": 0,
|
||||
}]
|
||||
# Sub-views as indented items
|
||||
sub_views = ["Detailed board", "Table view", "Status overview", "Team Load"]
|
||||
for sv in sub_views:
|
||||
for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
|
||||
recent_pages.append({
|
||||
"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
|
||||
"name": sv,
|
||||
"icon": "·",
|
||||
"url": f"/board/{owner}/{repo}",
|
||||
"active": False,
|
||||
"indent": 1,
|
||||
"name": sv, "icon": "·",
|
||||
"url": f"/board/{owner}/{repo}", "active": False, "indent": 1,
|
||||
})
|
||||
|
||||
return {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_initial": workspace_initial,
|
||||
"current_page": repo,
|
||||
"last_edited": "2m ago",
|
||||
"recent_pages": recent_pages,
|
||||
"private_pages": recent_pages[:1],
|
||||
"workspace_name": workspace_name, "workspace_initial": workspace_initial,
|
||||
"current_page": repo, "last_edited": "2m ago",
|
||||
"recent_pages": recent_pages, "private_pages": recent_pages[:1],
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
|
||||
if status_filter:
|
||||
allowed = set(status_filter.split(","))
|
||||
cards = [c for c in cards if c["status"] in allowed]
|
||||
if filters:
|
||||
for f in filters.split(","):
|
||||
if ":" in f:
|
||||
prop, val = f.split(":", 1)
|
||||
if prop == "assignee":
|
||||
cards = [c for c in cards if c.get("assignee", "").lower() == val.lower()]
|
||||
elif prop == "tag":
|
||||
cards = [c for c in cards if c.get("tag", "").lower() == val.lower()]
|
||||
return cards
|
||||
|
||||
|
||||
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
if not sorts:
|
||||
return cards
|
||||
for sort_spec in reversed(sorts.split(",")):
|
||||
if ":" not in sort_spec:
|
||||
continue
|
||||
field, direction = sort_spec.split(":", 1)
|
||||
reverse = direction == "desc"
|
||||
if field == "name":
|
||||
cards.sort(key=lambda c: c["title"].lower(), reverse=reverse)
|
||||
elif field == "status":
|
||||
order = {"todo": 0, "progress": 1, "done": 2}
|
||||
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=reverse)
|
||||
elif field == "assignee":
|
||||
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=reverse)
|
||||
elif field == "deadline":
|
||||
cards.sort(key=lambda c: c.get("due_date", ""), reverse=reverse)
|
||||
return cards
|
||||
|
||||
|
||||
# ── Board page ──
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
milestone: str = "",
|
||||
label: str = "",
|
||||
assignee: str = "",
|
||||
fragment: bool = False,
|
||||
):
|
||||
"""Kanban board Notion-style for a Gitea project."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
labels_list = await gitea.get_labels(owner, repo)
|
||||
milestones = await gitea.get_milestones(owner, repo)
|
||||
|
||||
if milestone:
|
||||
issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone]
|
||||
if label:
|
||||
issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))]
|
||||
if assignee:
|
||||
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
|
||||
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
|
||||
# Convert to Notion-style cards
|
||||
cards = [_map_issue_to_card(i) for i in issues_only]
|
||||
|
||||
# Build groups
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
groups = []
|
||||
for gname in group_names:
|
||||
group_cards = [c for c in cards] # For now, all cards in all groups
|
||||
counts = {
|
||||
"todo": len([c for c in group_cards if c["status"] == "todo"]),
|
||||
"progress": len([c for c in group_cards if c["status"] == "progress"]),
|
||||
"done": len([c for c in group_cards if c["status"] == "done"]),
|
||||
}
|
||||
groups.append({
|
||||
"id": gname.lower().replace(" ", "-"),
|
||||
"name": gname,
|
||||
"counts": counts,
|
||||
"cards": group_cards,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
from httpx import HTTPStatusError
|
||||
if isinstance(e, HTTPStatusError) and e.response is not None and e.response.status_code == 404:
|
||||
return HTMLResponse(
|
||||
'<div style="padding:48px;text-align:center">'
|
||||
f'<h2 style="color:#8b949e">Projet introuvable</h2>'
|
||||
f'<p style="color:#8b949e">{owner}/{repo} n\'existe pas sur Gitea.</p>'
|
||||
'<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a></div>',
|
||||
status_code=404)
|
||||
logger.error("Board error: %s", e)
|
||||
return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500)
|
||||
|
||||
async def board(request: Request, owner: str, repo: str):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from datetime import datetime
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
now_str = datetime.now().isoformat()[:10]
|
||||
|
||||
ctx = dict(
|
||||
request=request, owner=owner, repo=repo,
|
||||
groups=groups, labels=labels_list, milestones=milestones,
|
||||
**sidebar,
|
||||
)
|
||||
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar)
|
||||
|
||||
|
||||
# ── View fragments ──
|
||||
|
||||
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
|
||||
async def board_view(
|
||||
request: Request, owner: str, repo: str, view: str,
|
||||
status: str = Query(default=""),
|
||||
filter: str = Query(default=""),
|
||||
sort: str = Query(default=""),
|
||||
):
|
||||
"""Return a view fragment (Kanban, Table, Status Overview, Team Load, Detailed)."""
|
||||
try:
|
||||
issues = await gitea.get_issues(owner, repo, state="all")
|
||||
issues_only = [i for i in issues if not i.get("pull_request")]
|
||||
cards = [_map_issue_to_card(i) for i in issues_only]
|
||||
cards = _apply_filters(cards, status, filter)
|
||||
cards = _apply_sorts(cards, sort)
|
||||
except Exception as e:
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
groups = []
|
||||
for gname in group_names:
|
||||
gid = gname.lower().replace(" ", "-")
|
||||
gcards = cards
|
||||
groups.append({
|
||||
"id": gid, "name": gname,
|
||||
"counts": {
|
||||
"todo": len([c for c in gcards if c["status"] == "todo"]),
|
||||
"progress": len([c for c in gcards if c["status"] == "progress"]),
|
||||
"done": len([c for c in gcards if c["status"] == "done"]),
|
||||
},
|
||||
"cards": gcards,
|
||||
})
|
||||
|
||||
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
|
||||
|
||||
if view == "table":
|
||||
grouped = {}
|
||||
for g in groups:
|
||||
grouped[g["name"]] = g["cards"]
|
||||
ctx["grouped_cards"] = grouped
|
||||
template = env.get_template("table_view.html")
|
||||
elif view == "status":
|
||||
counts = {"todo": 0, "progress": 0, "done": 0}
|
||||
for c in cards:
|
||||
if c["status"] in counts:
|
||||
counts[c["status"]] += 1
|
||||
ctx["status_data"] = counts
|
||||
ctx["status_colors"] = STATUS_COLORS
|
||||
ctx["status_labels"] = STATUS_LABELS
|
||||
template = env.get_template("status_overview.html")
|
||||
elif view == "teamload":
|
||||
members = {}
|
||||
for c in cards:
|
||||
name = c.get("assignee") or "Unassigned"
|
||||
if name not in members:
|
||||
members[name] = {"name": name, "initial": name[0].upper(), "todo": 0, "progress": 0, "complete": 0, "total": 0}
|
||||
status_key = c["status"] if c["status"] in ("todo", "progress") else "complete"
|
||||
members[name][status_key] += 1
|
||||
members[name]["total"] += 1
|
||||
ctx["team_data"] = list(members.values())
|
||||
template = env.get_template("team_load.html")
|
||||
elif view == "detailed":
|
||||
template = env.get_template("detailed_board.html")
|
||||
else:
|
||||
template = env.get_template("board_fragment.html")
|
||||
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
# ── Kanban fragment (default) ──
|
||||
|
||||
@router.get("/{owner}/{repo}/view/kanban", response_class=HTMLResponse)
|
||||
async def board_kanban(request: Request, owner: str, repo: str):
|
||||
return await board_view(request, owner, repo, "kanban")
|
||||
|
||||
Reference in New Issue
Block a user