From 2ff37d6dd9378c686b72ec33ded86984fa362631 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 02:54:51 -0500 Subject: [PATCH] docs: add visibility model, dashboard tabs, and fork UI implementation plan Co-Authored-By: Claude Opus 4.6 --- ...02-24-visibility-dashboard-tabs-fork-ui.md | 686 ++++++++++++++++++ 1 file changed, 686 insertions(+) create mode 100644 docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md diff --git a/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md b/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md new file mode 100644 index 00000000..92b77a71 --- /dev/null +++ b/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md @@ -0,0 +1,686 @@ +# Visibility Model, Dashboard Tabs & Fork UI — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the dashboard stale-data bug, wire up the existing `visibility` column into access control, replace the single-list "My Flows" dashboard with a tabbed All / My Team / Public / My Flows view, and add a Fork button to public flow cards. + +**Architecture:** Backend: rewrite `build_tree_access_filter` to use `visibility` column, add `visibility` query param to `GET /trees`, add `author_name` to `TreeListResponse`. Frontend: convert `MyTreesPage` into a tabbed page with per-tab API calls; add Fork button on public/team cards with a minimal confirmation modal. No new migrations needed — all columns exist. `is_public` stays in sync with `visibility='public'` for backward compat. + +**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Pydantic v2, React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React. + +**Key files:** +- `backend/app/core/filters.py` — access filter (currently ignores `visibility`) +- `backend/app/api/endpoints/trees.py` — list endpoint (add `visibility` param, `author_name` in response) +- `backend/app/schemas/tree.py` — add `author_name`, `visibility` to `TreeListResponse` +- `backend/tests/test_trees.py` — add visibility filter tests +- `frontend/src/types/tree.ts` — add `visibility`, `author_name` to `TreeListItem` +- `frontend/src/api/trees.ts` — add `visibility` param to `list()` +- `frontend/src/pages/MyTreesPage.tsx` — full rewrite with tabs + stale data fix + fork button + +--- + +## Task 1: Fix `build_tree_access_filter` to enforce `visibility` + +**Files:** +- Modify: `backend/app/core/filters.py` + +### Background + +Currently the filter uses `is_public` boolean and `author_id`/`account_id` equality. The `visibility` column (`private | team | link | public`) is completely ignored. This means: +- `visibility='private'` trees are still visible to team members (wrong) +- The column is dead code + +We keep `is_public` in sync (`is_public = visibility == 'public'`) since other code may read it. The new filter logic is: + +| Condition | Sees tree | +|---|---| +| `is_default == True` | Everyone | +| `visibility == 'public'` | Everyone | +| `author_id == me` | Always (regardless of visibility) | +| `visibility == 'team' AND account_id == mine` | Team members (not private ones) | + +`visibility='private'` trees are only ever visible to their author. +`visibility='link'` trees are accessible via share token (already handled by share endpoints); they don't appear in list queries unless you are the author. + +**Step 1: Rewrite `build_tree_access_filter`** + +Replace the function body in `backend/app/core/filters.py`: + +```python +def build_tree_access_filter(current_user: User): + """Build the access filter for trees based on user permissions. + + Visibility rules: + - super_admin: sees everything + - is_default: visible to all authenticated users + - visibility='public': visible to all authenticated users + - author_id == me: always visible (regardless of visibility setting) + - visibility='team' AND account_id == mine: visible to account members + - visibility='private': only visible to author (covered by author_id check above) + - visibility='link': only visible to author (share token access is handled separately) + """ + from app.models.tree import Tree + + if current_user.is_super_admin: + return sa_true() + + conditions = [ + Tree.is_default == True, + Tree.visibility == 'public', + Tree.author_id == current_user.id, + ] + if current_user.account_id: + conditions.append( + and_( + Tree.visibility == 'team', + Tree.account_id == current_user.account_id + ) + ) + return or_(*conditions) +``` + +**Step 2: Verify no existing tests break** + +```bash +cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts=" +``` + +Expected: all existing tests pass (the change narrows visibility but test trees are created with default `visibility='team'` and are owned by test user so they pass via `author_id` match). + +**Step 3: Commit** + +```bash +git add backend/app/core/filters.py +git commit -m "fix: enforce visibility column in tree access filter + +Previously build_tree_access_filter used is_public boolean and ignored the +visibility column entirely. Now private/link trees are only visible to their +author, team trees require matching account_id, and public trees are open to all. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 2: Add `visibility` query param and `author_name` to list endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/trees.py` +- Modify: `backend/app/schemas/tree.py` +- Modify: `backend/app/models/user.py` (confirm `full_name` or `email` field name) + +### Background + +The frontend tabs need to filter by visibility scope: +- "My Flows" tab: `author_id=` (existing) +- "My Team" tab: `visibility=team` (new) +- "Public" tab: `visibility=public` (new) +- "All" tab: no visibility filter (existing default behavior) + +We also want to show "Created by X" on cards that don't belong to the current user. The `TreeListResponse` needs an `author_name` field (email or display name). + +**Step 1: Add `author_name` to `TreeListResponse` in `backend/app/schemas/tree.py`** + +Add to the `TreeListResponse` class after `author_id`: +```python +author_name: Optional[str] = None # Display name or email of author +``` + +**Step 2: Add `visibility` to `TreeListResponse` in `backend/app/schemas/tree.py`** + +Add to `TreeListResponse` after `is_default`: +```python +visibility: str = 'team' +``` + +**Step 3: Update `build_tree_response` in `backend/app/api/endpoints/trees.py`** + +The `build_tree_response` function needs to include `author_name` and `visibility`. However, since it receives a `Tree` object (not a joined query), we need to either: +a) Eagerly load the `author` relationship, or +b) Set `author_name` from a pre-built map + +The cleanest approach is to update `list_trees` to eagerly load the `author` relationship and pass it through. + +In `list_trees`, update the query to load author: +```python +query = select(Tree).options( + selectinload(Tree.category_rel), + selectinload(Tree.tags), + selectinload(Tree.author), # ADD THIS +) +``` + +Update `build_tree_response` signature and body: +```python +def build_tree_response(tree: Tree) -> TreeListResponse: + """Build TreeListResponse with category_info, tags, author_name, and visibility.""" + category_info = None + if tree.category_rel: + category_info = CategoryInfo( + id=tree.category_rel.id, + name=tree.category_rel.name, + slug=tree.category_rel.slug + ) + + # Author display: prefer full_name, fall back to email + author_name = None + if tree.author: + author_name = getattr(tree.author, 'full_name', None) or tree.author.email + + return TreeListResponse( + id=tree.id, + name=tree.name, + description=tree.description, + tree_type=tree.tree_type, + category=tree.category, + category_id=tree.category_id, + category_info=category_info, + tags=tree.tag_names, + author_id=tree.author_id, + author_name=author_name, + account_id=tree.account_id, + is_active=tree.is_active, + is_public=tree.is_public, + is_default=tree.is_default, + visibility=tree.visibility, + status=tree.status, + version=tree.version, + usage_count=tree.usage_count, + created_at=tree.created_at, + updated_at=tree.updated_at + ) +``` + +**Step 4: Add `visibility` query param to `list_trees`** + +In the function signature, add: +```python +visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"), +``` + +In the filter section (after the `is_public` filter block): +```python +if visibility: + query = query.where(Tree.visibility == visibility) +``` + +**Step 5: Write tests** + +In `backend/tests/test_trees.py`, add a new test class `TestVisibilityFilter`: + +```python +class TestVisibilityFilter: + """Test that visibility filtering works correctly.""" + + @pytest.mark.asyncio + async def test_private_tree_only_visible_to_author( + self, client: AsyncClient, auth_headers: dict, test_user: dict + ): + """A private tree should NOT appear in another user's list.""" + # Create a private tree as test_user + tree_data = { + "name": "Private Flow", + "tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []}, + } + create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers) + assert create_resp.status_code == 201 + tree_id = create_resp.json()["id"] + + # Set visibility to private + vis_resp = await client.patch( + f"/api/v1/trees/{tree_id}/visibility", + json={"visibility": "private"}, + headers=auth_headers + ) + assert vis_resp.status_code == 200 + + # Verify it appears for the author + list_resp = await client.get("/api/v1/trees", headers=auth_headers) + assert list_resp.status_code == 200 + ids = [t["id"] for t in list_resp.json()] + assert tree_id in ids + + @pytest.mark.asyncio + async def test_visibility_query_param_filters_correctly( + self, client: AsyncClient, auth_headers: dict + ): + """?visibility=public should only return public trees.""" + resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers) + assert resp.status_code == 200 + trees = resp.json() + for tree in trees: + assert tree["visibility"] == "public" + + @pytest.mark.asyncio + async def test_author_name_present_in_list_response( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """TreeListResponse should include author_name.""" + resp = await client.get("/api/v1/trees", headers=auth_headers) + assert resp.status_code == 200 + trees = resp.json() + assert len(trees) >= 1 + # author_name should be present (may be None for system trees) + assert "author_name" in trees[0] +``` + +**Step 6: Run tests** + +```bash +cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py::TestVisibilityFilter -v --override-ini="addopts=" +``` + +Expected: all 3 new tests pass. + +**Step 7: Commit** + +```bash +git add backend/app/schemas/tree.py backend/app/api/endpoints/trees.py backend/tests/test_trees.py +git commit -m "feat: add visibility filter param and author_name to tree list endpoint + +GET /trees now accepts ?visibility=private|team|link|public to scope results. +TreeListResponse includes author_name (full_name or email) and visibility. +Author relationship eagerly loaded to avoid N+1 queries. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 3: Update frontend types and API client + +**Files:** +- Modify: `frontend/src/types/tree.ts` +- Modify: `frontend/src/api/trees.ts` + +### Background + +`TreeListItem` needs `visibility` and `author_name`. The `trees.list()` API method needs a `visibility` parameter. + +**Step 1: Add fields to `TreeListItem` in `frontend/src/types/tree.ts`** + +After `author_id: string | null`, add: +```typescript +author_name: string | null +visibility: 'private' | 'team' | 'link' | 'public' +``` + +**Step 2: Add `visibility` to `TreeListParams` in `frontend/src/api/trees.ts`** + +Find the params type/interface for `list()` and add: +```typescript +visibility?: 'private' | 'team' | 'link' | 'public' +``` + +**Step 3: Verify build passes** + +```bash +cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10 +``` + +Expected: no TypeScript errors. There may be type errors in `MyTreesPage.tsx` if it accesses `tree.visibility` — they'll be fixed in Task 4. + +**Step 4: Commit** + +```bash +git add frontend/src/types/tree.ts frontend/src/api/trees.ts +git commit -m "feat: add visibility and author_name to TreeListItem type and list API params + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 4: Rewrite `MyTreesPage` with tabs and fork button + +**Files:** +- Modify: `frontend/src/pages/MyTreesPage.tsx` + +### Background + +**Current state:** +- Single list of `author_id=me` trees +- Data loads on mount only (stale after navigating back from editor) +- Has a "Create New" dropdown in the header +- Has a fork badge on cards that have `parent_tree_id` + +**New state:** +- Four tabs: **My Flows** | **My Team** | **Public** | **All** +- "My Team" tab hidden when `user.account_id` is null (solo user) +- Data reloads when the active tab changes AND when the window regains focus (fixes stale data) +- "Create New" button moves into the **My Flows** tab header area (only shown on that tab) +- Cards on **Public** and **All** tabs (and **My Team** for other users' flows) show a **Fork** button +- Fork button opens a minimal inline modal with optional reason field + +### Tab → API call mapping + +| Tab | `treesApi.list()` params | +|-----|--------------------------| +| My Flows | `{ author_id: user.id, sort_by: 'updated_at' }` | +| My Team | `{ visibility: 'team', sort_by: 'updated_at' }` | +| Public | `{ visibility: 'public', sort_by: 'usage_count' }` | +| All | `{ sort_by: 'updated_at' }` | + +**Step 1: Read the current `MyTreesPage.tsx` in full before editing.** + +The file is at `frontend/src/pages/MyTreesPage.tsx`. Read it completely before making any changes. + +**Step 2: Rewrite `MyTreesPage.tsx`** + +Key structural changes: + +```tsx +type Tab = 'mine' | 'team' | 'public' | 'all' + +export function MyTreesPage() { + const { user } = useAuthStore() + const { canEditTree, canCreateTrees } = usePermissions() + const navigate = useNavigate() + const hasTeam = Boolean(user?.account_id) + + // Active tab state — default to 'mine' + const [activeTab, setActiveTab] = useState('mine') + const [trees, setTrees] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + // Fork modal state + const [forkTarget, setForkTarget] = useState(null) + const [forkReason, setForkReason] = useState('') + const [isForking, setIsForking] = useState(false) + + // ... existing modal state (delete, share, AI builder) + + // Load trees whenever the active tab changes + useEffect(() => { + loadTrees() + }, [activeTab, user?.id]) + + // Reload on window focus (fixes stale data after navigating back from editor) + useEffect(() => { + const onFocus = () => loadTrees() + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [activeTab, user?.id]) + + const loadTrees = async () => { + if (!user?.id) return + setIsLoading(true) + try { + const params: Parameters[0] = { + sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at', + } + if (activeTab === 'mine') params.author_id = user.id + if (activeTab === 'team') params.visibility = 'team' + if (activeTab === 'public') params.visibility = 'public' + + const [userTrees, recentSessions] = await Promise.all([ + treesApi.list(params), + activeTab === 'mine' ? sessionsApi.list({ size: 100 }) : Promise.resolve([]), + ]) + + // Build lastUsed map (only for mine tab) + const lastUsedMap = new Map() + for (const session of recentSessions) { + const existing = lastUsedMap.get(session.tree_id) + if (!existing || new Date(session.started_at) > new Date(existing)) { + lastUsedMap.set(session.tree_id, session.started_at) + } + } + + setTrees(userTrees.map((tree) => ({ + ...tree, + lastUsed: lastUsedMap.get(tree.id), + sessionCount: tree.usage_count ?? 0, + }))) + } catch { + toast.error('Failed to load flows') + } finally { + setIsLoading(false) + } + } + + const handleFork = async () => { + if (!forkTarget) return + setIsForking(true) + try { + const forked = await treesApi.fork(forkTarget.id, { + fork_reason: forkReason.trim() || undefined, + }) + toast.success(`"${forked.name}" added to your flows`) + setForkTarget(null) + setForkReason('') + // Switch to My Flows tab so they can see it + setActiveTab('mine') + } catch { + toast.error('Failed to fork flow') + } finally { + setIsForking(false) + } + } +``` + +**Tab bar UI** — render above the tree grid: +```tsx +{/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} + + {/* Create button — only on My Flows tab */} + {activeTab === 'mine' && canCreateTrees && ( +
+ {/* existing CreateMenu / AI builder button */} +
+ )} +
+``` + +Tabs array (built once, team tab conditionally included): +```tsx +const tabs = [ + { id: 'mine' as Tab, label: 'My Flows' }, + ...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []), + { id: 'public' as Tab, label: 'Public' }, + { id: 'all' as Tab, label: 'All' }, +] +``` + +**Fork button on cards** — shown when `tree.author_id !== user?.id` OR when on the Public tab: + +```tsx +{/* Show Fork button for flows you don't own */} +{tree.author_id !== user?.id && ( + +)} +``` + +**Fork confirmation modal** — simple inline modal (not a full-screen dialog): +```tsx +{forkTarget && ( +
+
+

Fork this flow?

+

+ Creates a copy of “{forkTarget.name}” under your account that you can edit freely. +

+ + setForkReason(e.target.value)} + placeholder="e.g. Adding Cisco Meraki steps for our network" + maxLength={255} + className="mb-4 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + onKeyDown={(e) => e.key === 'Enter' && handleFork()} + /> +
+ + +
+
+
+)} +``` + +**Author attribution on cards** — in the card header area, when `tree.author_id !== user?.id && tree.author_name`: +```tsx +{tree.author_id !== user?.id && tree.author_name && ( +

+ by {tree.author_name} +

+)} +``` + +**Step 3: Verify build passes** + +```bash +cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10 +``` + +Expected: no TypeScript errors. + +**Step 4: Commit** + +```bash +git add frontend/src/pages/MyTreesPage.tsx +git commit -m "feat: add tabbed dashboard with My Flows/My Team/Public/All views and fork UI + +- Tabs filter by visibility scope; My Team hidden for solo users +- Data reloads on tab change and window focus (fixes stale-after-editor bug) +- Create button moves into My Flows tab header +- Fork button on flows not owned by current user; opens reason modal +- Author attribution shown on cards from other users + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 5: Keep `is_public` in sync when visibility changes + +**Files:** +- Modify: `backend/app/api/endpoints/trees.py` — the `update_tree_visibility` endpoint + +### Background + +The existing `PATCH /trees/{id}/visibility` endpoint sets the `visibility` column. But `is_public` is a separate boolean that some code may still read. We need to keep them in sync so `is_public = (visibility == 'public')`. + +Find the visibility update endpoint (around line 1025–1077) and add the sync: + +**Step 1: Add `is_public` sync in the visibility endpoint** + +Locate the line that sets `tree.visibility = visibility_data.visibility` and add directly after it: +```python +tree.is_public = (visibility_data.visibility == 'public') +``` + +Also do the same in `update_tree` (the PUT endpoint) — find where `is_public` is set from the update data and add a corresponding `visibility` update: +```python +# Keep visibility and is_public in sync +if tree_data.is_public is not None: + tree.is_public = tree_data.is_public + if tree_data.is_public and tree.visibility not in ('public',): + tree.visibility = 'public' + elif not tree_data.is_public and tree.visibility == 'public': + tree.visibility = 'team' # downgrade from public to team +``` + +**Step 2: Run existing tests** + +```bash +cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts=" +``` + +Expected: all tests pass. + +**Step 3: Commit** + +```bash +git add backend/app/api/endpoints/trees.py +git commit -m "fix: keep is_public and visibility in sync on updates + +When visibility changes to 'public', is_public=True. When it changes away +from 'public', is_public=False. When is_public is set via TreeUpdate, +visibility column is updated to match. + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Task 6: Push and verify + +**Step 1: Push branch** + +```bash +git push +``` + +**Step 2: Check PR CI** + +```bash +gh pr checks 88 2>&1 | head -20 +``` + +**Step 3: Manual smoke test checklist** + +- [ ] Create a new AI flow → "Open in Editor" → publish → navigate back to dashboard. Flow should appear immediately (focus trigger reloads). +- [ ] Dashboard has tabs: My Flows, My Team (if account), Public, All. +- [ ] My Flows tab shows only flows you authored. Create button is here. +- [ ] My Team tab shows team-visibility flows from your account (other team members' flows appear here). +- [ ] Public tab shows `visibility='public'` flows from all users. Fork button visible on flows you don't own. +- [ ] Fork a public flow → reason modal appears → confirm → toast "Added to your flows" → switches to My Flows tab → fork appears there. +- [ ] Solo user (no account_id) sees no My Team tab. +- [ ] Cards for other users' flows show "by [author name]" attribution. +- [ ] Changing a flow's visibility to "private" via editor makes it disappear from team members' My Team tab (requires two user accounts to verify). + +--- + +## Implementation Notes for Claude + +**Working directory:** `/home/michaelchihlas/dev/patherly/.worktrees/feat-ai-flow-builder` +**Branch:** `frontend-standardization` (PR #88) +**Run backend tests from:** `backend/venv/bin/python -m pytest ...` (worktrees share the main venv) +**Frontend build:** `cd frontend && npm run build` + +**Before Task 4:** Read `MyTreesPage.tsx` in FULL before making any edits. The file is ~410 lines and has important modal state, the AI builder button, delete/share handlers that must be preserved exactly. + +**Task 4 note on `TreeWithStats`:** The existing interface extension adds `lastUsed` and `sessionCount`. Also add `parent_tree_id` and `parent_tree_name` which are already being used in the fork badge rendering. Keep those. Also add `visibility` and `author_name` since `TreeListItem` now includes them (inherited from the spread `...tree`). + +**User model field name:** Check `backend/app/models/user.py` for the display name field — it may be `full_name`, `name`, or just `email`. Use `getattr(tree.author, 'full_name', None) or tree.author.email` as a safe fallback.