AdvisorPanelBody.tsx 5.8 KB

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