-
}
- onClick={handleBackToList}
- tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
- />
-
-
- {selectedItem?.title}
-
- {selectedItem && (
-
- {severityLabels[selectedItem.severity]}
-
- )}
-
-
}
- onClick={handleClose}
- tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
- />
-
{selectedItem ? (
-
+
) : (
@@ -218,118 +295,40 @@ export const AdvisorPanel = () => {
>
) : (
<>
-
-
-
-
-
- All
-
-
- Security
-
-
- Performance
-
-
-
-
- setSeverityFilters(values as AdvisorSeverity[])}
- />
- }
- onClick={handleClose}
- tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
- />
-
-
-
+
{
+ notificationFilterStatuses
+ .filter((status) => !values.includes(status))
+ .forEach((status) => setNotificationFilters(status, 'status'))
+ values
+ .filter((status) => !notificationFilterStatuses.includes(status))
+ .forEach((status) => setNotificationFilters(status, 'status'))
+ }}
+ hasProjectRef={hasProjectRef}
+ onClose={handleClose}
+ isPlatform={IS_PLATFORM}
+ />
- {isLoading ? (
-
-
-
- ) : isError ? (
-
-
-
Error loading advisories
-
Please try again later.
-
- ) : filteredItems.length === 0 ? (
-
0}
- onClearFilters={clearSeverityFilters}
- />
- ) : (
- <>
-
- {filteredItems.map((item) => {
- const SeverityIcon = tabIconMap[item.tab]
- const severityClass = severityColorClasses[item.severity]
- return (
-
-
setSelectedItemId(item.id)}
- >
-
-
-
- )
- })}
-
- {severityFilters.length > 0 && hiddenItemsCount > 0 && (
-
-
- Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
-
-
- )}
- >
- )}
+
>
)}
)
}
-
-interface AdvisorDetailProps {
- item: AdvisorItem
- projectRef: string
-}
-
-const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => {
- if (item.source === 'lint') {
- const lint = item.original as Lint
- return (
-
-
-
- )
- }
-}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx
new file mode 100644
index 00000000000..5af554a3bb2
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx
@@ -0,0 +1,144 @@
+import { AlertTriangle, ChevronRight, Gauge, Inbox, Shield } from 'lucide-react'
+
+import { Button } from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns'
+import { EmptyAdvisor } from './EmptyAdvisor'
+import { AdvisorItem } from './AdvisorPanelHeader'
+import { Notification } from 'data/notifications/notifications-v2-query'
+import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state'
+import { cn } from 'ui'
+
+const NoProjectNotice = () => {
+ return (
+
+
+
+
Project required
+
+ Select a project to view security and performance advisories
+
+
+
+ )
+}
+
+const tabIconMap: Record
, React.ElementType> = {
+ security: Shield,
+ performance: Gauge,
+ messages: Inbox,
+}
+
+const severityColorClasses: Record = {
+ critical: 'text-destructive',
+ warning: 'text-warning',
+ info: 'text-foreground-light',
+}
+
+interface AdvisorPanelBodyProps {
+ isLoading: boolean
+ isError: boolean
+ filteredItems: AdvisorItem[]
+ activeTab: AdvisorTab
+ severityFilters: AdvisorSeverity[]
+ onItemClick: (item: AdvisorItem) => void
+ onClearFilters: () => void
+ hiddenItemsCount: number
+ hasAnyFilters: boolean
+ hasProjectRef?: boolean
+}
+
+export const AdvisorPanelBody = ({
+ isLoading,
+ isError,
+ filteredItems,
+ activeTab,
+ severityFilters,
+ onItemClick,
+ onClearFilters,
+ hiddenItemsCount,
+ hasAnyFilters,
+ hasProjectRef = true,
+}: AdvisorPanelBodyProps) => {
+ // Show notice if no project ref and trying to view project-specific tabs
+ if (!hasProjectRef && activeTab !== 'messages') {
+ return
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (isError) {
+ return (
+
+
+
Error loading advisories
+
Please try again later.
+
+ )
+ }
+
+ if (filteredItems.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+ {filteredItems.map((item) => {
+ const SeverityIcon = tabIconMap[item.tab]
+ const severityClass = severityColorClasses[item.severity]
+ const isNotification = item.source === 'notification'
+ const notification = isNotification ? (item.original as Notification) : null
+ const isUnread = notification?.status === 'new'
+
+ return (
+
+
onItemClick(item)}
+ >
+
+
+
+ {item.title.replace(/[`\\]/g, '')}
+
+
+
+
+
+ )
+ })}
+
+ {severityFilters.length > 0 && hiddenItemsCount > 0 && (
+
+
+ Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
+
+
+ )}
+ >
+ )
+}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx
new file mode 100644
index 00000000000..ae060dddb93
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx
@@ -0,0 +1,77 @@
+import dayjs from 'dayjs'
+import { ChevronLeft, X } from 'lucide-react'
+
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { AdvisorItemSource, AdvisorSeverity } from 'state/advisor-state'
+import { Badge } from 'ui'
+
+export type AdvisorItem = {
+ id: string
+ title: string
+ severity: AdvisorSeverity
+ createdAt?: number
+ tab: 'security' | 'performance' | 'messages'
+ source: AdvisorItemSource
+ original: any
+}
+
+export const severityBadgeVariants: Record =
+ {
+ critical: 'destructive',
+ warning: 'warning',
+ info: 'default',
+ }
+
+export const severityLabels: Record = {
+ critical: 'Critical',
+ warning: 'Warning',
+ info: 'Info',
+}
+
+interface AdvisorPanelHeaderProps {
+ selectedItem: AdvisorItem | undefined
+ onBack: () => void
+ onClose: () => void
+}
+
+export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => {
+ return (
+
+
}
+ onClick={onBack}
+ tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
+ />
+
+
+ {selectedItem?.title?.replace(/[`\\]/g, '')}
+ {selectedItem?.createdAt && (
+
+ {(() => {
+ const insertedAt = selectedItem.createdAt
+ const daysFromNow = dayjs().diff(dayjs(insertedAt), 'day')
+ const formattedTimeFromNow = dayjs(insertedAt).fromNow()
+ const formattedInsertedAt = dayjs(insertedAt).format('MMM DD, YYYY')
+ return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
+ })()}
+
+ )}
+
+ {selectedItem && (
+
+ {severityLabels[selectedItem.severity]}
+
+ )}
+
+
}
+ onClick={onClose}
+ tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
+ />
+
+ )
+}
diff --git a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx
new file mode 100644
index 00000000000..cb8cda263e4
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx
@@ -0,0 +1,113 @@
+import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react'
+import Link from 'next/link'
+import { Button } from 'ui'
+import { Markdown } from 'components/interfaces/Markdown'
+import { Notification, NotificationData } from 'data/notifications/notifications-v2-query'
+import { useProjectDetailQuery } from 'data/projects/project-detail-query'
+import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+
+interface NotificationDetailProps {
+ notification: Notification
+ onUpdateStatus: (id: string, status: 'archived' | 'seen') => void
+}
+
+export const NotificationDetail = ({ notification, onUpdateStatus }: NotificationDetailProps) => {
+ const data = notification.data as NotificationData
+
+ const { data: project } = useProjectDetailQuery({ ref: data.project_ref })
+ const { data: organizations } = useOrganizationsQuery()
+
+ const organization =
+ data.org_slug !== undefined
+ ? organizations?.find((org) => org.slug === data.org_slug)
+ : project !== undefined
+ ? organizations?.find((org) => org.id === project.organization_id)
+ : undefined
+
+ const onButtonAction = (type?: string) => {
+ // [Joshen] Implement accordingly - BE team will need to give us a heads up on this
+ console.log('Action', type)
+ }
+
+ return (
+
+ {(project !== undefined || organization !== undefined) && (
+ <>
+
Context
+
+ {organization !== undefined && (
+
+ {organization.name}
+
+ )}
+ {project !== undefined && (
+
+ {project.name}
+
+ )}
+
+ >
+ )}
+
+ {data.message !== undefined && (
+ <>
+
Message
+
+ >
+ )}
+
+
Actions
+
+ {(data.actions ?? []).map((action, idx) => {
+ const key = `${notification.id}-action-${idx}`
+ if (action.url !== undefined) {
+ const url = action.url.includes('[ref]')
+ ? action.url.replace('[ref]', project?.ref ?? '_')
+ : action.url.includes('[slug]')
+ ? action.url.replace('[slug]', organization?.slug ?? '_')
+ : action.url
+ return (
+ } asChild>
+
+ {action.label}
+
+
+ )
+ } else if (action.action_type !== undefined) {
+ return (
+ onButtonAction(action.action_type)}>
+ {action.label}
+
+ )
+ } else {
+ return null
+ }
+ })}
+ {notification.status === 'archived' ? (
+ }
+ onClick={() => onUpdateStatus(notification.id, 'seen')}
+ >
+ Unarchive
+
+ ) : (
+ }
+ onClick={() => onUpdateStatus(notification.id, 'archived')}
+ >
+ Archive
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx
index 4ae0b2365a5..6ac0bf7ae46 100644
--- a/apps/studio/components/ui/FilterPopover.tsx
+++ b/apps/studio/components/ui/FilterPopover.tsx
@@ -30,6 +30,7 @@ interface FilterPopoverProps {
maxHeightClass?: string
clearButtonText?: string
className?: string
+ isMinimized?: boolean
onSaveFilters: (options: string[]) => void
// [Joshen] These props are to support async data with infinite loading if applicable
@@ -60,6 +61,7 @@ export const FilterPopover = >({
className,
maxHeightClass = 'h-[205px]',
clearButtonText = 'Clear',
+ isMinimized = false,
onSaveFilters,
search,
@@ -127,14 +129,20 @@ export const FilterPopover = >({
>
{name}
- {activeOptions.length > 0 && : }
- {activeOptions.length >= 3 ? (
-
- {formattedOptions[0]} and {activeOptions.length - 1} others
-
- ) : activeOptions.length > 0 ? (
- {formattedOptions.join(', ')}
- ) : null}
+ {activeOptions.length > 0 && (
+ <>
+ :
+ {isMinimized ? (
+ {activeOptions.length}
+ ) : activeOptions.length >= 3 ? (
+
+ {formattedOptions[0]} and {activeOptions.length - 1} others
+
+ ) : (
+ {formattedOptions.join(', ')}
+ )}
+ >
+ )}
diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts
index a5839330a32..501ad4536f2 100644
--- a/apps/studio/state/advisor-state.ts
+++ b/apps/studio/state/advisor-state.ts
@@ -2,11 +2,19 @@ import { proxy, snapshot, useSnapshot } from 'valtio'
export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages'
export type AdvisorSeverity = 'critical' | 'warning' | 'info'
+export type AdvisorItemSource = 'lint' | 'notification'
const initialState = {
activeTab: 'all' as AdvisorTab,
severityFilters: ['critical'] as AdvisorSeverity[],
selectedItemId: undefined as string | undefined,
+ selectedItemSource: undefined as AdvisorItemSource | undefined,
+ // Notification filters
+ notificationFilterStatuses: [] as string[],
+ notificationFilterPriorities: [] as string[],
+ get numNotificationFiltersApplied() {
+ return [...this.notificationFilterStatuses, ...this.notificationFilterPriorities].length
+ },
}
export const advisorState = proxy({
@@ -20,14 +28,44 @@ export const advisorState = proxy({
clearSeverityFilters() {
advisorState.severityFilters = []
},
- setSelectedItemId(id: string | undefined) {
+ setSelectedItem(id: string | undefined, source?: AdvisorItemSource) {
advisorState.selectedItemId = id
+ advisorState.selectedItemSource = source
},
- focusItem({ id, tab }: { id: string; tab?: AdvisorTab }) {
+ focusItem({ id, tab, source }: { id: string; tab?: AdvisorTab; source?: AdvisorItemSource }) {
if (tab) {
advisorState.activeTab = tab
}
advisorState.selectedItemId = id
+ advisorState.selectedItemSource = source
+ },
+ setNotificationFilters: (value: string, type: 'status' | 'priority') => {
+ switch (type) {
+ case 'status':
+ if (advisorState.notificationFilterStatuses.includes(value)) {
+ advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.filter(
+ (x) => x !== value
+ )
+ } else {
+ advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.concat([
+ value,
+ ])
+ }
+ break
+ case 'priority':
+ if (advisorState.notificationFilterPriorities.includes(value)) {
+ advisorState.notificationFilterPriorities =
+ advisorState.notificationFilterPriorities.filter((x) => x !== value)
+ } else {
+ advisorState.notificationFilterPriorities =
+ advisorState.notificationFilterPriorities.concat([value])
+ }
+ break
+ }
+ },
+ resetNotificationFilters() {
+ advisorState.notificationFilterStatuses = []
+ advisorState.notificationFilterPriorities = []
},
reset() {
Object.assign(advisorState, initialState)
diff --git a/apps/studio/state/notifications.ts b/apps/studio/state/notifications.ts
deleted file mode 100644
index 4a1c0be56bb..00000000000
--- a/apps/studio/state/notifications.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { proxy, snapshot, useSnapshot } from 'valtio'
-
-export const notificationsState = proxy({
- filterStatuses: [] as string[],
- filterPriorities: [] as string[],
- filterOrganizations: [] as string[],
- filterProjects: [] as string[],
- get numFiltersApplied() {
- return [
- ...this.filterStatuses,
- ...this.filterPriorities,
- ...this.filterOrganizations,
- ...this.filterProjects,
- ].length
- },
- setFilters: (value: string, type: 'status' | 'priority' | 'organizations' | 'projects') => {
- switch (type) {
- case 'status':
- if (notificationsState.filterStatuses.includes(value)) {
- notificationsState.filterStatuses = notificationsState.filterStatuses.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterStatuses = notificationsState.filterStatuses.concat([value])
- }
- break
- case 'priority':
- if (notificationsState.filterPriorities.includes(value)) {
- notificationsState.filterPriorities = notificationsState.filterPriorities.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterPriorities = notificationsState.filterPriorities.concat([value])
- }
- break
- case 'organizations':
- if (notificationsState.filterOrganizations.includes(value)) {
- notificationsState.filterOrganizations = notificationsState.filterOrganizations.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterOrganizations = notificationsState.filterOrganizations.concat([
- value,
- ])
- }
- break
- case 'projects':
- if (notificationsState.filterProjects.includes(value)) {
- notificationsState.filterProjects = notificationsState.filterProjects.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterProjects = notificationsState.filterProjects.concat([value])
- }
- break
- }
- },
- resetFilters: () => {
- notificationsState.filterStatuses = []
- notificationsState.filterPriorities = []
- notificationsState.filterOrganizations = []
- notificationsState.filterProjects = []
- },
-})
-
-export const getNotificationsStateSnapshot = () => snapshot(notificationsState)
-
-export const useNotificationsStateSnapshot = (options?: Parameters[1]) =>
- useSnapshot(notificationsState, options)
diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx
index e0e48404090..2e09ddffc46 100644
--- a/apps/studio/state/sidebar-manager-state.tsx
+++ b/apps/studio/state/sidebar-manager-state.tsx
@@ -143,18 +143,22 @@ export const useRegisterSidebar = (
id: string,
component: () => ReactNode,
handlers: SidebarHandlers = {},
- hotKey?: string
+ hotKey?: string,
+ enabled?: boolean
) => {
const [isSidebarHotkeyEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(id),
true
)
- useEffect(() => {
- const { registerSidebar, unregisterSidebar, sidebars } = sidebarManagerState
+ const { sidebars, registerSidebar, unregisterSidebar } = useSidebarManagerSnapshot()
- if (!sidebars[id]) {
+ useEffect(() => {
+ const isEnabled = enabled !== false
+ if (!sidebars[id] && isEnabled) {
registerSidebar(id, component, handlers)
+ } else if (sidebars[id] && !isEnabled) {
+ unregisterSidebar(id)
}
return () => {
@@ -162,7 +166,7 @@ export const useRegisterSidebar = (
unregisterSidebar(id)
}
}
- }, [id])
+ }, [id, enabled])
useEffect(() => {
if (!hotKey) return