AdvisorPanel.tsx291 lines · main
| 1 | import { useMemo, useRef } from 'react' |
| 2 | |
| 3 | import { AdvisorDetail } from './AdvisorDetail' |
| 4 | import { AdvisorFilters } from './AdvisorFilters' |
| 5 | import type { AdvisorItem } from './AdvisorPanel.types' |
| 6 | import { |
| 7 | createAdvisorLintItems, |
| 8 | createAdvisorNotificationItems, |
| 9 | sortAdvisorItems, |
| 10 | } from './AdvisorPanel.utils' |
| 11 | import { AdvisorPanelBody } from './AdvisorPanelBody' |
| 12 | import { AdvisorPanelHeader } from './AdvisorPanelHeader' |
| 13 | import { useAdvisorSignals } from './useAdvisorSignals' |
| 14 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 15 | import { useProjectLintsQuery } from '@/data/lint/lint-query' |
| 16 | import { Notification, useNotificationsV2Query } from '@/data/notifications/notifications-v2-query' |
| 17 | import { useNotificationsV2UpdateMutation } from '@/data/notifications/notifications-v2-update-mutation' |
| 18 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 19 | import { IS_PLATFORM } from '@/lib/constants' |
| 20 | import { useTrack } from '@/lib/telemetry/track' |
| 21 | import { AdvisorTab, useAdvisorStateSnapshot } from '@/state/advisor-state' |
| 22 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 23 | |
| 24 | export const AdvisorPanel = () => { |
| 25 | const track = useTrack() |
| 26 | const { |
| 27 | activeTab, |
| 28 | severityFilters, |
| 29 | selectedItemId, |
| 30 | selectedItemSource, |
| 31 | setActiveTab, |
| 32 | setSeverityFilters, |
| 33 | clearSeverityFilters, |
| 34 | setSelectedItem, |
| 35 | notificationFilterStatuses, |
| 36 | notificationFilterPriorities, |
| 37 | setNotificationFilters, |
| 38 | resetNotificationFilters, |
| 39 | } = useAdvisorStateSnapshot() |
| 40 | const { data: project } = useSelectedProjectQuery() |
| 41 | const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot() |
| 42 | |
| 43 | const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL |
| 44 | const markedRead = useRef<string[]>([]) |
| 45 | const hasProjectRef = !!project?.ref |
| 46 | const shouldLoadProjectAdvisorData = isSidebarOpen && hasProjectRef && activeTab !== 'messages' |
| 47 | |
| 48 | const { |
| 49 | data: lintData, |
| 50 | isPending: isLintsLoading, |
| 51 | isError: isLintsError, |
| 52 | } = useProjectLintsQuery({ projectRef: project?.ref }, { enabled: shouldLoadProjectAdvisorData }) |
| 53 | |
| 54 | const { data: signalItems } = useAdvisorSignals({ |
| 55 | projectRef: project?.ref, |
| 56 | enabled: shouldLoadProjectAdvisorData, |
| 57 | }) |
| 58 | |
| 59 | // Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs) |
| 60 | const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM |
| 61 | |
| 62 | const notificationStatus = useMemo(() => { |
| 63 | if (notificationFilterStatuses.includes('archived')) { |
| 64 | return 'archived' |
| 65 | } |
| 66 | if (notificationFilterStatuses.includes('unread')) { |
| 67 | return 'new' |
| 68 | } |
| 69 | return undefined |
| 70 | }, [notificationFilterStatuses]) |
| 71 | |
| 72 | // Memoize filters to prevent query key changes on every render |
| 73 | // Use selected organization and project if they exist |
| 74 | const notificationFilters = useMemo( |
| 75 | () => ({ priority: notificationFilterPriorities }), |
| 76 | [notificationFilterPriorities] |
| 77 | ) |
| 78 | |
| 79 | const { |
| 80 | data: notificationsData, |
| 81 | isPending: isNotificationsLoading, |
| 82 | isError: isNotificationsError, |
| 83 | } = useNotificationsV2Query( |
| 84 | { |
| 85 | status: notificationStatus, |
| 86 | filters: notificationFilters, |
| 87 | limit: 20, |
| 88 | }, |
| 89 | { enabled: shouldLoadNotifications } |
| 90 | ) |
| 91 | |
| 92 | const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() |
| 93 | |
| 94 | const notifications = useMemo(() => { |
| 95 | return notificationsData?.pages.flatMap((page) => page) ?? [] |
| 96 | }, [notificationsData?.pages]) |
| 97 | |
| 98 | const markNotificationsRead = () => { |
| 99 | if (markedRead.current.length > 0) { |
| 100 | updateNotifications({ ids: markedRead.current, status: 'seen' }) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | const lintItems = useMemo<AdvisorItem[]>(() => { |
| 105 | return createAdvisorLintItems(lintData ?? []) |
| 106 | }, [lintData]) |
| 107 | |
| 108 | const notificationItems = useMemo<AdvisorItem[]>(() => { |
| 109 | if (!IS_PLATFORM) return [] |
| 110 | return createAdvisorNotificationItems(notifications) |
| 111 | }, [notifications]) |
| 112 | |
| 113 | const combinedItems = useMemo<AdvisorItem[]>(() => { |
| 114 | return sortAdvisorItems([...lintItems, ...signalItems, ...notificationItems]) |
| 115 | }, [lintItems, signalItems, notificationItems]) |
| 116 | |
| 117 | const filteredItems = useMemo<AdvisorItem[]>(() => { |
| 118 | return combinedItems.filter((item) => { |
| 119 | // Filter by severity |
| 120 | if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) { |
| 121 | return false |
| 122 | } |
| 123 | |
| 124 | // Filter by tab |
| 125 | if (activeTab === 'all') { |
| 126 | // When no projectRef, only show notifications in 'all' tab |
| 127 | if (!hasProjectRef && item.source !== 'notification') { |
| 128 | return false |
| 129 | } |
| 130 | return true |
| 131 | } |
| 132 | |
| 133 | return item.tab === activeTab |
| 134 | }) |
| 135 | }, [combinedItems, severityFilters, activeTab, hasProjectRef]) |
| 136 | |
| 137 | const itemsFilteredByTabOnly = useMemo<AdvisorItem[]>(() => { |
| 138 | return combinedItems.filter((item) => { |
| 139 | if (activeTab === 'all') { |
| 140 | // When no projectRef, only show notifications in 'all' tab |
| 141 | if (!hasProjectRef && item.source !== 'notification') { |
| 142 | return false |
| 143 | } |
| 144 | return true |
| 145 | } |
| 146 | return item.tab === activeTab |
| 147 | }) |
| 148 | }, [combinedItems, activeTab, hasProjectRef]) |
| 149 | |
| 150 | const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length |
| 151 | |
| 152 | const selectedItem = combinedItems.find( |
| 153 | (item) => item.id === selectedItemId && item.source === selectedItemSource |
| 154 | ) |
| 155 | const isDetailView = !!selectedItem |
| 156 | |
| 157 | // Only show loading state if the query is actually enabled |
| 158 | const isLintsActuallyLoading = shouldLoadProjectAdvisorData && isLintsLoading |
| 159 | const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading |
| 160 | |
| 161 | // [Joshen] Opting to ignore loading and error state of advisor signals - render lints irregardless of banned ips |
| 162 | const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading |
| 163 | const isError = isLintsError || isNotificationsError |
| 164 | |
| 165 | const handleTabChange = (tab: string) => { |
| 166 | setActiveTab(tab as AdvisorTab) |
| 167 | setSelectedItem(undefined) |
| 168 | } |
| 169 | |
| 170 | const handleBackToList = () => { |
| 171 | setSelectedItem(undefined) |
| 172 | markNotificationsRead() |
| 173 | } |
| 174 | |
| 175 | const handleClose = () => { |
| 176 | markNotificationsRead() |
| 177 | closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) |
| 178 | } |
| 179 | |
| 180 | const handleItemClick = (item: AdvisorItem) => { |
| 181 | setSelectedItem(item.id, item.source) |
| 182 | |
| 183 | if (item.source === 'notification') { |
| 184 | const notification = item.original as Notification |
| 185 | if (notification.status === 'new' && !markedRead.current.includes(notification.id)) { |
| 186 | markedRead.current.push(notification.id) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | const advisorCategory = |
| 191 | item.source === 'lint' |
| 192 | ? item.original.categories.includes('SECURITY') |
| 193 | ? 'SECURITY' |
| 194 | : item.original.categories.includes('PERFORMANCE') |
| 195 | ? 'PERFORMANCE' |
| 196 | : undefined |
| 197 | : item.source === 'signal' |
| 198 | ? 'SECURITY' |
| 199 | : undefined |
| 200 | const advisorType = |
| 201 | item.source === 'signal' |
| 202 | ? item.type |
| 203 | : item.source === 'lint' |
| 204 | ? item.original.name |
| 205 | : item.title |
| 206 | const advisorLevel = item.source === 'lint' ? item.original.level : undefined |
| 207 | |
| 208 | track('advisor_detail_opened', { |
| 209 | origin: 'advisor_panel', |
| 210 | advisorCategory, |
| 211 | advisorSource: item.source, |
| 212 | advisorType, |
| 213 | advisorLevel, |
| 214 | }) |
| 215 | } |
| 216 | |
| 217 | const handleUpdateNotificationStatus = (id: string, status: 'archived' | 'seen') => { |
| 218 | updateNotifications({ ids: [id], status }) |
| 219 | } |
| 220 | |
| 221 | const handleClearAllFilters = () => { |
| 222 | clearSeverityFilters() |
| 223 | resetNotificationFilters() |
| 224 | } |
| 225 | |
| 226 | const hasAnyFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0 |
| 227 | |
| 228 | return ( |
| 229 | <div className="flex h-full flex-col bg-background"> |
| 230 | {isDetailView ? ( |
| 231 | <> |
| 232 | <AdvisorPanelHeader |
| 233 | selectedItem={selectedItem} |
| 234 | onBack={handleBackToList} |
| 235 | onClose={handleClose} |
| 236 | /> |
| 237 | <div className="flex-1 overflow-y-auto"> |
| 238 | {selectedItem ? ( |
| 239 | <AdvisorDetail |
| 240 | item={selectedItem} |
| 241 | projectRef={project?.ref ?? ''} |
| 242 | onUpdateNotificationStatus={handleUpdateNotificationStatus} |
| 243 | onAfterLintAction={handleBackToList} |
| 244 | /> |
| 245 | ) : ( |
| 246 | <div className="px-6 py-8"> |
| 247 | <p className="text-sm text-foreground-light"> |
| 248 | Select an advisor item to view more details. |
| 249 | </p> |
| 250 | </div> |
| 251 | )} |
| 252 | </div> |
| 253 | </> |
| 254 | ) : ( |
| 255 | <> |
| 256 | <AdvisorFilters |
| 257 | activeTab={activeTab} |
| 258 | onTabChange={handleTabChange} |
| 259 | severityFilters={[...severityFilters]} |
| 260 | onSeverityFiltersChange={setSeverityFilters} |
| 261 | statusFilters={[...notificationFilterStatuses]} |
| 262 | onStatusFiltersChange={(values) => { |
| 263 | notificationFilterStatuses |
| 264 | .filter((status) => !values.includes(status)) |
| 265 | .forEach((status) => setNotificationFilters(status, 'status')) |
| 266 | values |
| 267 | .filter((status) => !notificationFilterStatuses.includes(status)) |
| 268 | .forEach((status) => setNotificationFilters(status, 'status')) |
| 269 | }} |
| 270 | onClose={handleClose} |
| 271 | isPlatform={IS_PLATFORM} |
| 272 | /> |
| 273 | <div className="flex-1 overflow-y-auto"> |
| 274 | <AdvisorPanelBody |
| 275 | isLoading={isLoading} |
| 276 | isError={isError} |
| 277 | filteredItems={filteredItems} |
| 278 | activeTab={activeTab} |
| 279 | severityFilters={[...severityFilters]} |
| 280 | onItemClick={handleItemClick} |
| 281 | onClearFilters={handleClearAllFilters} |
| 282 | hiddenItemsCount={hiddenItemsCount} |
| 283 | hasAnyFilters={hasAnyFilters} |
| 284 | hasProjectRef={hasProjectRef} |
| 285 | /> |
| 286 | </div> |
| 287 | </> |
| 288 | )} |
| 289 | </div> |
| 290 | ) |
| 291 | } |