docs: add visibility model, dashboard tabs, and fork UI implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
686
docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
Normal file
686
docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
Normal file
@@ -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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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=<me>` (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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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<Tab>('mine')
|
||||||
|
const [trees, setTrees] = useState<TreeWithStats[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
// Fork modal state
|
||||||
|
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(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<typeof treesApi.list>[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<string, string>()
|
||||||
|
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 */}
|
||||||
|
<div className="flex items-center gap-1 border-b border-border">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Create button — only on My Flows tab */}
|
||||||
|
{activeTab === 'mine' && canCreateTrees && (
|
||||||
|
<div className="ml-auto pb-1.5">
|
||||||
|
{/* existing CreateMenu / AI builder button */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setForkTarget(tree); setForkReason('') }}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
Fork
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fork confirmation modal** — simple inline modal (not a full-screen dialog):
|
||||||
|
```tsx
|
||||||
|
{forkTarget && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-5 shadow-xl">
|
||||||
|
<h3 className="mb-1 text-sm font-semibold text-foreground">Fork this flow?</h3>
|
||||||
|
<p className="mb-4 text-xs text-muted-foreground">
|
||||||
|
Creates a copy of “{forkTarget.name}” under your account that you can edit freely.
|
||||||
|
</p>
|
||||||
|
<label className="mb-1 block text-xs text-muted-foreground">
|
||||||
|
Why are you forking? <span className="opacity-60">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={forkReason}
|
||||||
|
onChange={(e) => 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()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFork}
|
||||||
|
disabled={isForking}
|
||||||
|
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-gradient-brand py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
{isForking ? 'Forking...' : 'Fork Flow'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForkTarget(null)}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 && (
|
||||||
|
<p className="text-[10px] font-label text-muted-foreground">
|
||||||
|
by {tree.author_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
Reference in New Issue
Block a user