fix: move tabbed flows view to Dashboard, restore MyTreesPage
Tabs (My Flows / My Team / Public / All) belong on the Dashboard page (/), not on the separate Flow Editor page (/my-trees). MyTreesPage is restored to its original state. QuickStartPage gets the tab bar replacing the 'My Flows' section header, with Create button visible on My Flows tab only, and window focus reload to fix stale data after returning from editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench, Sparkles } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem, TreeFilters } from '@/types'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { ShareTreeModal } from '@/components/library/ShareTreeModal'
|
||||
@@ -22,8 +22,6 @@ interface TreeWithStats extends TreeListItem {
|
||||
parent_tree_name?: string | null
|
||||
}
|
||||
|
||||
type Tab = 'mine' | 'team' | 'public' | 'all'
|
||||
|
||||
export function MyTreesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
@@ -38,26 +36,23 @@ export function MyTreesPage() {
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const [aiEnabled, setAiEnabled] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
||||
const [forkReason, setForkReason] = useState('')
|
||||
const [isForking, setIsForking] = useState(false)
|
||||
|
||||
const loadTrees = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
loadMyTrees()
|
||||
}, [user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
aiBuilderApi.getQuota().then((q) => setAiEnabled(q.ai_enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadMyTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: TreeFilters = {
|
||||
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'
|
||||
// 'all' tab: no author/visibility filter
|
||||
|
||||
// Fetch trees and recent sessions in parallel (2 API calls total, not N+1)
|
||||
const [userTrees, recentSessions] = await Promise.all([
|
||||
treesApi.list(params),
|
||||
activeTab === 'mine' ? sessionsApi.list({ size: 100 }) : Promise.resolve([]),
|
||||
treesApi.list({ author_id: user.id }),
|
||||
sessionsApi.list({ size: 100 }),
|
||||
])
|
||||
|
||||
// Build a map of tree_id -> most recent session start time
|
||||
@@ -77,34 +72,12 @@ export function MyTreesPage() {
|
||||
|
||||
setTrees(treesWithStats)
|
||||
} catch (err) {
|
||||
toast.error('Failed to load flows')
|
||||
toast.error('Failed to load your flows')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [activeTab, user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrees()
|
||||
}, [loadTrees])
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => loadTrees()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [loadTrees])
|
||||
|
||||
useEffect(() => {
|
||||
aiBuilderApi.getQuota().then((q) => setAiEnabled(q.ai_enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const hasTeam = Boolean(user?.account_id)
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'mine', label: 'My Flows' },
|
||||
...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []),
|
||||
{ id: 'public', label: 'Public' },
|
||||
{ id: 'all', label: 'All' },
|
||||
]
|
||||
}
|
||||
|
||||
const handleStartSession = (tree: TreeWithStats) => {
|
||||
if (tree.tree_type === 'maintenance') {
|
||||
@@ -138,24 +111,6 @@ export function MyTreesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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('')
|
||||
setActiveTab('mine')
|
||||
} catch {
|
||||
toast.error('Failed to fork flow')
|
||||
} finally {
|
||||
setIsForking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
@@ -167,105 +122,82 @@ export function MyTreesPage() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">My Flows</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Your forked and custom flows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-1 border-b border-border mb-4">
|
||||
{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'
|
||||
{canCreateTrees && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCreateMenu(!showCreateMenu)}
|
||||
className="flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create New
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{showCreateMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowCreateMenu(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||
<Link
|
||||
to="/trees/new"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Troubleshooting Tree</div>
|
||||
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Procedural Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new?type=maintenance"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Wrench className="h-4 w-4 text-amber-400" />
|
||||
<div>
|
||||
<div className="font-medium">Maintenance Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateMenu(false)
|
||||
setShowAIBuilder(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Build with AI</div>
|
||||
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Create button — only on My Flows tab */}
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<div className="ml-auto pb-1.5">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCreateMenu(!showCreateMenu)}
|
||||
className="flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create New
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{showCreateMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowCreateMenu(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||
<Link
|
||||
to="/trees/new"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Troubleshooting Tree</div>
|
||||
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Procedural Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new?type=maintenance"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Wrench className="h-4 w-4 text-amber-400" />
|
||||
<div>
|
||||
<div className="font-medium">Maintenance Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateMenu(false)
|
||||
setShowAIBuilder(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Build with AI</div>
|
||||
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,41 +210,33 @@ export function MyTreesPage() {
|
||||
) : trees.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-accent px-4 py-12 text-center">
|
||||
<FolderTree className="mx-auto mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">No flows found</h2>
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">No personal flows yet</h2>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{activeTab === 'mine'
|
||||
? 'Fork a flow from the library to customize it for your workflow'
|
||||
: activeTab === 'team'
|
||||
? 'No team flows found'
|
||||
: activeTab === 'public'
|
||||
? 'No public flows found'
|
||||
: 'No flows found'}
|
||||
Fork a flow from the library to customize it for your workflow
|
||||
</p>
|
||||
{activeTab === 'mine' && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to="/trees"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
|
||||
'hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Browse Library
|
||||
</Link>
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to="/trees"
|
||||
to="/trees/new"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium',
|
||||
'hover:opacity-90'
|
||||
'inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Browse Library
|
||||
<Plus className="h-4 w-4" />
|
||||
Create from Scratch
|
||||
</Link>
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create from Scratch
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -330,14 +254,7 @@ export function MyTreesPage() {
|
||||
{tree.tree_type === 'maintenance' && (
|
||||
<Wrench className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
||||
{tree.author_id !== user?.id && tree.author_name && (
|
||||
<p className="text-[10px] font-label text-muted-foreground">
|
||||
by {tree.author_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">{tree.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tree.tree_type === 'procedural' && (
|
||||
@@ -399,7 +316,7 @@ export function MyTreesPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartSession(tree)}
|
||||
@@ -423,16 +340,6 @@ export function MyTreesPage() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -499,48 +406,6 @@ export function MyTreesPage() {
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
|
||||
{/* Fork Confirmation Modal */}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, Loader2, Star, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Search, Loader2, Star, ChevronLeft, ChevronRight, GitBranch } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { TreeListItem, TreeFilters } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
@@ -21,6 +21,7 @@ import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
@@ -66,6 +67,16 @@ export function QuickStartPage() {
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Tab state
|
||||
type Tab = 'mine' | 'team' | 'public' | 'all'
|
||||
const hasTeam = Boolean(user?.account_id)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||
const [forkReason, setForkReason] = useState('')
|
||||
const [isForking, setIsForking] = useState(false)
|
||||
|
||||
// Pin store
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const pinnedIsLoading = usePinnedFlowsStore((s) => s.isLoading)
|
||||
@@ -102,34 +113,29 @@ export function QuickStartPage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Load my flows when page/size or user changes
|
||||
useEffect(() => {
|
||||
// Load flows — tab-aware
|
||||
const loadFlows = useCallback(async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoadingFlows(true)
|
||||
setAllFlowsCeiling(false)
|
||||
|
||||
const loadFlows = async () => {
|
||||
setIsLoadingFlows(true)
|
||||
setAllFlowsCeiling(false)
|
||||
|
||||
try {
|
||||
if (pageSize === 'all') {
|
||||
// Fetch in chunks of 100, max 500
|
||||
let allItems: TreeListItem[] = []
|
||||
let skip = 0
|
||||
const CHUNK = 100
|
||||
const MAX = 500
|
||||
|
||||
while (true) {
|
||||
const chunk = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
limit: CHUNK,
|
||||
skip,
|
||||
})
|
||||
const params: TreeFilters = { sort_by: 'updated_at', limit: CHUNK, skip }
|
||||
if (activeTab === 'mine') params.author_id = user.id
|
||||
if (activeTab === 'team') params.visibility = 'team'
|
||||
if (activeTab === 'public') { params.visibility = 'public'; params.sort_by = 'usage_count' }
|
||||
|
||||
const chunk = await treesApi.list(params)
|
||||
allItems = [...allItems, ...chunk]
|
||||
if (chunk.length < CHUNK || allItems.length >= MAX) {
|
||||
if (allItems.length >= MAX) {
|
||||
allItems = allItems.slice(0, MAX)
|
||||
setAllFlowsCeiling(true)
|
||||
}
|
||||
if (allItems.length >= MAX) { allItems = allItems.slice(0, MAX); setAllFlowsCeiling(true) }
|
||||
break
|
||||
}
|
||||
skip += CHUNK
|
||||
@@ -138,20 +144,34 @@ export function QuickStartPage() {
|
||||
setHasNextPage(false)
|
||||
} else {
|
||||
const numSize = pageSize as number
|
||||
const response = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
const params: TreeFilters = {
|
||||
sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at',
|
||||
limit: numSize + 1,
|
||||
skip: (page - 1) * numSize,
|
||||
})
|
||||
}
|
||||
if (activeTab === 'mine') params.author_id = user.id
|
||||
if (activeTab === 'team') params.visibility = 'team'
|
||||
if (activeTab === 'public') params.visibility = 'public'
|
||||
|
||||
const response = await treesApi.list(params)
|
||||
setHasNextPage(response.length > numSize)
|
||||
setMyFlows(response.slice(0, numSize))
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setIsLoadingFlows(false)
|
||||
}
|
||||
}, [user?.id, page, pageSize, activeTab])
|
||||
|
||||
loadFlows().catch(() => setIsLoadingFlows(false))
|
||||
}, [user?.id, page, pageSize])
|
||||
useEffect(() => { loadFlows() }, [loadFlows])
|
||||
|
||||
// Reload on window focus (fixes stale data after returning from editor)
|
||||
useEffect(() => {
|
||||
const onFocus = () => loadFlows()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [loadFlows])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
@@ -219,9 +239,35 @@ export function QuickStartPage() {
|
||||
const handleTagClick = () => {} // Not used on dashboard
|
||||
const handleFolderCreated = () => {} // Not used on dashboard
|
||||
|
||||
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('')
|
||||
setActiveTab('mine')
|
||||
} catch {
|
||||
toast.error('Failed to fork flow')
|
||||
} finally {
|
||||
setIsForking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Page size options
|
||||
const pageSizeOptions: (number | 'all')[] = [10, 25, 50, 'all']
|
||||
|
||||
// Tabs
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'mine', label: 'My Flows' },
|
||||
...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []),
|
||||
{ id: 'public', label: 'Public' },
|
||||
{ id: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -234,14 +280,6 @@ export function QuickStartPage() {
|
||||
Welcome back. Here's what's happening with your flows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
@@ -354,11 +392,31 @@ export function QuickStartPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Flows Section */}
|
||||
{/* My Flows Section — tabbed */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">My Flows</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-3 flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1) }}
|
||||
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>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2 pb-1.5">
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
)}
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,8 +429,16 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
) : myFlows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">You haven't created any flows yet.</p>
|
||||
{canCreateTrees && (
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{activeTab === 'mine'
|
||||
? "You haven't created any flows yet."
|
||||
: activeTab === 'team'
|
||||
? 'No team flows found.'
|
||||
: activeTab === 'public'
|
||||
? 'No public flows found.'
|
||||
: 'No flows found.'}
|
||||
</p>
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
@@ -497,6 +563,48 @@ export function QuickStartPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fork Modal */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
|
||||
Reference in New Issue
Block a user