feat: Phase 1 — pinnedFlowsStore, pagination hook, cached quota hook, sidebar refactor
- Add pin() to pinnedFlowsApi - Create pinnedFlowsStore (Zustand) — single source of truth for pin state - Add dashboardMyFlowsView preference to userPreferencesStore - Create usePaginationParams hook (URL-synced) - Create useCachedQuota hook (5-min TTL) - Sidebar uses pinnedFlowsStore instead of local state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ export const pinnedFlowsApi = {
|
||||
unpin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.delete(`/trees/${treeId}/pin`)
|
||||
},
|
||||
|
||||
pin: async (treeId: string): Promise<void> => {
|
||||
await apiClient.post(`/trees/${treeId}/pin`)
|
||||
},
|
||||
}
|
||||
|
||||
export default pinnedFlowsApi
|
||||
|
||||
@@ -2,32 +2,36 @@ import { useEffect, useState } from 'react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
|
||||
import { NavItem } from './NavItem'
|
||||
import { sessionsApi, treesApi } from '@/api'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
|
||||
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
|
||||
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
const unpinFlow = usePinnedFlowsStore((s) => s.unpin)
|
||||
|
||||
const [activeSessionCount, setActiveSessionCount] = useState(0)
|
||||
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
|
||||
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0, maintenance: 0 })
|
||||
|
||||
// Load pinned flows on mount
|
||||
useEffect(() => {
|
||||
loadPinned()
|
||||
}, [loadPinned])
|
||||
|
||||
// Fetch sidebar data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [activeSessions, allTrees, pinnedData] = await Promise.all([
|
||||
const [activeSessions, allTrees] = await Promise.all([
|
||||
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
|
||||
treesApi.list({ sort_by: 'name' }).catch(() => []),
|
||||
pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })),
|
||||
])
|
||||
setActiveSessionCount(activeSessions.length)
|
||||
setPinnedFlows(pinnedData.items)
|
||||
|
||||
const total = allTrees.length
|
||||
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
|
||||
@@ -41,16 +45,6 @@ export function Sidebar() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const handleUnpin = async (treeId: string) => {
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
setPinnedFlows(prev => prev.filter(f => f.tree_id !== treeId))
|
||||
toast.success('Unpinned from sidebar')
|
||||
} catch {
|
||||
toast.error('Failed to unpin flow')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarWheel = (e: React.WheelEvent<HTMLElement>) => {
|
||||
const sidebar = e.currentTarget
|
||||
const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight
|
||||
@@ -89,7 +83,7 @@ export function Sidebar() {
|
||||
) : (
|
||||
<>
|
||||
{/* Pinned Flows */}
|
||||
<PinnedFlowsSection flows={pinnedFlows} onUnpin={handleUnpin} />
|
||||
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
|
||||
|
||||
31
frontend/src/hooks/useCachedQuota.ts
Normal file
31
frontend/src/hooks/useCachedQuota.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { aiBuilderApi } from '@/api'
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
let cachedResult: { aiEnabled: boolean; timestamp: number } | null = null
|
||||
|
||||
export function useCachedQuota() {
|
||||
const [aiEnabled, setAiEnabled] = useState(cachedResult?.aiEnabled ?? false)
|
||||
const [isLoading, setIsLoading] = useState(!cachedResult)
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
|
||||
setAiEnabled(cachedResult.aiEnabled)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
aiBuilderApi
|
||||
.getQuota()
|
||||
.then((q) => {
|
||||
cachedResult = { aiEnabled: q.ai_enabled, timestamp: Date.now() }
|
||||
setAiEnabled(q.ai_enabled)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
return { aiEnabled, isLoading }
|
||||
}
|
||||
57
frontend/src/hooks/usePaginationParams.ts
Normal file
57
frontend/src/hooks/usePaginationParams.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
type PageSize = number | 'all'
|
||||
|
||||
interface UsePaginationParamsOptions {
|
||||
defaultPageSize?: number
|
||||
allowedPageSizes?: PageSize[]
|
||||
}
|
||||
|
||||
export function usePaginationParams(options: UsePaginationParamsOptions = {}) {
|
||||
const { defaultPageSize = 10, allowedPageSizes = [10, 25, 50, 'all'] } = options
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const page = useMemo(() => {
|
||||
const raw = searchParams.get('page')
|
||||
const n = raw ? parseInt(raw, 10) : 1
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1
|
||||
}, [searchParams])
|
||||
|
||||
const pageSize = useMemo((): PageSize => {
|
||||
const raw = searchParams.get('size')
|
||||
if (raw === 'all' && allowedPageSizes.includes('all')) return 'all'
|
||||
const n = raw ? parseInt(raw, 10) : defaultPageSize
|
||||
if (Number.isFinite(n) && allowedPageSizes.includes(n)) return n
|
||||
return defaultPageSize
|
||||
}, [searchParams, defaultPageSize, allowedPageSizes])
|
||||
|
||||
const setPage = useCallback(
|
||||
(newPage: number) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (newPage <= 1) {
|
||||
next.delete('page')
|
||||
} else {
|
||||
next.set('page', String(newPage))
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(newSize: PageSize) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('size', String(newSize))
|
||||
next.delete('page')
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
return { page, pageSize, setPage, setPageSize }
|
||||
}
|
||||
113
frontend/src/store/pinnedFlowsStore.ts
Normal file
113
frontend/src/store/pinnedFlowsStore.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { create } from 'zustand'
|
||||
import { pinnedFlowsApi } from '@/api/pinnedFlows'
|
||||
import type { PinnedFlow } from '@/api/pinnedFlows'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface PinnedFlowsState {
|
||||
items: PinnedFlow[]
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
isMutatingByTreeId: Record<string, boolean>
|
||||
error: string | null
|
||||
|
||||
load: (force?: boolean) => Promise<void>
|
||||
pin: (treeId: string) => Promise<void>
|
||||
unpin: (treeId: string) => Promise<void>
|
||||
toggle: (treeId: string) => void
|
||||
isPinned: (treeId: string) => boolean
|
||||
}
|
||||
|
||||
export const usePinnedFlowsStore = create<PinnedFlowsState>()((set, get) => ({
|
||||
items: [],
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
isMutatingByTreeId: {},
|
||||
error: null,
|
||||
|
||||
load: async (force = false) => {
|
||||
const state = get()
|
||||
if (state.isLoaded && !force) return
|
||||
if (state.isLoading) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set({ items: data.items, isLoaded: true, isLoading: false })
|
||||
} catch {
|
||||
set({ error: 'Failed to load pinned flows', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
pin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
set({ isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true } })
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.pin(treeId)
|
||||
const data = await pinnedFlowsApi.list()
|
||||
set((s) => ({
|
||||
items: data.items,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
toast.error('Maximum of 15 favorites reached. Unpin a flow to add a new one.')
|
||||
} else {
|
||||
toast.error('Failed to pin flow')
|
||||
}
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
unpin: async (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isMutatingByTreeId[treeId]) return
|
||||
|
||||
const prevItems = state.items
|
||||
set({
|
||||
items: state.items.filter((f) => f.tree_id !== treeId),
|
||||
isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await pinnedFlowsApi.unpin(treeId)
|
||||
set((s) => ({
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
} catch {
|
||||
toast.error('Failed to unpin flow')
|
||||
set((s) => ({
|
||||
items: prevItems,
|
||||
isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
toggle: (treeId: string) => {
|
||||
const state = get()
|
||||
if (state.isPinned(treeId)) {
|
||||
state.unpin(treeId)
|
||||
} else {
|
||||
state.pin(treeId)
|
||||
}
|
||||
},
|
||||
|
||||
isPinned: (treeId: string) => {
|
||||
return get().items.some((f) => f.tree_id === treeId)
|
||||
},
|
||||
}))
|
||||
|
||||
export const selectPinnedTreeIds = (state: PinnedFlowsState): Set<string> =>
|
||||
new Set(state.items.map((f) => f.tree_id))
|
||||
|
||||
export const selectPinLoadingTreeIds = (state: PinnedFlowsState): Set<string> =>
|
||||
new Set(
|
||||
Object.entries(state.isMutatingByTreeId)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
)
|
||||
@@ -17,6 +17,8 @@ interface UserPreferencesState {
|
||||
setPreferredEditorMode: (mode: EditorMode) => void
|
||||
sidebarCollapsed: boolean
|
||||
toggleSidebar: () => void
|
||||
dashboardMyFlowsView: TreeLibraryView
|
||||
setDashboardMyFlowsView: (view: TreeLibraryView) => void
|
||||
}
|
||||
|
||||
export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
@@ -32,6 +34,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
|
||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
|
||||
dashboardMyFlowsView: 'grid',
|
||||
setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }),
|
||||
}),
|
||||
{
|
||||
name: 'user-preferences-storage',
|
||||
|
||||
Reference in New Issue
Block a user