AdvisorPanelBody.tsx168 lines · main
1import { AlertTriangle, ChevronRight, Inbox } from 'lucide-react'
2import { Badge, Button, cn } from 'ui'
3import { GenericSkeletonLoader } from 'ui-patterns'
4
5import type { AdvisorItem } from './AdvisorPanel.types'
6import {
7 formatItemDate,
8 getAdvisorItemSecondaryText,
9 getAdvisorPanelItemDisplayTitle,
10 severityBadgeVariants,
11 severityColorClasses,
12 severityLabels,
13 tabIconMap,
14} from './AdvisorPanel.utils'
15import { EmptyAdvisor } from './EmptyAdvisor'
16import type { Notification } from '@/data/notifications/notifications-v2-query'
17import type { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state'
18
19const NoProjectNotice = () => {
20 return (
21 <div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
22 <Inbox className="text-foreground-muted" strokeWidth={1} />
23 <div className="text-center">
24 <p className="heading-default">Project required</p>
25 <p className="text-foreground-light text-sm">
26 Select a project to view security and performance advisories
27 </p>
28 </div>
29 </div>
30 )
31}
32
33interface AdvisorPanelBodyProps {
34 isLoading: boolean
35 isError: boolean
36 filteredItems: AdvisorItem[]
37 activeTab: AdvisorTab
38 severityFilters: AdvisorSeverity[]
39 onItemClick: (item: AdvisorItem) => void
40 onClearFilters: () => void
41 hiddenItemsCount: number
42 hasAnyFilters: boolean
43 hasProjectRef?: boolean
44}
45
46export const AdvisorPanelBody = ({
47 isLoading,
48 isError,
49 filteredItems,
50 activeTab,
51 severityFilters,
52 onItemClick,
53 onClearFilters,
54 hiddenItemsCount,
55 hasAnyFilters,
56 hasProjectRef = true,
57}: AdvisorPanelBodyProps) => {
58 // Show notice if no project ref and trying to view project-specific tabs
59 if (!hasProjectRef && activeTab !== 'messages' && activeTab !== 'all') {
60 return <NoProjectNotice />
61 }
62
63 if (isLoading) {
64 return (
65 <div>
66 <GenericSkeletonLoader className="w-full p-4" />
67 </div>
68 )
69 }
70
71 if (isError) {
72 return (
73 <div className="h-full mx-4 flex flex-col items-center justify-center gap-y-2">
74 <AlertTriangle className="text-destructive" />
75 <div className="flex flex-col items-center justify-center">
76 <h4 className="text-base font-normal text-foreground-light">Error loading advisories</h4>
77 <p className="text-sm text-foreground-lighter">Please try again later.</p>
78 </div>
79 </div>
80 )
81 }
82
83 if (filteredItems.length === 0) {
84 return (
85 <EmptyAdvisor
86 activeTab={activeTab}
87 hasFilters={hasAnyFilters}
88 onClearFilters={onClearFilters}
89 />
90 )
91 }
92
93 return (
94 <>
95 <div className="flex flex-col">
96 {filteredItems.map((item) => {
97 const SeverityIcon = tabIconMap[item.tab as Exclude<AdvisorTab, 'all'>]
98 const severityClass = severityColorClasses[item.severity]
99 const isNotification = item.source === 'notification'
100 const notification = isNotification ? (item.original as Notification) : null
101 const isUnread = notification?.status === 'new'
102
103 const primaryText = getAdvisorPanelItemDisplayTitle(item)
104 const secondaryText = getAdvisorItemSecondaryText(item)
105 const metadataText =
106 secondaryText ?? (item.createdAt ? formatItemDate(item.createdAt) : undefined)
107 // Date strings (e.g. "a few seconds ago") come from formatItemDate and
108 // need sentence-case capitalisation; entity strings (lint / signal) don't.
109 const metadataCapitalize = secondaryText === undefined && item.createdAt !== undefined
110
111 return (
112 <div key={`${item.source}-${item.id}`} className="border-b">
113 <Button
114 type="text"
115 className={cn(
116 'justify-start w-full block rounded-none h-auto py-3 px-4 hover:text-foreground',
117 isUnread && 'bg-surface-100/50'
118 )}
119 onClick={() => onItemClick(item)}
120 >
121 <div className="flex items-center justify-between gap-2">
122 <div className="flex items-center gap-3 overflow-hidden">
123 <SeverityIcon
124 size={16}
125 strokeWidth={1.5}
126 className={cn('shrink-0', severityClass)}
127 />
128 <div className="text-left flex flex-col gap-0.5 truncate flex-1 min-w-0">
129 <div className="truncate">{primaryText}</div>
130 {metadataText && (
131 <div className="flex items-center gap-1 text-xs text-foreground-light">
132 <span
133 className={cn('truncate', metadataCapitalize && 'capitalize-sentence')}
134 >
135 {metadataText}
136 </span>
137 </div>
138 )}
139 </div>
140 </div>
141 <div className="flex items-center gap-2 shrink-0">
142 {item.severity === 'critical' && (
143 <Badge variant={severityBadgeVariants[item.severity]}>
144 {severityLabels[item.severity]}
145 </Badge>
146 )}
147 <ChevronRight
148 size={16}
149 strokeWidth={1.5}
150 className="shrink-0 text-foreground-lighter"
151 />
152 </div>
153 </div>
154 </Button>
155 </div>
156 )
157 })}
158 </div>
159 {severityFilters.length > 0 && hiddenItemsCount > 0 && (
160 <div className="px-4 py-3">
161 <Button type="text" className="w-full" onClick={onClearFilters}>
162 Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
163 </Button>
164 </div>
165 )}
166 </>
167 )
168}