AdvisorPanel.tsx291 lines · main
1import { useMemo, useRef } from 'react'
2
3import { AdvisorDetail } from './AdvisorDetail'
4import { AdvisorFilters } from './AdvisorFilters'
5import type { AdvisorItem } from './AdvisorPanel.types'
6import {
7 createAdvisorLintItems,
8 createAdvisorNotificationItems,
9 sortAdvisorItems,
10} from './AdvisorPanel.utils'
11import { AdvisorPanelBody } from './AdvisorPanelBody'
12import { AdvisorPanelHeader } from './AdvisorPanelHeader'
13import { useAdvisorSignals } from './useAdvisorSignals'
14import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
15import { useProjectLintsQuery } from '@/data/lint/lint-query'
16import { Notification, useNotificationsV2Query } from '@/data/notifications/notifications-v2-query'
17import { useNotificationsV2UpdateMutation } from '@/data/notifications/notifications-v2-update-mutation'
18import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
19import { IS_PLATFORM } from '@/lib/constants'
20import { useTrack } from '@/lib/telemetry/track'
21import { AdvisorTab, useAdvisorStateSnapshot } from '@/state/advisor-state'
22import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
23
24export 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}