Unify Inbox and Advisor (#40026)

* sidebar-manager

* storage keys

* tests

* more ai spots

* test fix

* revert to default

* remove ref

* Update apps/studio/state/sidebar-manager-state.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix ts

* fix

* fux

* fux query param

* clean

* fix

* more

* mock local storage

* simplify

* remove provider test

* remve useopensidebar

* fix(new homepage): open ai assistant on advisor card button clicks

* Update apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/state/sidebar-manager-state.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* refine

* editor sidebar manager

* reset results

* advisor sidebar manager

* empty state and notice

* event tracking

* remove variable

* remove use effect

* open in sidebar

* use sidebar old home

* Update apps/studio/components/ui/EditorPanel/EditorPanel.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* connect hotkey

* Update apps/studio/components/layouts/AppLayout/AssistantButton.tsx

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/state/advisor-state.ts

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* Update apps/studio/state/advisor-state.ts

Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>

* fix

* initial prompt

* fix(inline editor button): only show keyboard shortcut if hotkey active

* cleanup(advisor panel): minor code cleanup

* fix(advisor panel): misplaced key on list

* fix(advisor panel): add error state

* fix(advisor panel): improve a11y

* fix(advisor panel): cannot find selected item

* fix

* fix

* tooltip

* link

* sidebar move up

* merge inbox

* project/org sidebars

* panels

* clean

* fix use effect

* layoutheader export

* fix

* ts

* prettier

* tests

* remove markdown

* remove org and project filters from state

* text link

* Update apps/studio/state/sidebar-manager-state.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix

* prettier

* remove files

* bump limit

* noop

* format

* remove notifications on self hosted

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-11-07 15:01:53 +10:00
1 parent 3b98a9844d
commit c63d2a92a0
21 files changed
+901 -1047

No files matched your search

@@ -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 =
@@ -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) {
@@ -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 })
@@ -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 (
<SidebarProvider defaultOpen={false}>
<ProjectContextProvider projectRef={ref}>
<LayoutSidebarProvider>
<LayoutSidebarProvider>
<ProjectContextProvider projectRef={ref}>
<AppBannerContextProvider>
<div className="flex flex-col h-screen w-screen">
{/* Top Banner */}
@@ -75,13 +76,22 @@ const DefaultLayout = ({
<div className="flex flex-1 w-full overflow-y-hidden">
{/* Sidebar - Only show for project pages, not account pages */}
{!router.pathname.startsWith('/account') && <Sidebar />}
{/* Main Content */}
<div className="flex-grow h-full overflow-y-auto">{children}</div>
{/* Main Content with Layout Sidebar */}
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId="default-layout-content"
>
<ResizablePanel id="panel-content" defaultSize={1} className="w-full">
<div className="h-full overflow-y-auto">{children}</div>
</ResizablePanel>
<LayoutSidebar />
</ResizablePanelGroup>
</div>
</div>
</AppBannerContextProvider>
</LayoutSidebarProvider>
</ProjectContextProvider>
</ProjectContextProvider>
</LayoutSidebarProvider>
</SidebarProvider>
)
}
@@ -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<HTMLSpanElement>) => (
@@ -212,11 +210,10 @@ export const LayoutHeader = ({
<div className="overflow-hidden flex items-center gap-2">
<HelpPopover />
<NotificationsPopoverV2 />
<AdvisorButton projectRef={projectRef} />
<AnimatePresence initial={false}>
{!!projectRef && (
<>
<AdvisorButton />
<InlineEditorButton />
<AssistantButton />
</>
@@ -229,10 +226,10 @@ export const LayoutHeader = ({
<>
<LocalVersionPopover />
<div className="overflow-hidden flex items-center gap-2">
<AdvisorButton projectRef={projectRef} />
<AnimatePresence initial={false}>
{!!projectRef && (
<>
<AdvisorButton />
<InlineEditorButton />
<AssistantButton />
</>
@@ -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 (
<div
ref={virtualizer.measureElement}
className={cn(
`p-4 flex justify-between gap-x-3 group`,
index !== 0 ? 'border-t border-overlay' : '',
status === 'new' ? 'bg-surface-100/50' : 'bg-background'
)}
>
<div ref={viewRef} className="flex flex-col gap-y-2.5 w-full py-0.5">
{(project !== undefined || organization !== undefined) && (
<div className="flex items-center max-w-[350px]">
{organization !== undefined && (
<Link
title={organization.name}
href={`/org/${organization.slug}/general`}
className="text-xs transition text-foreground-light hover:text-foreground underline truncate"
>
{organization.name}
</Link>
)}
{organization !== undefined && project !== undefined && (
<span className="text-foreground-lighter">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
)}
{project !== undefined && (
<Link
title={project.name}
href={`/project/${project.ref}`}
className="text-xs transition text-foreground-light hover:text-foreground underline truncate"
>
{project.name}
</Link>
)}
</div>
)}
<div className="flex items-center gap-x-2">
<p className="text-sm break-word">
{data.title}{' '}
<span className="ml-1 text-xs text-foreground-light capitalize-sentence">
{daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow}
</span>
</p>
</div>
{data.message !== undefined && (
<Markdown className="text-foreground-light text-xs" content={data.message} />
)}
{(data.actions ?? []).length > 0 && (
<div className="flex items-center gap-x-2">
{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 (
<Button
key={key}
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
asChild
>
<Link href={url} target="_blank" rel="noreferrer">
{action.label}
</Link>
</Button>
)
} else if (action.action_type !== undefined) {
return (
<Button
key={key}
type="default"
onClick={() => onButtonAction(action.action_type)}
>
{action.label}
</Button>
)
} else {
return null
}
})}
</div>
)}
</div>
<div className="flex flex-col items-center gap-y-2">
{priority === 'Warning' && <WarningIcon className="w-5 h-5" />}
{priority === 'Critical' && <CriticalIcon className="w-5 h-5" />}
{notification.status === 'archived' ? (
<ButtonTooltip
type="outline"
icon={
<ArchiveRestoreIcon size={13} strokeWidth={2} className="text-foreground-light" />
}
className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full"
onClick={() => onUpdateNotificationStatus(notification.id, 'seen')}
tooltip={{ content: { text: 'Unarchive', side: 'bottom' } }}
/>
) : (
<ButtonTooltip
type="outline"
icon={<Archive size={13} strokeWidth={2} className="text-foreground-light" />}
className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full"
onClick={() => onUpdateNotificationStatus(notification.id, 'archived')}
tooltip={{ content: { text: 'Archive', side: 'bottom' } }}
/>
)}
</div>
</div>
)
}
export default NotificationRow
@@ -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 (
<Popover_Shadcn_ modal={true} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={snap.numFiltersApplied > 0 ? 'default' : 'text'}
icon={<Settings2Icon strokeWidth={1} />}
className="px-1 h-[26px]"
>
{snap.numFiltersApplied > 0 &&
`${snap.numFiltersApplied} filter${snap.numFiltersApplied > 1 ? 's' : ''} applied`}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-64" side="bottom" align="end">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Find filter..." />
<CommandEmpty_Shadcn_>No filters found that match your search</CommandEmpty_Shadcn_>
<CommandList_Shadcn_>
<ScrollArea className="max-h-[240px] py-1 overflow-y-auto">
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<CommandItem_Shadcn_
disabled={activeTab === 'archived'}
onSelect={() => {
snap.setFilters('unread', 'status')
}}
>
<Label_Shadcn_
htmlFor={'unread'}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterStatuses.includes('unread') && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name="unread"
checked={snap.filterStatuses.includes('unread')}
/>
Unread
</Label_Shadcn_>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Priority</DropdownMenuLabel>
<CommandItem_Shadcn_
onSelect={() => {
snap.setFilters('Warning', 'priority')
}}
className="flex items-center gap-x-2"
>
<Label_Shadcn_
htmlFor={'warning'}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterPriorities.includes('Warning') && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name="warning"
checked={snap.filterPriorities.includes('Warning')}
/>
<WarningIcon className="size-4" />
Warning
</Label_Shadcn_>
</CommandItem_Shadcn_>
<CommandItem_Shadcn_
key={'critical'}
onSelect={() => {
snap.setFilters('Critical', 'priority')
}}
>
<Label_Shadcn_
htmlFor={'critical'}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterPriorities.includes('Critical') && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name="critical"
checked={snap.filterPriorities.includes('Critical')}
/>
<CriticalIcon className="size-4" />
Critical
</Label_Shadcn_>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
{(organizations ?? []).map((org) => (
<CommandItem_Shadcn_
key={org.slug}
value={org.name.replaceAll('"', '')}
className="flex items-center gap-x-2"
onSelect={() => {
snap.setFilters(org.slug, 'organizations')
}}
>
<Label_Shadcn_
htmlFor={`${org.slug}`}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterOrganizations.includes(org.slug) && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name={`${org.slug}`}
checked={snap.filterOrganizations.includes(org.slug)}
/>
{org.name}
</Label_Shadcn_>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
<CommandSeparator_Shadcn_ />
<CommandGroup_Shadcn_>
<DropdownMenuLabel>Projects</DropdownMenuLabel>
{/*
[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
*/}
<div className="px-2 mb-2">
<Input
size="tiny"
className="rounded"
placeholder="Search for a project"
value={search}
onChange={(e) => setSearch(e.target.value)}
actions={
search.length > 0 ? (
<X
size={14}
className="cursor-pointer mr-1"
onClick={() => setSearch('')}
/>
) : null
}
/>
</div>
{(projects ?? []).map((project) => (
<CommandItem_Shadcn_
key={project.ref}
value={project.ref}
className="flex items-center gap-x-2"
onSelect={() => {
snap.setFilters(project.ref, 'projects')
}}
>
<Label_Shadcn_
htmlFor={`${project.ref}`}
className={cn(
'flex items-center gap-x-2 text-xs text-foreground-light transition-colors',
snap.filterProjects.includes(project.ref) && 'text-foreground'
)}
>
<Checkbox_Shadcn_
name={`${project.ref}`}
checked={snap.filterProjects.includes(project.ref)}
/>
{project.name}
</Label_Shadcn_>
</CommandItem_Shadcn_>
))}
{projectCount > pageLimit && (
<p className="text-foreground-lighter text-xs pt-2 px-2">
Not all projects are shown here. Try searching to find a specific project.
</p>
)}
</CommandGroup_Shadcn_>
</ScrollArea>
<CommandSeparator_Shadcn_ />
<CommandGroup>
<CommandItem_Shadcn_
onSelect={() => snap.resetFilters()}
className="flex gap-x-2 items-center"
>
<RotateCcw size={12} />
Reset filters
</CommandItem_Shadcn_>
</CommandGroup>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -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<string[]>([])
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 (
<Popover_Shadcn_
modal={false}
open={open}
onOpenChange={(open) => {
setOpen(open)
if (!open) markNotificationsRead()
}}
>
<PopoverTrigger_Shadcn_ asChild>
<ButtonTooltip
tooltip={{
content: {
text: 'Notifications',
},
}}
type="outline"
size="tiny"
className={cn(
'rounded-full w-[32px] h-[32px] flex items-center justify-center p-0 group',
open && 'bg-foreground text-background'
)}
icon={
<div className="relative">
<InboxIcon
size={18}
strokeWidth={1.5}
className={cn(
'text-foreground-light group-hover:text-foreground',
open && 'text-background group-hover:text-background'
)}
/>
{hasCritical && (
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
<div className="w-0 h-0 border-l-[6px] border-r-[6px] border-b-[10px] border-l-transparent border-r-transparent border-b-destructive" />
</div>
)}
{hasWarning && !hasCritical && (
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-warning" />
</div>
)}
{!!hasNewNotifications && !hasCritical && !hasWarning && (
<div className="absolute -top-1.5 -right-2 w-3.5 h-3.5 z-10 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-brand" />
</div>
)}
</div>
}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 w-screen md:w-[450px] overflow-hidden"
side="bottom"
align="end"
>
<div className="px-4">
<p className="pt-4 pb-1 text-sm">Notifications</p>
<div className="flex items-center">
<Tabs_Shadcn_
className="w-full"
onValueChange={(tab: string) => {
setActiveTab(tab as 'inbox' | 'archived')
if (tab === 'archived' && snap.filterStatuses.includes('unread')) {
snap.setFilters('unread', 'status')
}
}}
value={activeTab}
>
<div className="flex items-center">
<TabsList_Shadcn_ className="flex gap-5 grow border-none">
<TabsTrigger_Shadcn_
id="inbox"
value="inbox"
className="px-0 data-[state=active]:bg-transparent flex gap-2"
>
Inbox
<div
className={cn(
'flex items-center justify-center text-xs rounded-full bg-surface-300 h-4',
(summary?.unread_count ?? 0) > 9 ? 'px-0.5 w-auto' : 'w-4'
)}
>
{summary?.unread_count}
</div>
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_
id="archived"
value="archived"
className="px-0 data-[state=active]:bg-transparent"
>
Archived
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<NotificationsFilter activeTab={activeTab} />
</div>
</Tabs_Shadcn_>
</div>
</div>
<div className="border-t">
{isLoading && (
<div className="p-4">
<GenericSkeletonLoader />
</div>
)}
{isError && (
<div className="p-4">
<AlertError subject="Failed to retrieve notifications" error={error} />
</div>
)}
{isSuccess && (
<div className="flex flex-1 h-[400px] bg-background">
{notifications.length > 0 &&
!(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? (
<InfiniteListDefault
className="w-full"
items={notifications}
ItemComponent={NotificationRow}
LoaderComponent={({ style }) => (
<div style={style} className="p-4">
<ShimmeringLoader />
</div>
)}
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()}
/>
) : (
<div className="flex flex-col gap-y-4 items-center flex-grow justify-center">
<InboxIcon size={32} className="text-foreground-light" />
<div className="flex flex-col gap-y-1">
<p className="text-foreground-light text-sm mx-auto text-center">
{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'}
</p>
<p className="text-foreground-lighter text-xs w-60 mx-auto text-center">
{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'}
</p>
</div>
</div>
)}
</div>
)}
</div>
{notifications.length > 0 && activeTab === 'inbox' && (
<div className="flex items-center justify-center p-1.5 border-t">
<Button
disabled={isArchiving}
loading={isArchiving}
type="text"
icon={<ArchiveIcon />}
onClick={() => archiveAllNotifications()}
>
Archive all
</Button>
</div>
)}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -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, () => <AIAssistant />, {}, 'i')
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <EditorPanel />, {}, 'e')
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />)
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => <AIAssistant />, {}, 'i', !!project)
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <EditorPanel />, {}, 'e', !!project)
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />, {}, undefined, true)
const router = useRouter()
const { openSidebar, activeSidebar } = useSidebarManagerSnapshot()
@@ -12,6 +12,70 @@ vi.mock('components/ui/AIAssistantPanel/AIAssistant', () => ({
AIAssistant: () => <div data-testid="ai-assistant-sidebar">AI Assistant</div>,
}))
vi.mock('components/ui/EditorPanel/EditorPanel', () => ({
EditorPanel: () => <div data-testid="editor-panel-sidebar">Editor Panel</div>,
}))
vi.mock('components/ui/AdvisorPanel/AdvisorPanel', () => ({
AdvisorPanel: () => <div data-testid="advisor-panel-sidebar">Advisor Panel</div>,
}))
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()
})
})
})
@@ -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<HTMLDivElement, PropsWithChildren<Projec
<ResizablePanel
defaultSize={1}
order={2}
id="panel-right"
className="h-full flex flex-col w-full"
id="panel-project-content"
className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId="project-layout-content"
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={ref}
>
<ResizablePanel
id="panel-content"
defaultSize={1}
className={cn('w-full xl:min-w-[600px] bg-dash-sidebar')}
>
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
ref={ref}
>
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
<LayoutSidebar />
</ResizablePanelGroup>
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
</ResizablePanelGroup>
</div>
@@ -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 (
<div className="px-6 py-6">
<LintDetail lint={lint} projectRef={projectRef} />
</div>
)
}
if (item.source === 'notification') {
const notification = item.original as Notification
return (
<div className="px-6 py-6">
<NotificationDetail
notification={notification}
onUpdateStatus={onUpdateNotificationStatus}
/>
</div>
)
}
return null
}
@@ -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 (
<div className="border-b">
<div className="flex items-center justify-between gap-3 px-4 h-[46px]">
<Tabs_Shadcn_ value={activeTab} onValueChange={onTabChange} className="h-full">
<TabsList_Shadcn_ className="border-b-0 gap-4 h-full">
<TabsTrigger_Shadcn_ value="all" className="h-full text-xs">
All
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="security" className="h-full text-xs">
Security
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="performance" className="h-full text-xs">
Performance
</TabsTrigger_Shadcn_>
{isPlatform && (
<TabsTrigger_Shadcn_
value="messages"
className="h-full text-xs flex items-center gap-2"
>
Messages
</TabsTrigger_Shadcn_>
)}
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="flex items-center gap-2">
{isPlatform && (
<FilterPopover
name="Status"
options={statusOptions}
activeOptions={[...statusFilters]}
valueKey="value"
labelKey="label"
isMinimized={true}
onSaveFilters={onStatusFiltersChange}
/>
)}
<FilterPopover
name="Severity"
options={severityOptions}
activeOptions={[...severityFilters]}
valueKey="value"
labelKey="label"
isMinimized={true}
onSaveFilters={(values) => {
onSeverityFiltersChange(values as AdvisorSeverity[])
}}
/>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={onClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
</div>
</div>
)
}
@@ -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<AdvisorTab, 'all'>
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<AdvisorSeverity, number> = {
critical: 0,
@@ -35,30 +25,6 @@ const severityOrder: Record<AdvisorSeverity, number> = {
info: 2,
}
const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> = {
critical: 'destructive',
warning: 'warning',
info: 'default',
}
const severityColorClasses: Record<AdvisorSeverity, string> = {
critical: 'text-destructive',
warning: 'text-warning',
info: 'text-foreground-light',
}
const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, 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<string[]>([])
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<AdvisorItem[]>(() => {
if (!lintData) return []
@@ -121,8 +155,24 @@ export const AdvisorPanel = () => {
.filter((item): item is AdvisorItem => item !== null)
}, [lintData])
const notificationItems = useMemo<AdvisorItem[]>(() => {
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<AdvisorItem[]>(() => {
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<AdvisorItem[]>(() => {
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<AdvisorItem[]>(() => {
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 (
<div className="flex h-full flex-col bg-background">
{isDetailView ? (
<>
<div className="border-b px-4 py-3 flex items-center gap-3">
<ButtonTooltip
type="text"
className="w-7 h-7 p-0 flex justify-center items-center"
icon={<ChevronLeft size={16} strokeWidth={1.5} aria-hidden={true} />}
onClick={handleBackToList}
tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
/>
<div className="flex items-center gap-2 overflow-hidden flex-1">
<div className="flex-1">
<span className="heading-default">{selectedItem?.title}</span>
</div>
{selectedItem && (
<Badge variant={severityBadgeVariants[selectedItem.severity]}>
{severityLabels[selectedItem.severity]}
</Badge>
)}
</div>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={handleClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
<AdvisorPanelHeader
selectedItem={selectedItem}
onBack={handleBackToList}
onClose={handleClose}
/>
<div className="flex-1 overflow-y-auto">
{selectedItem ? (
<AdvisorDetail item={selectedItem} projectRef={project?.ref ?? ''} />
<AdvisorDetail
item={selectedItem}
projectRef={project?.ref ?? ''}
onUpdateNotificationStatus={handleUpdateNotificationStatus}
/>
) : (
<div className="px-6 py-8">
<p className="text-sm text-foreground-light">
@@ -218,118 +295,40 @@ export const AdvisorPanel = () => {
</>
) : (
<>
<div className="border-b">
<div className="flex items-center justify-between gap-3 px-4 h-[46px]">
<Tabs_Shadcn_ value={activeTab} onValueChange={handleTabChange} className="h-full">
<TabsList_Shadcn_ className="border-b-0 gap-4 h-full">
<TabsTrigger_Shadcn_ value="all" className="h-full text-xs">
All
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="security" className="h-full text-xs">
Security
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="performance" className="h-full text-xs">
Performance
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="flex items-center gap-2">
<FilterPopover
name="Severity"
options={severityOptions}
activeOptions={[...severityFilters]}
valueKey="value"
labelKey="label"
onSaveFilters={(values) => setSeverityFilters(values as AdvisorSeverity[])}
/>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={handleClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
</div>
</div>
<AdvisorFilters
activeTab={activeTab}
onTabChange={handleTabChange}
severityFilters={[...severityFilters]}
onSeverityFiltersChange={setSeverityFilters}
statusFilters={[...notificationFilterStatuses]}
onStatusFiltersChange={(values) => {
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}
/>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div>
<GenericSkeletonLoader className="w-full p-4" />
</div>
) : isError ? (
<div className="my-8 mx-4 flex flex-col items-center gap-2">
<AlertTriangle className="text-destructive" />
<h2 className="text-base text-foreground-light">Error loading advisories</h2>
<p className="text-sm text-foreground-lighter">Please try again later.</p>
</div>
) : filteredItems.length === 0 ? (
<EmptyAdvisor
activeTab={activeTab}
hasFilters={severityFilters.length > 0}
onClearFilters={clearSeverityFilters}
/>
) : (
<>
<div className="flex flex-col">
{filteredItems.map((item) => {
const SeverityIcon = tabIconMap[item.tab]
const severityClass = severityColorClasses[item.severity]
return (
<div key={item.id} className="border-b">
<Button
type="text"
className="justify-start w-full block rounded-none h-auto py-3 px-4 text-foreground-light hover:text-foreground"
onClick={() => setSelectedItemId(item.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 overflow-hidden">
<SeverityIcon
size={16}
strokeWidth={1.5}
className={cn('flex-shrink-0', severityClass)}
/>
<span className="truncate">{item.title}</span>
</div>
<ChevronRight
size={16}
strokeWidth={1.5}
className="flex-shrink-0 text-foreground-lighter"
/>
</div>
</Button>
</div>
)
})}
</div>
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
<div className="px-4 py-3">
<Button type="text" className="w-full" onClick={clearSeverityFilters}>
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
</Button>
</div>
)}
</>
)}
<AdvisorPanelBody
isLoading={isLoading}
isError={isError}
filteredItems={filteredItems}
activeTab={activeTab}
severityFilters={[...severityFilters]}
onItemClick={handleItemClick}
onClearFilters={handleClearAllFilters}
hiddenItemsCount={hiddenItemsCount}
hasAnyFilters={hasAnyFilters}
hasProjectRef={hasProjectRef}
/>
</div>
</>
)}
</div>
)
}
interface AdvisorDetailProps {
item: AdvisorItem
projectRef: string
}
const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => {
if (item.source === 'lint') {
const lint = item.original as Lint
return (
<div className="px-6 py-6">
<LintDetail lint={lint} projectRef={projectRef} />
</div>
)
}
}
@@ -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 (
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<Inbox className="text-foreground-muted" strokeWidth={1} />
<div className="text-center">
<p className="heading-default">Project required</p>
<p className="text-foreground-light text-sm">
Select a project to view security and performance advisories
</p>
</div>
</div>
)
}
const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, React.ElementType> = {
security: Shield,
performance: Gauge,
messages: Inbox,
}
const severityColorClasses: Record<AdvisorSeverity, string> = {
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 <NoProjectNotice />
}
if (isLoading) {
return (
<div>
<GenericSkeletonLoader className="w-full p-4" />
</div>
)
}
if (isError) {
return (
<div className="my-8 mx-4 flex flex-col items-center gap-2">
<AlertTriangle className="text-destructive" />
<h2 className="text-base text-foreground-light">Error loading advisories</h2>
<p className="text-sm text-foreground-lighter">Please try again later.</p>
</div>
)
}
if (filteredItems.length === 0) {
return (
<EmptyAdvisor
activeTab={activeTab}
hasFilters={hasAnyFilters}
onClearFilters={onClearFilters}
/>
)
}
return (
<>
<div className="flex flex-col">
{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 (
<div key={`${item.source}-${item.id}`} className="border-b">
<Button
type="text"
className={cn(
'justify-start w-full block rounded-none h-auto py-3 px-4 text-foreground-light hover:text-foreground',
isUnread && 'bg-surface-100/50'
)}
onClick={() => onItemClick(item)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 overflow-hidden">
<SeverityIcon
size={16}
strokeWidth={1.5}
className={cn('flex-shrink-0', severityClass)}
/>
<span className="truncate">{item.title.replace(/[`\\]/g, '')}</span>
</div>
<ChevronRight
size={16}
strokeWidth={1.5}
className="flex-shrink-0 text-foreground-lighter"
/>
</div>
</Button>
</div>
)
})}
</div>
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
<div className="px-4 py-3">
<Button type="text" className="w-full" onClick={onClearFilters}>
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
</Button>
</div>
)}
</>
)
}
@@ -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<AdvisorSeverity, 'destructive' | 'warning' | 'default'> =
{
critical: 'destructive',
warning: 'warning',
info: 'default',
}
export const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
interface AdvisorPanelHeaderProps {
selectedItem: AdvisorItem | undefined
onBack: () => void
onClose: () => void
}
export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => {
return (
<div className="border-b px-4 py-3 flex items-center gap-3">
<ButtonTooltip
type="text"
className="w-7 h-7 p-0 flex justify-center items-center"
icon={<ChevronLeft size={16} strokeWidth={1.5} aria-hidden={true} />}
onClick={onBack}
tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
/>
<div className="flex items-center gap-2 overflow-hidden flex-1">
<div className="flex-1 flex flex-col gap-0.5">
<span className="heading-default">{selectedItem?.title?.replace(/[`\\]/g, '')}</span>
{selectedItem?.createdAt && (
<span className="text-xs text-foreground-light capitalize-sentence">
{(() => {
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
})()}
</span>
)}
</div>
{selectedItem && (
<Badge variant={severityBadgeVariants[selectedItem.severity]}>
{severityLabels[selectedItem.severity]}
</Badge>
)}
</div>
<ButtonTooltip
type="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={onClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
)
}
@@ -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 (
<div>
{(project !== undefined || organization !== undefined) && (
<>
<h3 className="text-sm mb-2">Context</h3>
<div className="flex items-center gap-2 flex-wrap mb-6">
{organization !== undefined && (
<Link
title={organization.name}
href={`/org/${organization.slug}/general`}
className="text-link"
>
{organization.name}
</Link>
)}
{project !== undefined && (
<Link title={project.name} href={`/project/${project.ref}`} className="text-link">
{project.name}
</Link>
)}
</div>
</>
)}
{data.message !== undefined && (
<>
<h3 className="text-sm mb-2">Message</h3>
<Markdown
className="leading-6 text-sm text-foreground-light mb-6"
content={data.message}
/>
</>
)}
<h3 className="text-sm mb-2">Actions</h3>
<div className="flex items-center gap-2">
{(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 (
<Button key={key} type="default" icon={<ExternalLink strokeWidth={1.5} />} asChild>
<Link href={url} target="_blank" rel="noreferrer">
{action.label}
</Link>
</Button>
)
} else if (action.action_type !== undefined) {
return (
<Button key={key} type="default" onClick={() => onButtonAction(action.action_type)}>
{action.label}
</Button>
)
} else {
return null
}
})}
{notification.status === 'archived' ? (
<Button
type="default"
icon={<ArchiveRestoreIcon size={14} strokeWidth={1.5} />}
onClick={() => onUpdateStatus(notification.id, 'seen')}
>
Unarchive
</Button>
) : (
<Button
type="default"
icon={<Archive size={14} strokeWidth={1.5} />}
onClick={() => onUpdateStatus(notification.id, 'archived')}
>
Archive
</Button>
)}
</div>
</div>
)
}
+16 -8
View File
@@ -30,6 +30,7 @@ interface FilterPopoverProps<T> {
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 = <T extends Record<string, any>>({
className,
maxHeightClass = 'h-[205px]',
clearButtonText = 'Clear',
isMinimized = false,
onSaveFilters,
search,
@@ -127,14 +129,20 @@ export const FilterPopover = <T extends Record<string, any>>({
>
<div>
<span>{name}</span>
{activeOptions.length > 0 && <span className="mr-1">:</span>}
{activeOptions.length >= 3 ? (
<span>
{formattedOptions[0]} and {activeOptions.length - 1} others
</span>
) : activeOptions.length > 0 ? (
<span>{formattedOptions.join(', ')}</span>
) : null}
{activeOptions.length > 0 && (
<>
<span className="mr-1">:</span>
{isMinimized ? (
<span>{activeOptions.length}</span>
) : activeOptions.length >= 3 ? (
<span>
{formattedOptions[0]} and {activeOptions.length - 1} others
</span>
) : (
<span>{formattedOptions.join(', ')}</span>
)}
</>
)}
</div>
</Button>
</PopoverTrigger_Shadcn_>
+40 -2
View File
@@ -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)
-69
View File
@@ -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<typeof useSnapshot>[1]) =>
useSnapshot(notificationsState, options)
+9 -5
View File
@@ -143,18 +143,22 @@ export const useRegisterSidebar = (
id: string,
component: () => ReactNode,
handlers: SidebarHandlers = {},
hotKey?: string
hotKey?: string,
enabled?: boolean
) => {
const [isSidebarHotkeyEnabled] = useLocalStorageQuery<boolean>(
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