Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a6ed24315 | ||
|
|
e725398543 |
+26
-45
@@ -118,49 +118,30 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
|
||||
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
|
||||
|
||||
### v1.6.0 — Vues Manquantes (Bloc 3)
|
||||
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
|
||||
### (2026-07-10)
|
||||
|
||||
- [ ] **Calendar view** — regroupement par propriété Date (jour/semaine/mois)
|
||||
- Template `calendar_view.html`
|
||||
- Navigation mois précédent/suivant
|
||||
- Création inline par clic sur un jour
|
||||
- [ ] **Timeline/Gantt view** — axe X = date range, barres horizontales
|
||||
- Template `timeline_view.html`
|
||||
- Propriétés `date` + `date_range` pour début/fin
|
||||
- Dépendances affichées en flèches
|
||||
- [ ] **Gallery view** — cartes visuelles avec cover image
|
||||
- Template `gallery_view.html`
|
||||
- `cover_property` configurable (Files/Image)
|
||||
- `card_size` : small/medium/large
|
||||
- [ ] **List view** — liste compacte 1 colonne
|
||||
- Template `list_view.html`
|
||||
- Preview du contenu sous le titre
|
||||
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
|
||||
- Route: `GET /db/{id}/view/calendar`
|
||||
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
|
||||
- `card_size`: small/medium/large, `cover_property` configurable
|
||||
- [x] **List view** — liste compacte avec preview propriétés
|
||||
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
|
||||
- [x] **Table view** — rendu SSR, colonnes properties
|
||||
- [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)
|
||||
|
||||
@@ -211,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
|
||||
├─ 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
@@ -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.5.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.5.0",
|
||||
version="1.8.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
|
||||
+484
-14
@@ -537,12 +537,236 @@ 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) ──
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int):
|
||||
"""Main view of a collection (default view)."""
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -561,20 +785,266 @@ async def view_collection(request: Request, collection_id: int):
|
||||
).fetchall()
|
||||
|
||||
collection_dict = dict(collection)
|
||||
view_dict = dict(view) if view else {"name": "Default", "view_type": "table"}
|
||||
pages_list = [dict(p) for p in pages]
|
||||
config = json.loads(view["config_json"]) if view else {}
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>{collection_dict['name']} — FlowDeck</title></head>
|
||||
<body>
|
||||
<h1>{collection_dict.get('icon', '📋')} {collection_dict['name']}</h1>
|
||||
<p>{collection_dict.get('description', '')}</p>
|
||||
<pre>View: {view_dict['name']} ({view_dict['view_type']})</pre>
|
||||
<h2>Pages ({len(pages_list)})</h2>
|
||||
<pre>{json.dumps(pages_list, indent=2, default=str)}</pre>
|
||||
</body>
|
||||
</html>""")
|
||||
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
|
||||
|
||||
|
||||
# ── View renderers (v1.6.0) ──
|
||||
|
||||
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
if view_type == "calendar":
|
||||
return _render_calendar(view_type, collection, pages, config)
|
||||
elif view_type == "gallery":
|
||||
return _render_gallery(view_type, collection, pages, config)
|
||||
elif view_type == "list":
|
||||
return _render_list(view_type, collection, pages, config)
|
||||
elif view_type == "timeline":
|
||||
return _render_timeline(view_type, collection, pages, config)
|
||||
else:
|
||||
return _render_table(view_type, collection, pages, config)
|
||||
|
||||
|
||||
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
|
||||
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
|
||||
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
|
||||
.tab:hover,.tab.active{{background:#333;color:#fff}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
</div>
|
||||
{body}
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
from datetime import date as dt_date, timedelta
|
||||
today = dt_date.today()
|
||||
# Determine month/year from config or current
|
||||
year = config.get("year", today.year)
|
||||
month = config.get("month", today.month)
|
||||
first = dt_date(year, month, 1)
|
||||
# Start from Monday of first week
|
||||
start = first - timedelta(days=first.weekday())
|
||||
days_in_month = []
|
||||
for i in range(42): # 6 weeks
|
||||
d = start + timedelta(days=i)
|
||||
days_in_month.append(d)
|
||||
|
||||
# Map pages to dates
|
||||
date_pages: dict[str, list[dict]] = {}
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
for v in props.values():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
d = v[:10]
|
||||
date_pages.setdefault(d, []).append(p)
|
||||
break
|
||||
|
||||
cells = ""
|
||||
for d in days_in_month:
|
||||
iso = d.isoformat()
|
||||
items = date_pages.get(iso, [])
|
||||
other_month = " other-month" if d.month != month else ""
|
||||
today_class = " today" if d == today else ""
|
||||
items_html = "".join(
|
||||
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
|
||||
for p in items
|
||||
)
|
||||
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
|
||||
|
||||
prev = first - timedelta(days=1)
|
||||
next_month = first + timedelta(days=32)
|
||||
next_month = next_month.replace(day=1)
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
|
||||
<style>
|
||||
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
|
||||
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
|
||||
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
|
||||
.cal-day.other-month{{opacity:.35}}
|
||||
.cal-day.today{{background:#1a2744}}
|
||||
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
|
||||
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
|
||||
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
|
||||
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
</div>
|
||||
<div class="calendar">
|
||||
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
|
||||
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
|
||||
{cells}
|
||||
</div>
|
||||
<p class="desc">{len(pages)} pages in collection</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
card_size = config.get("card_size", "medium")
|
||||
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
|
||||
|
||||
cards = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
cover_url = config.get("cover_property")
|
||||
cover_html = ""
|
||||
if cover_url:
|
||||
for k, v in props.items():
|
||||
if isinstance(v, list) and len(v) > 0:
|
||||
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
|
||||
if url.startswith("http"):
|
||||
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
|
||||
break
|
||||
|
||||
prop_tags = "".join(
|
||||
f"<span class='gal-prop'>{str(v)[:30]}</span>"
|
||||
for v in list(props.values())[:3] if v
|
||||
)
|
||||
|
||||
cards += f"""<div class='gal-card'>
|
||||
{cover_html}
|
||||
<div class='gal-body'>
|
||||
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
|
||||
<div class='gal-props'>{prop_tags}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
|
||||
<style>
|
||||
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
|
||||
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
|
||||
.gal-card:hover{{border-color:#555}}
|
||||
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
|
||||
.gal-body{{padding:12px}}
|
||||
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
|
||||
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
|
||||
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
|
||||
</style>
|
||||
<div class="gallery">{cards}</div>
|
||||
<p class="desc">{len(pages)} cards</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
items = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
|
||||
items += f"""<div class='list-item'>
|
||||
<span class='list-icon'>{p.get('icon','📄')}</span>
|
||||
<div class='list-content'>
|
||||
<div class='list-title'>{p['title']}</div>
|
||||
<div class='list-preview'>{preview}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
|
||||
<style>
|
||||
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
|
||||
.list-item:hover{{border-color:#444}}
|
||||
.list-icon{{font-size:18px;margin-top:1px}}
|
||||
.list-content{{flex:1;min-width:0}}
|
||||
.list-title{{font-size:14px;font-weight:500}}
|
||||
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
</style>
|
||||
{items}
|
||||
<p class="desc">{len(pages)} items</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
# Find date range
|
||||
dates = []
|
||||
page_dates = []
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
start_val = end_val = None
|
||||
for k, v in props.items():
|
||||
if isinstance(v, str) and v.startswith("20"):
|
||||
if "..." in v:
|
||||
parts = v.split("...")
|
||||
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
|
||||
else:
|
||||
start_val = end_val = v[:10]
|
||||
break
|
||||
if start_val:
|
||||
dates.append(start_val)
|
||||
if end_val:
|
||||
dates.append(end_val)
|
||||
page_dates.append((p, start_val, end_val or start_val))
|
||||
|
||||
if not dates:
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
|
||||
"<p class='desc'>No date data to display timeline.</p>")
|
||||
|
||||
from datetime import date as dt_date
|
||||
min_date = min(dt_date.fromisoformat(d) for d in dates)
|
||||
max_date = max(dt_date.fromisoformat(d) for d in dates)
|
||||
total = (max_date - min_date).days or 1
|
||||
|
||||
bars = ""
|
||||
for p, start, end in page_dates:
|
||||
sd = dt_date.fromisoformat(start)
|
||||
ed = dt_date.fromisoformat(end)
|
||||
left = (sd - min_date).days / total * 100
|
||||
width = max((ed - sd).days / total * 100, 1)
|
||||
bars += f"""<div class='tl-row'>
|
||||
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
|
||||
<div class='tl-track'>
|
||||
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
|
||||
<style>
|
||||
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
|
||||
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
||||
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
|
||||
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
|
||||
</style>
|
||||
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
|
||||
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
|
||||
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
|
||||
</div>
|
||||
{bars}
|
||||
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
|
||||
""")
|
||||
|
||||
|
||||
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
|
||||
rows = ""
|
||||
for p in pages:
|
||||
props = json.loads(p.get("property_values_json", "{}"))
|
||||
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
|
||||
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
|
||||
|
||||
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
|
||||
<style>
|
||||
table{{width:100%;border-collapse:collapse}}
|
||||
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
|
||||
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
|
||||
tr:hover td{{background:#222}}
|
||||
</style>
|
||||
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<p class="desc">{len(pages)} rows</p>
|
||||
""")
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
|
||||
+195
-4
@@ -34,7 +34,7 @@ def test_health(client):
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert data["db"] is True
|
||||
assert data["version"] == "1.5.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.5.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.5.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.5.0: Relations, Rollups, Formulas ──
|
||||
# ── v1.8.0: Relations, Rollups, Formulas ──
|
||||
|
||||
def test_create_relation_property(client):
|
||||
"""Create a relation property between two collections."""
|
||||
@@ -605,3 +605,194 @@ def test_formula_empty_expression(client):
|
||||
"""Empty expression should return 400."""
|
||||
resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── v1.8.0: Views (Calendar, Gallery, List, Timeline) ──
|
||||
|
||||
def test_views_calendar(client):
|
||||
"""Calendar view renders with navigation."""
|
||||
r = client.post("/db/api", json={"name": "Cal DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
|
||||
resp = client.get(f"/db/{cid}/view/calendar")
|
||||
assert resp.status_code == 200
|
||||
assert "July" in resp.text or "juillet" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_gallery(client):
|
||||
"""Gallery view renders card grid."""
|
||||
r = client.post("/db/api", json={"name": "Gal DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"})
|
||||
resp = client.get(f"/db/{cid}/view/gallery")
|
||||
assert resp.status_code == 200
|
||||
assert "gallery" in resp.text.lower() or "gal-card" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_list(client):
|
||||
"""List view renders compact items."""
|
||||
r = client.post("/db/api", json={"name": "List DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"})
|
||||
resp = client.get(f"/db/{cid}/view/list")
|
||||
assert resp.status_code == 200
|
||||
assert "list-item" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_timeline(client):
|
||||
"""Timeline view renders date bars."""
|
||||
r = client.post("/db/api", json={"name": "TL DB"})
|
||||
cid = r.json()["id"]
|
||||
client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}})
|
||||
resp = client.get(f"/db/{cid}/view/timeline")
|
||||
assert resp.status_code == 200
|
||||
assert "tl-bar" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_default_table(client):
|
||||
"""Default view renders as table."""
|
||||
r = client.post("/db/api", json={"name": "Tab DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}")
|
||||
assert resp.status_code == 200
|
||||
assert "<table>" in resp.text
|
||||
client.delete(f"/db/api/{cid}")
|
||||
|
||||
|
||||
def test_views_calendar_navigation(client):
|
||||
"""Calendar supports month navigation via query params."""
|
||||
r = client.post("/db/api", json={"name": "Nav DB"})
|
||||
cid = r.json()["id"]
|
||||
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user