From 67543dfb8f7c1b714203d4fbfcb741f9ef945e00 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 20 Feb 2026 21:24:34 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20pinnedFlowsStor?= =?UTF-8?q?e,=20pagination=20hook,=20cached=20quota=20hook,=20sidebar=20re?= =?UTF-8?q?factor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/api/pinnedFlows.ts | 4 + frontend/src/components/layout/Sidebar.tsx | 30 +++--- frontend/src/hooks/useCachedQuota.ts | 31 ++++++ frontend/src/hooks/usePaginationParams.ts | 57 +++++++++++ frontend/src/store/pinnedFlowsStore.ts | 113 +++++++++++++++++++++ frontend/src/store/userPreferencesStore.ts | 4 + 6 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 frontend/src/hooks/useCachedQuota.ts create mode 100644 frontend/src/hooks/usePaginationParams.ts create mode 100644 frontend/src/store/pinnedFlowsStore.ts diff --git a/frontend/src/api/pinnedFlows.ts b/frontend/src/api/pinnedFlows.ts index 38823178..517e81ed 100644 --- a/frontend/src/api/pinnedFlows.ts +++ b/frontend/src/api/pinnedFlows.ts @@ -25,6 +25,10 @@ export const pinnedFlowsApi = { unpin: async (treeId: string): Promise => { await apiClient.delete(`/trees/${treeId}/pin`) }, + + pin: async (treeId: string): Promise => { + await apiClient.post(`/trees/${treeId}/pin`) + }, } export default pinnedFlowsApi diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index b1093390..ab03cdf1 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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([]) 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) => { const sidebar = e.currentTarget const canSidebarScroll = sidebar.scrollHeight > sidebar.clientHeight @@ -89,7 +83,7 @@ export function Sidebar() { ) : ( <> {/* Pinned Flows */} - +
diff --git a/frontend/src/hooks/useCachedQuota.ts b/frontend/src/hooks/useCachedQuota.ts new file mode 100644 index 00000000..bc26b13c --- /dev/null +++ b/frontend/src/hooks/useCachedQuota.ts @@ -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 } +} diff --git a/frontend/src/hooks/usePaginationParams.ts b/frontend/src/hooks/usePaginationParams.ts new file mode 100644 index 00000000..346022cd --- /dev/null +++ b/frontend/src/hooks/usePaginationParams.ts @@ -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 } +} diff --git a/frontend/src/store/pinnedFlowsStore.ts b/frontend/src/store/pinnedFlowsStore.ts new file mode 100644 index 00000000..249600ba --- /dev/null +++ b/frontend/src/store/pinnedFlowsStore.ts @@ -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 + error: string | null + + load: (force?: boolean) => Promise + pin: (treeId: string) => Promise + unpin: (treeId: string) => Promise + toggle: (treeId: string) => void + isPinned: (treeId: string) => boolean +} + +export const usePinnedFlowsStore = create()((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 => + new Set(state.items.map((f) => f.tree_id)) + +export const selectPinLoadingTreeIds = (state: PinnedFlowsState): Set => + new Set( + Object.entries(state.isMutatingByTreeId) + .filter(([, v]) => v) + .map(([k]) => k) + ) diff --git a/frontend/src/store/userPreferencesStore.ts b/frontend/src/store/userPreferencesStore.ts index e26fcd34..ad43d6e5 100644 --- a/frontend/src/store/userPreferencesStore.ts +++ b/frontend/src/store/userPreferencesStore.ts @@ -17,6 +17,8 @@ interface UserPreferencesState { setPreferredEditorMode: (mode: EditorMode) => void sidebarCollapsed: boolean toggleSidebar: () => void + dashboardMyFlowsView: TreeLibraryView + setDashboardMyFlowsView: (view: TreeLibraryView) => void } export const useUserPreferencesStore = create()( @@ -32,6 +34,8 @@ export const useUserPreferencesStore = create()( setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }), sidebarCollapsed: false, toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }), + dashboardMyFlowsView: 'grid', + setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }), }), { name: 'user-preferences-storage',