mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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 />
|
||||
</>
|
||||
|
||||
-189
@@ -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
|
||||
-245
@@ -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_>
|
||||
)
|
||||
}
|
||||
-269
@@ -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_>
|
||||
)
|
||||
}
|
||||
+7
-10
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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_>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user