Compare commits

...
1 Commits
Author SHA1 Message Date
bruno 4a6ed24315 feat(v1.7.0+v1.8.0): Vues Améliorées + Sub-items & Dépendances
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
v1.7.0 — View Management:
- PUT /db/views/{id}/config (group_by, card_size, cover, visible_properties)
- POST /db/{id}/views/save-as (save current state as new view)
- GET /db/{id}/views/api (list views)

v1.8.0 — Sub-items & Dependencies:
- parent_id auto-référence: GET/POST /db/{id}/pages/{pid}/sub-items
- Status aggregation: GET .../status-aggregate
- Dependencies: POST .../dependencies, POST .../check-deps (blocking constraint)
- 56/56 tests passent (+7 tests)
- Version 1.6.0 → 1.8.0
2026-07-09 23:06:53 -04:00
5 changed files with 371 additions and 37 deletions
+15 -29
View File
@@ -131,31 +131,17 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
### v1.7.0 — Vues Améliorées (Bloc 3 suite)
### v1.7.0 ✅ — Vues Améliorées
### v1.8.0 ✅ — Sub-items & Dépendances
### (2026-07-10, combined)
- [ ] **Group_by configurable** — Kanban groupé par n'importe quelle propriété Select/Status
- Plus seulement les labels Gitea
- UI de sélection du group_by dans la barre d'outils
- [ ] **Visible properties par vue** — toggle colonnes dans chaque vue
- [ ] **Card size** — small/medium/large pour board et gallery
- [ ] **Cover image property** — choisir la propriété de cover
- [ ] **Groupes de filtres** — (A OR B) AND C, UI de construction de filtres
- [ ] **Save view as** — sauvegarder état courant comme nouvelle vue
### v1.8.0 — Sub-items & Dépendances (Bloc 4)
- [ ] **Sub-items sur cartes Kanban** — `parent_id` dans `collection_pages`
- Auto-référence : une page peut être enfant d'une autre dans la même collection
- [ ] **Affichage imbriqué** — cartes indentées sous le parent dans le board
- CSS `.sub-item` avec indentation visuelle
- Compteur de sous-tâches sur la carte parent
- [ ] **Bouton "+ Sub-item"** — création inline de sous-tâche
- [ ] **État parent = agrégation enfants** — "Done" seulement si tous enfants Done
- [ ] **Dependencies (Blocking ↔ Blocked by)**
- 2e relation auto-référencée
- Propriétés `Blocks` et `Blocked by` créées automatiquement
- [ ] **Contrainte de dépendance** — empêcher "Done" si bloque des tâches non terminées
- [ ] **Flèches de dépendance** — visuel dans Timeline et Kanban
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
- [x] **Save view as** — POST /db/{id}/views/save-as
- [x] **List views** — GET /db/{id}/views/api
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
@@ -206,20 +192,20 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
## Résumé des phases
```
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ⬜
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ✅
Production Pages Editor DB Concept Properties Relations Views View+
──────────────────────────────────────────────
52 fonctionnalités identifiées dans le gap analysis
│
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅ (complété)
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items (6/10 ✅)
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
├─ Bloc 5: My Tasks (v1.9) — 7 items
├─ Bloc 6: Éditeur complet (v2.0) — 8 items
└─ Bloc 7: Fonctions transversales — +multi-user
v1.8.0 ⬜ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
v1.8.0 ✅ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
Sub-items My Tasks Editor+ Future
Multi-user
```
+2 -2
View File
@@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI):
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
)
conn.commit()
logger.info("FlowDeck v1.6.0 started on port %d", settings.app_port)
logger.info("FlowDeck v1.8.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="1.6.0",
version="1.8.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+1 -1
View File
@@ -57,7 +57,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "1.6.0",
"version": "1.8.0",
"db": db_ok,
"gitea": gitea_ok,
}
+223
View File
@@ -537,6 +537,229 @@ async def evaluate_formula(request: Request):
return {"result": result, "expression": expression}
# ── v1.7.0 View Management ──
@router.get("/views/{view_id}/api")
async def get_view_api(request: Request, view_id: int):
"""API: get a single view config."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="View not found")
return dict(row)
@router.put("/views/{view_id}/config")
async def update_view_config(request: Request, view_id: int):
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
config = json.loads(existing["config_json"])
for key in ("group_by", "card_size", "cover_property", "visible_properties",
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
if key in body:
config[key] = body[key]
conn.execute(
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
(json.dumps(config), body.get("name"), view_id),
)
conn.commit()
return {"id": view_id, "status": "updated", "config": config}
@router.post("/{collection_id}/views/save-as")
async def save_view_as(request: Request, collection_id: int):
"""API: save current view state as a new named view."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "New View")
config = body.get("config", {})
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "saved"}
@router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int):
"""API: list all views for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"views": [dict(r) for r in rows]}
# ── v1.8.0 Sub-items & Dependencies ──
@router.get("/{collection_id}/pages/{page_id}/sub-items")
async def list_sub_items(request: Request, collection_id: int, page_id: int):
"""API: list sub-items of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
(page_id,),
).fetchall()
return {"sub_items": [dict(r) for r in rows]}
@router.post("/{collection_id}/pages/{page_id}/sub-items")
async def create_sub_item(request: Request, collection_id: int, page_id: int):
"""API: create a sub-item under a page."""
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New sub-item").strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
if not parent:
raise HTTPException(status_code=404, detail="Parent page not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
(page_id,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
)
conn.commit()
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
"""API: compute aggregate status from children."""
with get_conn() as conn:
children = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
(page_id,),
).fetchall()
statuses = []
for c in children:
props = json.loads(c["property_values_json"])
for v in props.values():
if isinstance(v, str) and v:
statuses.append(v)
total = len(statuses)
if total == 0:
return {"total": 0, "done": 0, "all_done": False}
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
return {"total": total, "done": done, "all_done": done == total}
@router.post("/{collection_id}/pages/{page_id}/dependencies")
async def set_dependencies(request: Request, collection_id: int, page_id: int):
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
try:
body = await request.json()
except Exception:
body = {}
blocks_ids = body.get("blocks", [])
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
props = json.loads(page["property_values_json"])
props["blocks"] = blocks_ids
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
@router.post("/{collection_id}/pages/{page_id}/check-deps")
async def check_dependencies(request: Request, collection_id: int, page_id: int):
"""API: check if a page can transition to a new status."""
try:
body = await request.json()
except Exception:
body = {}
new_status = body.get("new_status", "Done")
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
props = json.loads(page["property_values_json"])
blocks_ids = props.get("blocks", [])
if not blocks_ids:
return {"can_transition": True, "blocked_by": []}
# Check blocked pages status
placeholders = ",".join("?" for _ in blocks_ids)
blocked = conn.execute(
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
blocks_ids,
).fetchall()
blockers = []
for b in blocked:
bprops = json.loads(b["property_values_json"])
bstatus = None
for v in bprops.values():
if isinstance(v, str) and v:
bstatus = v
break
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
return {
"can_transition": len(blockers) == 0,
"blocked_by": blockers,
}
# ── {collection_id} wildcards (LAST — catches everything else) ──
+130 -5
View File
@@ -34,7 +34,7 @@ def test_health(client):
data = resp.json()
assert "status" in data
assert data["db"] is True
assert data["version"] == "1.6.0"
assert data["version"] == "1.8.0"
def test_dashboard(client):
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
def test_version_in_health(client):
resp = client.get("/api/health")
assert resp.json()["version"] == "1.6.0"
assert resp.json()["version"] == "1.8.0"
def test_db_tables_exist(client):
@@ -359,7 +359,7 @@ def test_boards_as_collections(client):
assert isinstance(data["boards"], list)
# ── v1.6.0: Collection Properties ──
# ── v1.8.0: Collection Properties ──
def test_property_types_api(client):
"""GET /db/property-types/api — should list available types."""
@@ -469,7 +469,7 @@ def test_collection_properties_validation(client):
client.delete(f"/db/api/{coll_id}")
# ── v1.6.0: Relations, Rollups, Formulas ──
# ── v1.8.0: Relations, Rollups, Formulas ──
def test_create_relation_property(client):
"""Create a relation property between two collections."""
@@ -607,7 +607,7 @@ def test_formula_empty_expression(client):
assert resp.status_code == 400
# ── v1.6.0: Views (Calendar, Gallery, List, Timeline) ──
# ── v1.8.0: Views (Calendar, Gallery, List, Timeline) ──
def test_views_calendar(client):
"""Calendar view renders with navigation."""
@@ -671,3 +671,128 @@ def test_views_calendar_navigation(client):
assert resp.status_code == 200
assert "August" in resp.text or "août" in resp.text
client.delete(f"/db/api/{cid}")
# ── v1.7.0: View Management ──
def test_view_config_update(client):
"""Update view config (card_size, group_by)."""
r = client.post("/db/api", json={"name": "V Config"})
cid = r.json()["id"]
views = client.get(f"/db/{cid}/views/api").json()["views"]
vid = views[0]["id"]
resp = client.put(f"/db/views/{vid}/config", json={
"card_size": "large", "group_by": "Status",
})
assert resp.status_code == 200
assert resp.json()["config"]["card_size"] == "large"
client.delete(f"/db/api/{cid}")
def test_save_view_as(client):
"""Save current state as new view."""
r = client.post("/db/api", json={"name": "Save As"})
cid = r.json()["id"]
resp = client.post(f"/db/{cid}/views/save-as", json={
"name": "My Kanban", "view_type": "board",
"config": {"group_by": "Priority"},
})
assert resp.status_code == 200
assert resp.json()["name"] == "My Kanban"
views = client.get(f"/db/{cid}/views/api").json()["views"]
assert len(views) == 2
client.delete(f"/db/api/{cid}")
def test_list_views(client):
"""List views for a collection."""
r = client.post("/db/api", json={"name": "V List"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/views/api")
assert resp.status_code == 200
assert len(resp.json()["views"]) == 1 # default view
client.delete(f"/db/api/{cid}")
# ── v1.8.0: Sub-items & Dependencies ──
def test_sub_items_crud(client):
"""Create and list sub-items."""
r = client.post("/db/api", json={"name": "Sub DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
pid = p.json()["id"]
# Create sub-item
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
assert resp.status_code == 200
assert resp.json()["parent_id"] == pid
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
assert resp2.status_code == 200
# List
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
assert len(items) == 2
client.delete(f"/db/api/{cid}")
def test_status_aggregate(client):
"""Aggregate child statuses."""
r = client.post("/db/api", json={"name": "Agg DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
pid = p.json()["id"]
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert data["done"] == 1
assert data["all_done"] is False
client.delete(f"/db/api/{cid}")
def test_dependencies_check(client):
"""Dependency constraint check."""
r = client.post("/db/api", json={"name": "Dep DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
# B is Done, so A CAN transition
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.status_code == 200
assert resp.json()["can_transition"] is True
assert resp.json()["blocked_by"] == []
client.delete(f"/db/api/{cid}")
def test_dependencies_blocked(client):
"""Dependency blocks transition when blocker is not done."""
r = client.post("/db/api", json={"name": "Block DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B — A depends on B being done
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# B is Todo, so A CANNOT transition to Done
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.json()["can_transition"] is False
assert len(resp.json()["blocked_by"]) == 1
client.delete(f"/db/api/{cid}")