feat: sidebar tree navigation — toggle chevrons + drag & drop
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped

- Add sort_order column to pages table for drag & drop reordering
- Rewrite _sidebar_data with recursive _build_page_tree for nested hierarchy
- Add tree toggle buttons (▶ collapsed / ▼ expanded) on parent items
- Alpine.js expandedNodes state with toggleTreeNode() for expand/collapse
- Jinja2 recursive macro render_tree_item for proper nesting + indentation
- SortableJS integration on .sidebar-items for drag & drop repositioning
- New PUT /board/api/pages/{id}/move API for persisting moves
- Auto-set sort_order on page creation (max+1 for same parent)
- CSS: .tree-toggle, .tree-toggle-placeholder, grab cursor on page-icon
- 28/28 tests pass, all endpoints healthy
This commit is contained in:
2026-07-08 22:27:09 -04:00
parent c1dba816de
commit 755404fbb0
5 changed files with 249 additions and 63 deletions
+83 -23
View File
@@ -115,6 +115,31 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
}
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
if depth >= max_depth:
return []
rows = conn.execute(
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? ORDER BY sort_order ASC, updated_at DESC",
(ws_key, parent_id),
).fetchall()
items = []
for row in rows:
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
items.append({
"id": f"page/{row['id']}",
"db_id": row["id"],
"name": row["title"],
"icon": "📄",
"url": f"/pages/{row['id']}",
"active": False,
"depth": depth,
"has_children": len(children) > 0,
"children": children,
})
return items
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_name = user.get("login", "Bruno") if user else "Bruno"
@@ -134,32 +159,19 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"name": label, "icon": "📁" if first else "·",
"url": f"/board/{owner}/{repo}?view={view}",
"active": active, "indent": indent,
"depth": indent, "has_children": False, "children": [],
})
first = False
# Load pages for this project workspace
# Load pages as nested tree for this project workspace
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, parent_id, updated_at FROM pages WHERE workspace=? AND parent_id IS NULL ORDER BY updated_at DESC LIMIT 10",
(ws_key,),
).fetchall()
for p in pages:
recent.append({
"id": f"page/{p['id']}", "name": p["title"], "icon": "📄",
"url": f"/pages/{p['id']}", "active": False, "indent": 0,
})
# Load sub-pages
subs = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
(p["id"],),
).fetchall()
for sp in subs:
recent.append({
"id": f"page/{sp['id']}", "name": sp["title"], "icon": "·",
"url": f"/pages/{sp['id']}", "active": False, "indent": 1,
})
tree_pages = _build_page_tree(conn, None, ws_key)
for p in tree_pages:
recent.append(p)
# Private pages: same as recent but filtered for page/ items (non-board views)
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": [r for r in recent if r.get("active") or r["id"].startswith("page/")],
"recent_pages": recent, "private_pages": private_items,
"favorite_pages": [], "user": user,
"workspace_key": ws_key}
@@ -455,9 +467,18 @@ async def create_page(request: Request, title: str = Query(default="New page"),
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
with get_conn() as conn:
# Compute next sort_order for this parent
next_order = 0
parent_val = parent_id if parent_id > 0 else None
row = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
(ws_key, parent_val),
).fetchone()
if row:
next_order = row[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, parent_section, parent_id) VALUES (?,?,?,?)",
(ws_key, title, section, parent_id if parent_id > 0 else None),
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
(ws_key, title, section, parent_val, next_order),
)
conn.commit()
page_id = cur.lastrowid
@@ -487,6 +508,45 @@ async def update_page(page_id: int, title: str = Query(default=""),
return {"status": "ok"}
@router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int,
new_parent_id: int = Query(default=0),
new_order: int = Query(default=0)):
"""Move/reorder a page in the tree (drag & drop).
new_parent_id=0 means move to root level.
new_order=0 means append to end (or set explicit position)."""
with get_conn() as conn:
# Verify page exists
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
# Update parent (None for root level)
conn.execute(
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_parent_id if new_parent_id > 0 else None, page_id),
)
# Update sort order
conn.execute(
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id),
)
# Re-index siblings to ensure no gaps
siblings = conn.execute(
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
).fetchall()
# Recompute sort_order for all siblings after the insertion point
insert_point = new_order
reorder = []
for sib in siblings:
if sib["sort_order"] >= insert_point:
reorder.append(sib["id"])
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
conn.commit()
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
"""Render a page as HTML."""
+3
View File
@@ -29,6 +29,9 @@ def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
return {