diff --git a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx index b6212222f1a..931c4265c90 100644 --- a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx +++ b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx @@ -49,7 +49,7 @@ export const AdvisorWidget = () => { ) const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() - const { setSelectedItemId } = useAdvisorStateSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() const securityLints = useMemo( () => (lints ?? []).filter((lint: Lint) => lint.categories.includes('SECURITY')), @@ -80,10 +80,10 @@ export const AdvisorWidget = () => { const handleLintClick = useCallback( (lint: Lint) => { - setSelectedItemId(lint.cache_key) + setSelectedItem(lint.cache_key, 'lint') openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) }, - [setSelectedItemId, openSidebar] + [setSelectedItem, openSidebar] ) const totalIssues = diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx index 3bccc7f130b..ffcc9d52752 100644 --- a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx @@ -30,7 +30,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const { mutate: sendEvent } = useSendEventMutation() const { data: organization } = useSelectedOrganizationQuery() const { openSidebar } = useSidebarManagerSnapshot() - const { setSelectedItemId } = useAdvisorStateSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() const errorLints: Lint[] = useMemo(() => { return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] @@ -67,7 +67,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const handleCardClick = useCallback( (lint: Lint) => { - setSelectedItemId(lint.cache_key) + setSelectedItem(lint.cache_key, 'lint') openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) if (projectRef && organization?.slug) { sendEvent({ @@ -84,7 +84,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo }) } }, - [sendEvent, setSelectedItemId, openSidebar, projectRef, organization, totalErrors] + [sendEvent, setSelectedItem, openSidebar, projectRef, organization, totalErrors] ) if (showEmptyState) { diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx index 6b3c3c3fccc..7dd855ae65d 100644 --- a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -1,14 +1,11 @@ import { Lightbulb } from 'lucide-react' - -import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectLintsQuery } from 'data/lint/lint-query' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { cn } from 'ui' -export const AdvisorButton = () => { - const { ref: projectRef } = useParams() +export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() const { data: lints } = useProjectLintsQuery({ projectRef }) diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index c2ed5bcdeae..47229e4acba 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -8,10 +8,11 @@ import { Sidebar } from 'components/interfaces/Sidebar' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy' import { useAppStateSnapshot } from 'state/app-state' -import { SidebarProvider } from 'ui' +import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui' import { LayoutHeader } from './ProjectLayout/LayoutHeader/LayoutHeader' import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar' import { ProjectContextProvider } from './ProjectLayout/ProjectContext' +import { LayoutSidebar } from './ProjectLayout/LayoutSidebar' import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider' export interface DefaultLayoutProps { @@ -55,8 +56,8 @@ const DefaultLayout = ({ return ( - - + +
{/* Top Banner */} @@ -75,13 +76,22 @@ const DefaultLayout = ({
{/* Sidebar - Only show for project pages, not account pages */} {!router.pathname.startsWith('/account') && } - {/* Main Content */} -
{children}
+ {/* Main Content with Layout Sidebar */} + + +
{children}
+
+ +
-
-
+ +
) } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 8c7765ca2aa..b8e99afb3e4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -27,8 +27,6 @@ import { HelpPopover } from './HelpPopover' import { HomeIcon } from './HomeIcon' import { LocalVersionPopover } from './LocalVersionPopover' import MergeRequestButton from './MergeRequestButton' -import { NotificationsPopoverV2 } from './NotificationsPopoverV2/NotificationsPopover' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -212,11 +210,10 @@ export const LayoutHeader = ({
- + {!!projectRef && ( <> - @@ -229,10 +226,10 @@ export const LayoutHeader = ({ <>
+ {!!projectRef && ( <> - diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx deleted file mode 100644 index 574b9aa4721..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import dayjs from 'dayjs' -import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useEffect } from 'react' -import { useInView } from 'react-intersection-observer' -import { Button, cn } from 'ui' - -import { Markdown } from 'components/interfaces/Markdown' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useVirtualizerContext } from 'components/ui/InfiniteList' -import { NotificationData, type NotificationsData } from 'data/notifications/notifications-v2-query' -import { useProjectDetailQuery } from 'data/projects/project-detail-query' -import type { Organization } from 'types' -import { CriticalIcon, WarningIcon } from 'ui' - -interface NotificationRowProps { - index: number - item: NotificationsData[number] - getOrganizationById: (id: number) => Organization - getOrganizationBySlug: (slug: string) => Organization - onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void - queueMarkRead: (id: string) => void -} - -const NotificationRow = ({ - index, - item: notification, - getOrganizationById, - getOrganizationBySlug, - onUpdateNotificationStatus, - queueMarkRead, -}: NotificationRowProps) => { - const { virtualizer } = useVirtualizerContext() - const { ref: viewRef, inView } = useInView() - - const { status, priority } = notification - const data = notification.data as NotificationData - - const { data: project } = useProjectDetailQuery({ ref: data.project_ref }) - - const organization = - data.org_slug !== undefined - ? getOrganizationBySlug(data.org_slug) - : project !== undefined - ? getOrganizationById(project.organization_id) - : undefined - - const daysFromNow = dayjs().diff(dayjs(notification.inserted_at), 'day') - const formattedTimeFromNow = dayjs(notification.inserted_at).fromNow() - const formattedInsertedAt = dayjs(notification.inserted_at).format('MMM DD, YYYY') - - const onButtonAction = (type?: string) => { - // [Joshen] Implement accordingly - BE team will need to give us a heads up on this - console.log('Action', type) - } - - useEffect(() => { - if (inView && notification.status === 'new') { - queueMarkRead(notification.id) - } - }, [inView]) - - return ( -
-
- {(project !== undefined || organization !== undefined) && ( -
- {organization !== undefined && ( - - {organization.name} - - )} - {organization !== undefined && project !== undefined && ( - - - - - - )} - {project !== undefined && ( - - {project.name} - - )} -
- )} -
-

- {data.title}{' '} - - {daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow} - -

-
- {data.message !== undefined && ( - - )} - {(data.actions ?? []).length > 0 && ( -
- {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 ( - - ) - } else if (action.action_type !== undefined) { - return ( - - ) - } else { - return null - } - })} -
- )} -
-
- {priority === 'Warning' && } - {priority === 'Critical' && } - {notification.status === 'archived' ? ( - - } - className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full" - onClick={() => onUpdateNotificationStatus(notification.id, 'seen')} - tooltip={{ content: { text: 'Unarchive', side: 'bottom' } }} - /> - ) : ( - } - className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full" - onClick={() => onUpdateNotificationStatus(notification.id, 'archived')} - tooltip={{ content: { text: 'Archive', side: 'bottom' } }} - /> - )} -
-
- ) -} - -export default NotificationRow diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx deleted file mode 100644 index 1cea7a375c8..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { RotateCcw, Settings2Icon, X } from 'lucide-react' -import { useMemo, useState } from 'react' -import { - Button, - Checkbox_Shadcn_, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - CommandSeparator_Shadcn_, - Command_Shadcn_, - DropdownMenuLabel, - Label_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, - cn, -} from 'ui' - -import { CommandGroup } from '@ui/components/shadcn/ui/command' -import { useDebounce } from '@uidotdev/usehooks' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' -import { useNotificationsStateSnapshot } from 'state/notifications' -import { CriticalIcon, WarningIcon } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' - -// [Joshen] Opting to not use infinite loading for projects in this UI specifically -// since the UX feels quite awkward having infinite loading for just a specific section in this Popover - -export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archived' }) => { - const [open, setOpen] = useState(false) - const snap = useNotificationsStateSnapshot() - - const [search, setSearch] = useState('') - const debouncedSearch = useDebounce(search, 500) - - const { data: organizations } = useOrganizationsQuery() - const { data } = useProjectsInfiniteQuery( - { search: search.length === 0 ? search : debouncedSearch }, - { keepPreviousData: true, enabled: open } - ) - const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] - const projectCount = data?.pages[0].pagination.count ?? 0 - const pageLimit = data?.pages[0].pagination.limit ?? 0 - - return ( - - - - - - - - - No filters found that match your search - - - - - Status - { - snap.setFilters('unread', 'status') - }} - > - - - Unread - - - - - - - - Priority - { - snap.setFilters('Warning', 'priority') - }} - className="flex items-center gap-x-2" - > - - - - Warning - - - { - snap.setFilters('Critical', 'priority') - }} - > - - - - Critical - - - - - - - - Organizations - {(organizations ?? []).map((org) => ( - { - snap.setFilters(org.slug, 'organizations') - }} - > - - - {org.name} - - - ))} - - - - - - Projects - {/* - [Joshen] Adding a separate search input field here for projects as the - top level CommandInput doesn't work well with a mix of sync and async data - */} -
- setSearch(e.target.value)} - actions={ - search.length > 0 ? ( - setSearch('')} - /> - ) : null - } - /> -
- {(projects ?? []).map((project) => ( - { - snap.setFilters(project.ref, 'projects') - }} - > - - - {project.name} - - - ))} - {projectCount > pageLimit && ( -

- Not all projects are shown here. Try searching to find a specific project. -

- )} -
-
- - - - - snap.resetFilters()} - className="flex gap-x-2 items-center" - > - - Reset filters - - -
-
-
-
- ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx deleted file mode 100644 index 81db3d3d2c1..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { ArchiveIcon, InboxIcon } from 'lucide-react' -import { useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - -import AlertError from 'components/ui/AlertError' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { InfiniteListDefault } from 'components/ui/InfiniteList' -import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useNotificationsArchiveAllMutation } from 'data/notifications/notifications-v2-archive-all-mutation' -import { useNotificationsV2Query } from 'data/notifications/notifications-v2-query' -import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query' -import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useNotificationsStateSnapshot } from 'state/notifications' -import { - Button, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import NotificationRow from './NotificationRow' -import { NotificationsFilter } from './NotificationsFilter' - -export const NotificationsPopoverV2 = () => { - const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox') - - const snap = useNotificationsStateSnapshot() - - // Storing in ref as no re-rendering required - const markedRead = useRef([]) - - const { data: organizations } = useOrganizationsQuery({ enabled: open }) - const { - data, - error, - isLoading, - isError, - isSuccess, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = useNotificationsV2Query( - { - status: - activeTab === 'archived' - ? 'archived' - : snap.filterStatuses.includes('unread') - ? 'new' - : undefined, - filters: { - priority: snap.filterPriorities, - organizations: snap.filterOrganizations, - projects: snap.filterProjects, - }, - }, - { enabled: open } - ) - const { data: summary } = useNotificationsSummaryQuery() - const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() - const { mutate: archiveAllNotifications, isLoading: isArchiving } = - useNotificationsArchiveAllMutation({ - onSuccess: () => toast.success('Successfully archived all notifications'), - }) - - const notifications = useMemo(() => data?.pages.flatMap((page) => page) ?? [], [data?.pages]) - const hasNewNotifications = summary?.unread_count ?? 0 > 0 - const hasWarning = summary?.has_warning - const hasCritical = summary?.has_critical - - const markNotificationsRead = () => { - if (markedRead.current.length > 0) { - updateNotifications({ ids: markedRead.current, status: 'seen' }) - } - } - - return ( - { - setOpen(open) - if (!open) markNotificationsRead() - }} - > - - - - {hasCritical && ( -
-
-
- )} - {hasWarning && !hasCritical && ( -
-
-
- )} - {!!hasNewNotifications && !hasCritical && !hasWarning && ( -
-
-
- )} -
- } - /> - - -
-

Notifications

-
- { - setActiveTab(tab as 'inbox' | 'archived') - if (tab === 'archived' && snap.filterStatuses.includes('unread')) { - snap.setFilters('unread', 'status') - } - }} - value={activeTab} - > -
- - - Inbox -
9 ? 'px-0.5 w-auto' : 'w-4' - )} - > - {summary?.unread_count} -
-
- - Archived - -
- -
-
-
-
-
- {isLoading && ( -
- -
- )} - {isError && ( -
- -
- )} - {isSuccess && ( -
- {notifications.length > 0 && - !(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? ( - ( -
- -
- )} - itemProps={{ - getOrganizationById: (id: number) => - organizations?.find((org) => org.id === id)!, - getOrganizationBySlug: (slug: string) => - organizations?.find((org) => org.slug === slug)!, - onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => { - updateNotifications({ ids: [id], status }) - }, - queueMarkRead: (id: string) => { - if (markedRead.current && !markedRead.current.includes(id)) { - markedRead.current.push(id) - } - }, - }} - getItemSize={() => 56} - hasNextPage={hasNextPage} - isLoadingNextPage={isFetchingNextPage} - onLoadNextPage={() => fetchNextPage()} - /> - ) : ( -
- -
-

- {activeTab === 'archived' - ? `No archived notifications${ - snap.numFiltersApplied > 0 - ? ` based on the ${snap.numFiltersApplied} filter${ - snap.numFiltersApplied > 1 ? 's' : '' - } applied` - : '' - }` - : snap.numFiltersApplied > 0 - ? `No notifications based on the ${snap.numFiltersApplied} filter${ - snap.numFiltersApplied > 1 ? 's' : '' - } applied` - : 'All caught up'} -

-

- {activeTab === 'archived' - ? 'Notifications that you have previously archived will be shown here' - : 'You will be notified here for any notices on your organizations and projects'} -

-
-
- )} -
- )} -
- {notifications.length > 0 && activeTab === 'inbox' && ( -
- -
- )} -
- - ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index 85b8d86d5ea..ca5af49f6b3 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -1,13 +1,12 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' - -import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' +import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' export const SIDEBAR_KEYS = { AI_ASSISTANT: 'ai-assistant', @@ -15,16 +14,14 @@ export const SIDEBAR_KEYS = { ADVISOR_PANEL: 'advisor-panel', } as const -// LayoutSidebars are meant to be used within a project, but rendered within DefaultLayout -// to prevent unnecessary registering / unregistering of sidebars with every route change export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() - useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i') - useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e') - useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => ) + useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project) + useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project) + useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true) const router = useRouter() const { openSidebar, activeSidebar } = useSidebarManagerSnapshot() diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx index 1c5cef6bb60..3a0bd639a17 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx @@ -12,6 +12,70 @@ vi.mock('components/ui/AIAssistantPanel/AIAssistant', () => ({ AIAssistant: () =>
AI Assistant
, })) +vi.mock('components/ui/EditorPanel/EditorPanel', () => ({ + EditorPanel: () =>
Editor Panel
, +})) + +vi.mock('components/ui/AdvisorPanel/AdvisorPanel', () => ({ + AdvisorPanel: () =>
Advisor Panel
, +})) + +const mockProject = { + id: 1, + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY' as const, + organization_id: 1, + cloud_provider: 'AWS', + region: 'us-east-1', + inserted_at: new Date().toISOString(), + subscription_id: 'subscription-1', + db_host: 'db.supabase.co', + is_branch_enabled: false, + is_physical_backups_enabled: false, + restUrl: 'https://project-1.supabase.co', +} + +let mockProjectData: typeof mockProject | undefined = mockProject + +vi.mock('hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: () => { + // Access the variable at runtime when the function is called + return { + data: mockProjectData, + } + }, +})) + +vi.mock('hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => ({ + data: { + id: 1, + name: 'Organization 1', + slug: 'test-org', + plan: { id: 'free', name: 'Free' }, + managed_by: 'supabase', + is_owner: true, + billing_email: 'billing@example.com', + billing_partner: null, + usage_billing_enabled: false, + stripe_customer_id: 'stripe-1', + subscription_id: 'subscription-1', + organization_requires_mfa: false, + opt_in_tags: [], + restriction_status: null, + restriction_data: null, + organization_missing_address: false, + }, + }), +})) + +vi.mock('data/telemetry/send-event-mutation', () => ({ + useSendEventMutation: () => ({ + mutate: vi.fn(), + }), +})) + const resetSidebarManagerState = () => { Object.keys(sidebarManagerState.sidebars).forEach((id) => { sidebarManagerState.unregisterSidebar(id) @@ -59,7 +123,8 @@ describe('LayoutSidebar', () => { sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) }) - expect(await screen.findByTestId('ai-assistant-sidebar')).toBeInTheDocument() + const sidebar = await screen.findByTestId('ai-assistant-sidebar') + expect(sidebar).toBeTruthy() }) it('auto-opens when sidebar query param matches a registered sidebar', async () => { @@ -69,4 +134,54 @@ describe('LayoutSidebar', () => { await screen.findByTestId('ai-assistant-sidebar') }) + + describe('at organization level', () => { + beforeEach(() => { + routerMock.setCurrentUrl('/org/default') + // Set project to undefined to simulate org-level (no project) + mockProjectData = undefined + }) + + afterEach(() => { + // Reset to project data for other tests + mockProjectData = mockProject + }) + + it('does not register project-related sidebars when no project is available', async () => { + renderSidebar() + + // Wait a bit to ensure sidebars have been registered + await waitFor(() => { + // Project-related sidebars should not be registered + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.AI_ASSISTANT]).toBeUndefined() + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.EDITOR_PANEL]).toBeUndefined() + // Advisor panel should still be available (doesn't require project) + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined() + }) + }) + + it('does not render project-related sidebars even when toggled', async () => { + renderSidebar() + + await waitFor(() => { + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined() + }) + + // Try to toggle AI_ASSISTANT - should not work since it's not registered + act(() => { + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + }) + + // Should not render since it's not registered + expect(screen.queryByTestId('ai-assistant-sidebar')).toBeNull() + expect(screen.queryByTestId('editor-panel-sidebar')).toBeNull() + + // Advisor panel should work + act(() => { + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + }) + + expect(await screen.findByTestId('advisor-panel-sidebar')).toBeTruthy() + }) + }) }) diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 451cb975512..45514fe65d8 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -19,7 +19,6 @@ import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' -import { LayoutSidebar } from './LayoutSidebar' import { LoadingState } from './LoadingState' import { ProjectPausedState } from './PausedState/ProjectPausedState' import { PauseFailedState } from './PauseFailedState' @@ -182,39 +181,26 @@ export const ProjectLayout = forwardRef - - -
- {showPausedState ? ( -
-
- -
-
- ) : ( - - - {children} - - )} -
-
- -
+ {showPausedState ? ( +
+
+ +
+
+ ) : ( + + + {children} + + )} +
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx new file mode 100644 index 00000000000..cc124a5c69c --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx @@ -0,0 +1,41 @@ +import LintDetail from 'components/interfaces/Linter/LintDetail' +import { Lint } from 'data/lint/lint-query' +import { Notification } from 'data/notifications/notifications-v2-query' +import { noop } from 'lodash' +import { AdvisorItem } from './AdvisorPanelHeader' +import { NotificationDetail } from './NotificationDetail' + +interface AdvisorDetailProps { + item: AdvisorItem + projectRef: string + onUpdateNotificationStatus?: (id: string, status: 'archived' | 'seen') => void +} + +export const AdvisorDetail = ({ + item, + projectRef, + onUpdateNotificationStatus = noop, +}: AdvisorDetailProps) => { + if (item.source === 'lint') { + const lint = item.original as Lint + return ( +
+ +
+ ) + } + + if (item.source === 'notification') { + const notification = item.original as Notification + return ( +
+ +
+ ) + } + + return null +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx new file mode 100644 index 00000000000..6fc70c1930c --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx @@ -0,0 +1,100 @@ +import { X } from 'lucide-react' + +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { FilterPopover } from 'components/ui/FilterPopover' +import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state' +import { TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_ } from 'ui' + +const severityOptions = [ + { label: 'Critical', value: 'critical' }, + { label: 'Warning', value: 'warning' }, + { label: 'Info', value: 'info' }, +] + +const statusOptions = [ + { label: 'Unread', value: 'unread' }, + { label: 'Archived', value: 'archived' }, +] + +interface AdvisorFiltersProps { + activeTab: AdvisorTab + onTabChange: (tab: string) => void + severityFilters: AdvisorSeverity[] + onSeverityFiltersChange: (filters: AdvisorSeverity[]) => void + statusFilters: string[] + onStatusFiltersChange: (filters: string[]) => void + hasProjectRef?: boolean + onClose: () => void + isPlatform?: boolean +} + +export const AdvisorFilters = ({ + activeTab, + onTabChange, + severityFilters, + onSeverityFiltersChange, + statusFilters, + onStatusFiltersChange, + hasProjectRef = true, + onClose, + isPlatform = false, +}: AdvisorFiltersProps) => { + return ( +
+
+ + + + All + + + Security + + + Performance + + {isPlatform && ( + + Messages + + )} + + +
+ {isPlatform && ( + + )} + { + onSeverityFiltersChange(values as AdvisorSeverity[]) + }} + /> + } + onClick={onClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx index 1a5cdfe2724..23d798e7dea 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx @@ -1,33 +1,23 @@ -import { AlertTriangle, ChevronLeft, ChevronRight, Gauge, Inbox, Shield, X } from 'lucide-react' -import { useMemo } from 'react' +import dayjs from 'dayjs' +import { useMemo, useRef } from 'react' -import LintDetail from 'components/interfaces/Linter/LintDetail' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { FilterPopover } from 'components/ui/FilterPopover' import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' +import { + Notification, + NotificationData, + useNotificationsV2Query, +} from 'data/notifications/notifications-v2-query' +import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { IS_PLATFORM } from 'lib/constants' import { AdvisorSeverity, AdvisorTab, useAdvisorStateSnapshot } from 'state/advisor-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { Badge, Button, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' -import { EmptyAdvisor } from './EmptyAdvisor' - -type AdvisorItem = { - id: string - title: string - severity: AdvisorSeverity - createdAt?: number - tab: Exclude - source: 'lint' - original: Lint -} - -const severityOptions = [ - { label: 'Critical', value: 'critical' }, - { label: 'Warning', value: 'warning' }, - { label: 'Info', value: 'info' }, -] +import { AdvisorDetail } from './AdvisorDetail' +import { AdvisorFilters } from './AdvisorFilters' +import { AdvisorPanelBody } from './AdvisorPanelBody' +import { AdvisorItem, AdvisorPanelHeader } from './AdvisorPanelHeader' const severityOrder: Record = { critical: 0, @@ -35,30 +25,6 @@ const severityOrder: Record = { info: 2, } -const severityLabels: Record = { - critical: 'Critical', - warning: 'Warning', - info: 'Info', -} - -const severityBadgeVariants: Record = { - critical: 'destructive', - warning: 'warning', - info: 'default', -} - -const severityColorClasses: Record = { - critical: 'text-destructive', - warning: 'text-warning', - info: 'text-foreground-light', -} - -const tabIconMap: Record, React.ElementType> = { - security: Shield, - performance: Gauge, - messages: Inbox, -} - const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { switch (level) { case 'ERROR': @@ -70,20 +36,39 @@ const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { } } +const notificationPriorityToSeverity = (priority: string | null | undefined): AdvisorSeverity => { + switch (priority) { + case 'Critical': + return 'critical' + case 'Warning': + return 'warning' + default: + return 'info' + } +} + export const AdvisorPanel = () => { const { activeTab, severityFilters, selectedItemId, + selectedItemSource, setActiveTab, setSeverityFilters, clearSeverityFilters, - setSelectedItemId, + setSelectedItem, + notificationFilterStatuses, + notificationFilterPriorities, + setNotificationFilters, + resetNotificationFilters, } = useAdvisorStateSnapshot() const { data: project } = useSelectedProjectQuery() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot() const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL + const markedRead = useRef([]) + const hasProjectRef = !!project?.ref const { data: lintData, @@ -91,9 +76,58 @@ export const AdvisorPanel = () => { isError: isLintsError, } = useProjectLintsQuery( { projectRef: project?.ref }, - { enabled: isSidebarOpen && !!project?.ref } + { enabled: isSidebarOpen && hasProjectRef && activeTab !== 'messages' } ) + // Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs) + const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM + + const notificationStatus = useMemo(() => { + if (notificationFilterStatuses.includes('archived')) { + return 'archived' + } + if (notificationFilterStatuses.includes('unread')) { + return 'new' + } + return undefined + }, [notificationFilterStatuses]) + + // Memoize filters to prevent query key changes on every render + // Use selected organization and project if they exist + const notificationFilters = useMemo( + () => ({ + priority: notificationFilterPriorities, + organizations: selectedOrganization?.slug ? [selectedOrganization.slug] : [], + projects: project?.ref ? [project.ref] : [], + }), + [notificationFilterPriorities, selectedOrganization?.slug, project?.ref] + ) + + const { + data: notificationsData, + isLoading: isNotificationsLoading, + isError: isNotificationsError, + } = useNotificationsV2Query( + { + status: notificationStatus, + filters: notificationFilters, + limit: 20, + }, + { enabled: shouldLoadNotifications } + ) + + const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() + + const notifications = useMemo(() => { + return notificationsData?.pages.flatMap((page) => page) ?? [] + }, [notificationsData?.pages]) + + const markNotificationsRead = () => { + if (markedRead.current.length > 0) { + updateNotifications({ ids: markedRead.current, status: 'seen' }) + } + } + const lintItems = useMemo(() => { if (!lintData) return [] @@ -121,8 +155,24 @@ export const AdvisorPanel = () => { .filter((item): item is AdvisorItem => item !== null) }, [lintData]) + const notificationItems = useMemo(() => { + if (!IS_PLATFORM) return [] + return notifications?.map((notification): AdvisorItem => { + const data = notification.data as NotificationData + return { + id: notification.id, + title: data.title, + severity: notificationPriorityToSeverity(notification.priority), + createdAt: dayjs(notification.inserted_at).valueOf(), + tab: 'messages' as const, + source: 'notification' as const, + original: notification, + } + }) + }, [notifications]) + const combinedItems = useMemo(() => { - const all = [...lintItems] + const all = [...lintItems, ...notificationItems] return all.sort((a, b) => { const severityDiff = severityOrder[a.severity] - severityOrder[b.severity] @@ -133,80 +183,107 @@ export const AdvisorPanel = () => { return a.title.localeCompare(b.title) }) - }, [lintItems]) + }, [lintItems, notificationItems]) const filteredItems = useMemo(() => { return combinedItems.filter((item) => { + // Filter by severity if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) { return false } - if (activeTab === 'all') return true + // Filter by tab + if (activeTab === 'all') { + // When no projectRef, only show notifications in 'all' tab + if (!hasProjectRef && item.source !== 'notification') { + return false + } + return true + } return item.tab === activeTab }) - }, [combinedItems, severityFilters, activeTab]) + }, [combinedItems, severityFilters, activeTab, hasProjectRef]) const itemsFilteredByTabOnly = useMemo(() => { return combinedItems.filter((item) => { - if (activeTab === 'all') return true + if (activeTab === 'all') { + // When no projectRef, only show notifications in 'all' tab + if (!hasProjectRef && item.source !== 'notification') { + return false + } + return true + } return item.tab === activeTab }) - }, [combinedItems, activeTab]) + }, [combinedItems, activeTab, hasProjectRef]) const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length - const selectedItem = combinedItems.find((item) => item.id === selectedItemId) + const selectedItem = combinedItems.find( + (item) => item.id === selectedItemId && item.source === selectedItemSource + ) const isDetailView = !!selectedItem - const isLoading = isLintsLoading - const isError = isLintsError + // Only show loading state if the query is actually enabled + const isLintsActuallyLoading = + isSidebarOpen && hasProjectRef && activeTab !== 'messages' && isLintsLoading + const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading + const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading + const isError = isLintsError || isNotificationsError const handleTabChange = (tab: string) => { setActiveTab(tab as AdvisorTab) + setSelectedItem(undefined) } const handleBackToList = () => { - setSelectedItemId(undefined) + setSelectedItem(undefined) + markNotificationsRead() } const handleClose = () => { + markNotificationsRead() closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) } + const handleItemClick = (item: AdvisorItem) => { + setSelectedItem(item.id, item.source) + if (item.source === 'notification') { + const notification = item.original as Notification + if (notification.status === 'new' && !markedRead.current.includes(notification.id)) { + markedRead.current.push(notification.id) + } + } + } + + const handleUpdateNotificationStatus = (id: string, status: 'archived' | 'seen') => { + updateNotifications({ ids: [id], status }) + } + + const handleClearAllFilters = () => { + clearSeverityFilters() + resetNotificationFilters() + } + + const hasAnyFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0 + return (
{isDetailView ? ( <> -
- } - 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 ( -
- -
- ) - })} -
- {severityFilters.length > 0 && hiddenItemsCount > 0 && ( -
- -
- )} - - )} +
)}
) } - -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 ( +
+ +
+ ) + })} +
+ {severityFilters.length > 0 && hiddenItemsCount > 0 && ( +
+ +
+ )} + + ) +} 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 ( + + ) + } else if (action.action_type !== undefined) { + return ( + + ) + } else { + return null + } + })} + {notification.status === 'archived' ? ( + + ) : ( + + )} +
+
+ ) +} 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