mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat/notifications v2 (#19162)
* Midway implementation notifications v2 * Some updates to Tab component * Midway implementation notifications v2 again * implement infinite scrolling and loading for notification sv2 * Add button actions * Implement archive notifications * Add filter dropdown for notifications * Update date format * Small style fix * Update * Update API codegen * Add empt state * Add empty state for archived notifications * Fix type * Final changes to notifications v2 * Small style fix * Small fix * Only mark notifications that come into view as read * Update package lock
This commit is contained in:
1 parent
661d8c0668
commit
34a2ccdae3
18 files changed
+838
-144
No files matched your search
+4
-2
@@ -104,11 +104,13 @@ const InvoicesSettings = () => {
|
||||
<p>{x.number}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<InvoiceStatusBadge status={x.status} />
|
||||
<InvoiceStatusBadge status={x.status as any} />
|
||||
</Table.td>
|
||||
<Table.td className="align-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && (
|
||||
{[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(
|
||||
x.status as any
|
||||
) && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`https://redirect.revops.supabase.com/pay-invoice/${x.id}`}
|
||||
|
||||
@@ -9,18 +9,10 @@ const BreadcrumbsView = ({ defaultValue: breadcrumbs }: BreadcrumbsViewProps) =>
|
||||
return (
|
||||
<>
|
||||
{breadcrumbs?.length
|
||||
? breadcrumbs.map((breadcrumb: any, idx: number) => (
|
||||
? breadcrumbs.map((breadcrumb: any, i: number) => (
|
||||
<Fragment key={breadcrumb.key}>
|
||||
<a
|
||||
onClick={breadcrumb.onClick || (() => {})}
|
||||
className={`text-gray-1100 block px-2 py-1 text-xs leading-5 focus:bg-gray-100 focus:text-gray-900 focus:outline-none ${
|
||||
breadcrumb.onClick ? 'cursor-pointer hover:text-white' : ''
|
||||
}`}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</a>
|
||||
{idx !== breadcrumbs.length - 1 && (
|
||||
<span className="text-border-stronger">
|
||||
{i > 0 && (
|
||||
<span className="text-border-stronger dark:text-border-strong">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
@@ -36,6 +28,15 @@ const BreadcrumbsView = ({ defaultValue: breadcrumbs }: BreadcrumbsViewProps) =>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<a
|
||||
onClick={breadcrumb.onClick || (() => {})}
|
||||
className={`text-gray-1100 block px-2 py-1 text-xs leading-5 focus:bg-gray-100 focus:text-gray-900 focus:outline-none ${
|
||||
breadcrumb.onClick ? 'cursor-pointer hover:text-white' : ''
|
||||
}`}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</a>
|
||||
</Fragment>
|
||||
))
|
||||
: null}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ const FeedbackDropdown = ({ alt = false }: FeedbackDropdownProps) => {
|
||||
<Button
|
||||
asChild
|
||||
onClick={onOpen}
|
||||
type="default"
|
||||
type={alt ? 'outline' : 'default'}
|
||||
icon={
|
||||
alt ? null : (
|
||||
<IconMessageCircle size={16} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import { Badge } from 'ui'
|
||||
|
||||
import BranchDropdown from 'components/layouts/AppLayout/BranchDropdown'
|
||||
import EnableBranchingButton from 'components/layouts/AppLayout/EnableBranchingButton/EnableBranchingButton'
|
||||
@@ -11,11 +12,11 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
|
||||
import { useOrgUsageQuery } from 'data/usage/org-usage-query'
|
||||
import { useFlag, useSelectedOrganization, useSelectedProject } from 'hooks'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { Badge } from 'ui'
|
||||
import BreadcrumbsView from './BreadcrumbsView'
|
||||
import FeedbackDropdown from './FeedbackDropdown'
|
||||
import HelpPopover from './HelpPopover'
|
||||
import NotificationsPopover from './NotificationsPopover'
|
||||
import NotificationsPopoverV2 from './NotificationsPopoverV2/NotificationsPopover'
|
||||
|
||||
const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -23,6 +24,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
|
||||
const enableBranchManagement = useFlag('branchManagement')
|
||||
const notificationsV2 = useFlag('notificationsV2')
|
||||
|
||||
const isBranchingEnabled =
|
||||
selectedProject?.is_branch_enabled === true || selectedProject?.parent_project_ref !== undefined
|
||||
@@ -118,9 +120,19 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{customHeaderComponents && customHeaderComponents}
|
||||
{IS_PLATFORM && <HelpPopover />}
|
||||
{IS_PLATFORM && <FeedbackDropdown />}
|
||||
{IS_PLATFORM && <NotificationsPopover />}
|
||||
{IS_PLATFORM && <FeedbackDropdown alt={notificationsV2} />}
|
||||
{IS_PLATFORM && !notificationsV2 && (
|
||||
<>
|
||||
<HelpPopover />
|
||||
<NotificationsPopover />
|
||||
</>
|
||||
)}
|
||||
{IS_PLATFORM && notificationsV2 && (
|
||||
<>
|
||||
<NotificationsPopoverV2 />
|
||||
<HelpPopover alt />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+4
-1
@@ -27,9 +27,10 @@ interface NotificationsPopoverProps {
|
||||
}
|
||||
|
||||
const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const { meta, ui } = useStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: notifications } = useNotificationsQuery()
|
||||
const { mutate: updateNotifications } = useNotificationsUpdateMutation({
|
||||
onError: () => console.error('Failed to update notifications'),
|
||||
@@ -271,6 +272,7 @@ const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
onSelectCancel={() => setProjectToRestart(undefined)}
|
||||
onSelectConfirm={onConfirmProjectRestart}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
size="large"
|
||||
visible={projectToApplyMigration !== undefined}
|
||||
@@ -304,6 +306,7 @@ const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
|
||||
onSelectCancel={() => setProjectToApplyMigration(undefined)}
|
||||
onSelectConfirm={onConfirmProjectApplyMigration}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
size="medium"
|
||||
visible={projectToRollbackMigration !== undefined}
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { Button, IconAlertCircle, IconAlertTriangle, IconArchive, IconExternalLink } from 'ui'
|
||||
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { Notification, NotificationData } from 'data/notifications/notifications-v2-query'
|
||||
import { Organization } from 'types'
|
||||
import { Project } from 'data/projects/project-detail-query'
|
||||
|
||||
interface NotificationRowProps {
|
||||
index: number
|
||||
listRef: any
|
||||
item: Notification
|
||||
setRowHeight: (idx: number, height: number) => void
|
||||
getProject: (ref: string) => Project
|
||||
getOrganization: (id: number) => Organization
|
||||
onArchiveNotification: (id: string) => void
|
||||
queueMarkRead: (id: string) => void
|
||||
}
|
||||
|
||||
const NotificationRow = ({
|
||||
index,
|
||||
listRef,
|
||||
item: notification,
|
||||
setRowHeight,
|
||||
getProject,
|
||||
getOrganization,
|
||||
onArchiveNotification,
|
||||
queueMarkRead,
|
||||
}: NotificationRowProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { ref: viewRef, inView } = useInView()
|
||||
const { status, priority } = notification
|
||||
|
||||
const data = notification.data as NotificationData
|
||||
const project = data.project_ref !== undefined ? getProject(data.project_ref) : undefined
|
||||
const organization = project !== undefined ? getOrganization(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 (ref.current) {
|
||||
listRef.current.resetAfterIndex(0)
|
||||
setRowHeight(index, ref.current.clientHeight)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && notification.status === 'new') {
|
||||
queueMarkRead(notification.id)
|
||||
}
|
||||
}, [inView])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
`p-4 flex justify-between gap-x-3`,
|
||||
index !== 0 ? 'border-t' : '',
|
||||
status !== 'new' ? 'bg-background' : ''
|
||||
)}
|
||||
>
|
||||
<div ref={viewRef} className="flex flex-col gap-y-2 w-full">
|
||||
{(project !== undefined || organization !== undefined) && (
|
||||
<div className="flex items-center">
|
||||
{organization !== undefined && (
|
||||
<Link
|
||||
href={`/org/${organization.slug}/general`}
|
||||
className="text-xs transition text-foreground-light hover:text-foreground underline"
|
||||
>
|
||||
{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
|
||||
href={`/project/${project.ref}`}
|
||||
className="text-xs transition text-foreground-light hover:text-foreground underline"
|
||||
>
|
||||
{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="outline"
|
||||
icon={<IconExternalLink strokeWidth={1.5} size={14} />}
|
||||
>
|
||||
<Link href={url} target="_blank" rel="noreferrer">
|
||||
{action.label}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
} else if (action.action_type !== undefined) {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
type="outline"
|
||||
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' && (
|
||||
<IconAlertCircle
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
className="rounded p-0.5 text-warning-400 bg-warning-600"
|
||||
/>
|
||||
)}
|
||||
{priority === 'Critical' && (
|
||||
<IconAlertTriangle
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
className="rounded p-0.5 text-destructive-400 bg-destructive-600"
|
||||
/>
|
||||
)}
|
||||
{notification.status !== 'archived' && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type="outline"
|
||||
icon={<IconArchive />}
|
||||
className="px-1"
|
||||
onClick={() => onArchiveNotification(notification.id)}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-alternative py-1 px-2 leading-none shadow',
|
||||
'border border-background',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-foreground">Archive</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationRow
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export type NOTIFICATION_FILTER_TYPE = 'all' | 'unread' | 'warning' | 'critical'
|
||||
|
||||
export const NOTIFICATION_FILTERS: {
|
||||
id: NOTIFICATION_FILTER_TYPE
|
||||
label: string
|
||||
}[] = [
|
||||
{ id: 'all', label: 'View all notifications' },
|
||||
{ id: 'unread', label: 'View unread notifications' },
|
||||
{ id: 'warning', label: 'View warning notifications' },
|
||||
{ id: 'critical', label: 'View critical notifications' },
|
||||
]
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import clsx from 'clsx'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconInbox,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
Tabs,
|
||||
} from 'ui'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import InfiniteList from 'components/ui/InfiniteList'
|
||||
import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useNotificationsV2Query } from 'data/notifications/notifications-v2-query'
|
||||
import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation'
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { useProjectsQuery } from 'data/projects/projects-query'
|
||||
import NotificationRow from './NotificationRow'
|
||||
import { NOTIFICATION_FILTERS, NOTIFICATION_FILTER_TYPE } from './NotificationsPopover.constants'
|
||||
import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query'
|
||||
|
||||
const NotificationsPopverV2 = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [openFilters, setOpenFilters] = useState(false)
|
||||
const [selectedFilter, setSelectedFilter] = useState<NOTIFICATION_FILTER_TYPE>('all')
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox')
|
||||
|
||||
// Storing in ref as no re-rendering required
|
||||
const markedRead = useRef<string[]>([])
|
||||
|
||||
// [Joshen] Just FYI this variable row heights logic should ideally live in InfiniteList
|
||||
// but I ran into some infinite loops issues when I was trying to implement it there
|
||||
// so opting to simplify and implement it here for now
|
||||
const rowHeights = useRef<{ [key: number]: number }>({})
|
||||
|
||||
const { data: projects } = useProjectsQuery()
|
||||
const { data: organizations } = useOrganizationsQuery()
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useNotificationsV2Query({
|
||||
status: activeTab === 'archived' ? 'archived' : selectedFilter === 'unread' ? 'new' : undefined,
|
||||
priority:
|
||||
selectedFilter === 'critical'
|
||||
? 'Critical'
|
||||
: selectedFilter === 'warning'
|
||||
? 'Warning'
|
||||
: undefined,
|
||||
})
|
||||
const { data: summary } = useNotificationsSummaryQuery()
|
||||
const { mutate: updateNotifications } = useNotificationsV2UpdateMutation()
|
||||
|
||||
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 onSelectFilter = (value: 'all' | 'unread' | 'warning' | 'critical') => {
|
||||
setSelectedFilter(value)
|
||||
setOpenFilters(false)
|
||||
}
|
||||
|
||||
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>
|
||||
<Button
|
||||
type={hasNewNotifications ? 'outline' : 'text'}
|
||||
className={clsx(
|
||||
'group',
|
||||
hasNewNotifications ? 'rounded-full px-1.5' : 'px-1',
|
||||
hasCritical
|
||||
? 'border-destructive-500 hover:border-destructive-600 hover:bg-destructive-300'
|
||||
: hasWarning
|
||||
? 'border-warning-500 hover:border-warning-600 hover:bg-warning-300'
|
||||
: ''
|
||||
)}
|
||||
icon={
|
||||
hasCritical ? (
|
||||
<IconAlertTriangle
|
||||
size={16}
|
||||
strokeWidth={3}
|
||||
className={clsx(
|
||||
'transition-all -mr-3 group-hover:-mr-1',
|
||||
'z-10 rounded p-0.5 text-destructive-400 bg-destructive-600'
|
||||
)}
|
||||
/>
|
||||
) : hasWarning ? (
|
||||
<IconAlertCircle
|
||||
size={16}
|
||||
strokeWidth={3}
|
||||
className={clsx(
|
||||
'transition-all -mr-3 group-hover:-mr-1',
|
||||
'z-10 rounded p-0.5 text-warning-400 bg-warning-600'
|
||||
)}
|
||||
/>
|
||||
) : hasNewNotifications ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-all -mr-3 group-hover:-mr-1',
|
||||
'z-10 h-4 flex items-center justify-center rounded-full bg-black dark:bg-white',
|
||||
(summary?.unread_count ?? 0) > 9 ? 'px-0.5 w-auto' : 'w-4'
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-background-alternative">{summary?.unread_count}</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
iconRight={
|
||||
<IconInbox
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
className="transition group-hover:text-foreground text-foreground-light"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 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
|
||||
size="medium"
|
||||
type="underlined"
|
||||
baseClassNames="!space-y-0"
|
||||
listClassNames="[&>button>span]:text-xs"
|
||||
activeId={activeTab}
|
||||
onChange={(tab: 'inbox' | 'archived') => {
|
||||
setActiveTab(tab)
|
||||
}}
|
||||
>
|
||||
<Tabs.Panel
|
||||
id="inbox"
|
||||
label="Inbox"
|
||||
iconRight={
|
||||
<div
|
||||
className={clsx([
|
||||
'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>
|
||||
}
|
||||
/>
|
||||
<Tabs.Panel id="archived" label="Archived" />
|
||||
</Tabs>
|
||||
<Popover_Shadcn_ modal={false} open={openFilters} onOpenChange={setOpenFilters}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button type="text" icon={<SlidersHorizontal size={14} />}>
|
||||
View {selectedFilter}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-52" side="bottom" align="end">
|
||||
<Command_Shadcn_>
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{NOTIFICATION_FILTERS.map((filter) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={filter.id}
|
||||
className="cursor-pointer flex items-center justify-between"
|
||||
onSelect={() => onSelectFilter(filter.id)}
|
||||
onClick={() => onSelectFilter(filter.id)}
|
||||
>
|
||||
<p>{filter.label}</p>
|
||||
{selectedFilter === filter.id && <IconCheck />}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_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]">
|
||||
{notifications.length > 0 &&
|
||||
!(activeTab === 'archived' && selectedFilter === 'unread') ? (
|
||||
<InfiniteList
|
||||
items={notifications}
|
||||
ItemComponent={NotificationRow}
|
||||
LoaderComponent={
|
||||
<div className="p-4">
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
}
|
||||
itemProps={{
|
||||
setRowHeight: (idx: number, height: number) => {
|
||||
if (rowHeights.current) {
|
||||
rowHeights.current = { ...rowHeights.current, [idx]: height }
|
||||
}
|
||||
},
|
||||
getProject: (ref: string) => projects?.find((project) => project.ref === ref),
|
||||
getOrganization: (id: number) => organizations?.find((org) => org.id === id),
|
||||
onArchiveNotification: (id: string) =>
|
||||
updateNotifications({ ids: [id], status: 'archived' }),
|
||||
queueMarkRead: (id: string) => {
|
||||
if (markedRead.current && !markedRead.current.includes(id)) {
|
||||
markedRead.current.push(id)
|
||||
}
|
||||
},
|
||||
}}
|
||||
getItemSize={(idx: number) => rowHeights?.current?.[idx] ?? 56}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoadingNextPage={isFetchingNextPage}
|
||||
onLoadNextPage={() => fetchNextPage()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-4 items-center flex-grow justify-center">
|
||||
<IconInbox size={32} className="text-foreground-light" />
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p className="text-foreground-light text-sm w-64 text-center">
|
||||
{activeTab === 'archived'
|
||||
? `No archived ${
|
||||
['warning', 'critical'].includes(selectedFilter)
|
||||
? `${selectedFilter} `
|
||||
: ''
|
||||
}notifications`
|
||||
: 'All caught up'}
|
||||
</p>
|
||||
<p className="text-foreground-lighter text-xs w-64 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>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationsPopverV2
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CSSProperties, memo } from 'react'
|
||||
import memoize from 'memoize-one'
|
||||
import { areEqual, VariableSizeList } from 'react-window'
|
||||
import { CSSProperties, ReactNode, memo, useRef } from 'react'
|
||||
import AutoSizer from 'react-virtualized-auto-sizer'
|
||||
import { VariableSizeList, areEqual } from 'react-window'
|
||||
import InfiniteLoader from 'react-window-infinite-loader'
|
||||
|
||||
import { propsAreEqual } from 'lib/helpers'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { propsAreEqual } from 'lib/helpers'
|
||||
|
||||
/**
|
||||
* Note that the loading more logic of this component works best with a cursor-based
|
||||
@@ -17,27 +17,12 @@ const createItemData = memoize((items, itemProps) => ({ items, ...itemProps }))
|
||||
|
||||
export interface ItemProps {
|
||||
data: any
|
||||
listRef: any
|
||||
index: number
|
||||
style: CSSProperties
|
||||
LoaderComponent?: ReactNode
|
||||
}
|
||||
|
||||
const Item = memo(({ data, index, style }: ItemProps) => {
|
||||
const { items, itemProps, ItemComponent } = data
|
||||
const item = index < items.length ? items[index] : undefined
|
||||
|
||||
return item ? (
|
||||
<div style={style}>
|
||||
<ItemComponent index={index} item={item} {...itemProps} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 my-1" style={style}>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)
|
||||
}, areEqual)
|
||||
|
||||
export interface InfiniteListProps<T> {
|
||||
items?: T[]
|
||||
itemProps?: any
|
||||
@@ -46,8 +31,29 @@ export interface InfiniteListProps<T> {
|
||||
getItemSize?: (index: number) => number
|
||||
onLoadNextPage?: () => void
|
||||
ItemComponent?: any
|
||||
LoaderComponent?: ReactNode
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const Item = memo(({ listRef, LoaderComponent, data, index, style }: ItemProps) => {
|
||||
const { items, itemProps, ItemComponent } = data
|
||||
const item = index < items.length ? items[index] : undefined
|
||||
|
||||
return item ? (
|
||||
<div style={style}>
|
||||
<ItemComponent index={index} item={item} listRef={listRef} {...itemProps} />
|
||||
</div>
|
||||
) : LoaderComponent !== undefined ? (
|
||||
<div style={style}>{LoaderComponent}</div>
|
||||
) : (
|
||||
<div className="space-y-1 my-1" style={style}>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader />
|
||||
</div>
|
||||
)
|
||||
}, areEqual)
|
||||
|
||||
function InfiniteList<T>({
|
||||
items = [],
|
||||
itemProps = {},
|
||||
@@ -56,7 +62,10 @@ function InfiniteList<T>({
|
||||
getItemSize = () => 40,
|
||||
onLoadNextPage = () => {},
|
||||
ItemComponent = () => null,
|
||||
LoaderComponent,
|
||||
}: InfiniteListProps<T>) {
|
||||
const listRef = useRef<any>()
|
||||
|
||||
// Only load 1 page of items at a time
|
||||
// Pass an empty callback to InfiniteLoader in case it asks to load more than once
|
||||
const loadMoreItems = isLoadingNextPage ? () => {} : onLoadNextPage
|
||||
@@ -81,7 +90,10 @@ function InfiniteList<T>({
|
||||
>
|
||||
{({ onItemsRendered, ref }) => (
|
||||
<VariableSizeList
|
||||
ref={ref}
|
||||
ref={(refy) => {
|
||||
ref(refy)
|
||||
listRef.current = refy
|
||||
}}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
itemCount={itemCount}
|
||||
@@ -89,7 +101,9 @@ function InfiniteList<T>({
|
||||
itemSize={getItemSize}
|
||||
onItemsRendered={onItemsRendered}
|
||||
>
|
||||
{Item}
|
||||
{(props) => (
|
||||
<Item listRef={listRef} LoaderComponent={LoaderComponent} {...props} />
|
||||
)}
|
||||
</VariableSizeList>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
|
||||
Vendored
+100
-99
@@ -21,6 +21,10 @@ export interface paths {
|
||||
/** Update notifications */
|
||||
patch: operations['NotificationsController_updateNotificationsV2']
|
||||
}
|
||||
'/platform/notifications/summary': {
|
||||
/** Get an aggregated data of interest across all notifications for the user */
|
||||
get: operations['NotificationsController_getNotificationsSummary']
|
||||
}
|
||||
'/platform/reset-password': {
|
||||
/** Reset password for email */
|
||||
post: operations['ResetPasswordController_resetPassword']
|
||||
@@ -242,7 +246,7 @@ export interface paths {
|
||||
}
|
||||
'/platform/organizations/{slug}/billing/subscription/preview': {
|
||||
/** Preview subscription changes */
|
||||
post: operations['SubscriptionController_previewSubscriptionChangeV2']
|
||||
post: operations['SubscriptionController_previewSubscriptionChange']
|
||||
}
|
||||
'/platform/organizations/{slug}/billing/subscription/schedule': {
|
||||
/** Deletes any upcoming subscription schedule */
|
||||
@@ -254,7 +258,7 @@ export interface paths {
|
||||
}
|
||||
'/platform/organizations/{slug}/billing/invoices/upcoming': {
|
||||
/** Gets the upcoming invoice */
|
||||
get: operations['OrgInvoicesController_getUpcomingInvoiceV2']
|
||||
get: operations['OrgInvoicesController_getUpcomingInvoice']
|
||||
}
|
||||
'/platform/pg-meta/{ref}/column-privileges': {
|
||||
/** Retrieve column privileges */
|
||||
@@ -463,10 +467,6 @@ export interface paths {
|
||||
/** Updates project's content */
|
||||
patch: operations['ContentController_updateContent']
|
||||
}
|
||||
'/platform/projects/{ref}/daily-stats': {
|
||||
/** Gets daily project stats */
|
||||
get: operations['DailyStatsController_getDailyStats']
|
||||
}
|
||||
'/platform/projects/{ref}/databases': {
|
||||
/** Gets non-removed databases of a specified project */
|
||||
get: operations['DatabasesController_getDatabases']
|
||||
@@ -495,12 +495,6 @@ export interface paths {
|
||||
/** Gets project's usage metrics */
|
||||
get: operations['InfraMonitoringController_getUsageMetrics']
|
||||
}
|
||||
'/platform/projects/{ref}/invoices': {
|
||||
/** Gets project's invoices */
|
||||
get: operations['InvoicesController_getInvoices']
|
||||
/** Gets project's invoice count */
|
||||
head: operations['InvoicesController_getInvoiceCount']
|
||||
}
|
||||
'/platform/projects/{ref}/pause': {
|
||||
/** Pauses the project */
|
||||
post: operations['PauseController_pauseProject']
|
||||
@@ -729,6 +723,10 @@ export interface paths {
|
||||
/** Gets the project with the given ID if provided, otherwise gets the list of projects */
|
||||
get: operations['VercelProjectsController_getVercelProjects']
|
||||
}
|
||||
'/platform/vercel/projects/{id}': {
|
||||
/** Gets the Vercel project with the given ID */
|
||||
get: operations['VercelProjectsController_getVercelProject']
|
||||
}
|
||||
'/platform/vercel/projects/envs': {
|
||||
/** Gets the environment variables for the given project ID on behalf of the given team ID */
|
||||
get: operations['VercelEnvironmentVariablesController_getEnvironmentVariables']
|
||||
@@ -932,6 +930,10 @@ export interface paths {
|
||||
/** Update notifications */
|
||||
patch: operations['NotificationsController_updateNotificationsV2']
|
||||
}
|
||||
'/v0/notifications/summary': {
|
||||
/** Get an aggregated data of interest across all notifications for the user */
|
||||
get: operations['NotificationsController_getNotificationsSummary']
|
||||
}
|
||||
'/v0/status': {
|
||||
/** Get infrastructure status */
|
||||
get: operations['StatusController_getStatus']
|
||||
@@ -1217,10 +1219,6 @@ export interface paths {
|
||||
/** Updates project's content */
|
||||
patch: operations['ContentController_updateContent']
|
||||
}
|
||||
'/v0/projects/{ref}/daily-stats': {
|
||||
/** Gets daily project stats */
|
||||
get: operations['DailyStatsController_getDailyStats']
|
||||
}
|
||||
'/v0/projects/{ref}/databases': {
|
||||
/** Gets non-removed databases of a specified project */
|
||||
get: operations['DatabasesController_getDatabases']
|
||||
@@ -1249,12 +1247,6 @@ export interface paths {
|
||||
/** Gets project's usage metrics */
|
||||
get: operations['InfraMonitoringController_getUsageMetrics']
|
||||
}
|
||||
'/v0/projects/{ref}/invoices': {
|
||||
/** Gets project's invoices */
|
||||
get: operations['InvoicesController_getInvoices']
|
||||
/** Gets project's invoice count */
|
||||
head: operations['InvoicesController_getInvoiceCount']
|
||||
}
|
||||
'/v0/projects/{ref}/pause': {
|
||||
/** Pauses the project */
|
||||
post: operations['PauseController_pauseProject']
|
||||
@@ -1430,6 +1422,7 @@ export interface paths {
|
||||
post: operations['ProjectsController_createProject']
|
||||
}
|
||||
'/v1/projects/{ref}/api-keys': {
|
||||
/** Get project api keys */
|
||||
get: operations['ApiKeysController_getProjectApiKeys']
|
||||
}
|
||||
'/v1/projects/{ref}/branches': {
|
||||
@@ -1734,6 +1727,18 @@ export interface components {
|
||||
UpdateNotificationsBodyV1: {
|
||||
ids: string[]
|
||||
}
|
||||
NotificationAction: {
|
||||
label: string
|
||||
url?: string
|
||||
action_type?: string
|
||||
}
|
||||
NotificationData: {
|
||||
org_slug?: string
|
||||
project_ref?: string
|
||||
title: string
|
||||
message?: string
|
||||
actions?: components['schemas']['NotificationAction'][]
|
||||
}
|
||||
NotificationResponseV2: {
|
||||
/** @enum {string} */
|
||||
type:
|
||||
@@ -1746,12 +1751,17 @@ export interface components {
|
||||
status: 'new' | 'seen' | 'archived'
|
||||
/** @enum {string} */
|
||||
priority: 'Critical' | 'Warning' | 'Info'
|
||||
data: components['schemas']['NotificationData']
|
||||
id: string
|
||||
inserted_at: string
|
||||
name: string
|
||||
data: Record<string, never>
|
||||
meta: Record<string, never>
|
||||
}
|
||||
NotificationsSummary: {
|
||||
unread_count: number
|
||||
has_warning: boolean
|
||||
has_critical: boolean
|
||||
}
|
||||
UpdateNotificationBodyV2: {
|
||||
id: string
|
||||
/** @enum {string} */
|
||||
@@ -3466,6 +3476,7 @@ export interface components {
|
||||
| 'us-east-1'
|
||||
| 'us-west-1'
|
||||
| 'us-west-2'
|
||||
| 'ap-east-1'
|
||||
| 'ap-southeast-1'
|
||||
| 'ap-northeast-1'
|
||||
| 'ap-northeast-2'
|
||||
@@ -3827,6 +3838,7 @@ export interface components {
|
||||
effective_cache_size?: string
|
||||
maintenance_work_mem?: string
|
||||
max_connections?: number
|
||||
max_locks_per_transaction?: number
|
||||
max_parallel_maintenance_workers?: number
|
||||
max_parallel_workers?: number
|
||||
max_parallel_workers_per_gather?: number
|
||||
@@ -3841,6 +3853,7 @@ export interface components {
|
||||
effective_cache_size?: string
|
||||
maintenance_work_mem?: string
|
||||
max_connections?: number
|
||||
max_locks_per_transaction?: number
|
||||
max_parallel_maintenance_workers?: number
|
||||
max_parallel_workers?: number
|
||||
max_parallel_workers_per_gather?: number
|
||||
@@ -3879,7 +3892,7 @@ export interface components {
|
||||
available_addons: components['schemas']['AvailableAddonResponse'][]
|
||||
}
|
||||
UpdateAddonBody: {
|
||||
addon_variant: string
|
||||
addon_variant: Record<string, never>
|
||||
/** @enum {string} */
|
||||
addon_type: 'custom_domain' | 'compute_instance' | 'pitr'
|
||||
}
|
||||
@@ -4251,7 +4264,6 @@ export interface components {
|
||||
}
|
||||
SyncVercelEnvError: {
|
||||
message: string
|
||||
details: string
|
||||
}
|
||||
CreateVercelConnectionResponse: {
|
||||
id: string
|
||||
@@ -4372,7 +4384,7 @@ export interface components {
|
||||
expiry_time: string
|
||||
}
|
||||
UpdateAddonAdminBody: {
|
||||
addon_variant: string
|
||||
addon_variant: Record<string, never>
|
||||
/** @enum {string} */
|
||||
addon_type: 'custom_domain' | 'compute_instance' | 'pitr'
|
||||
price_id?: string
|
||||
@@ -4589,6 +4601,7 @@ export interface components {
|
||||
| 'us-east-1'
|
||||
| 'us-west-1'
|
||||
| 'us-west-2'
|
||||
| 'ap-east-1'
|
||||
| 'ap-southeast-1'
|
||||
| 'ap-northeast-1'
|
||||
| 'ap-northeast-2'
|
||||
@@ -4631,6 +4644,7 @@ export interface components {
|
||||
default_pool_size?: number
|
||||
ignore_startup_parameters?: string
|
||||
max_client_conn?: number
|
||||
connection_string?: string
|
||||
}
|
||||
AuthConfigResponse: {
|
||||
smtp_admin_email?: string
|
||||
@@ -4974,7 +4988,8 @@ export interface operations {
|
||||
NotificationsController_getNotificationsV2: {
|
||||
parameters: {
|
||||
query: {
|
||||
archived?: boolean
|
||||
status: 'new' | 'seen' | 'archived'
|
||||
priority: 'Critical' | 'Warning' | 'Info'
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
@@ -5029,6 +5044,16 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Get an aggregated data of interest across all notifications for the user */
|
||||
NotificationsController_getNotificationsSummary: {
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['NotificationsSummary']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Reset password for email */
|
||||
ResetPasswordController_resetPassword: {
|
||||
requestBody: {
|
||||
@@ -6419,7 +6444,7 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
/** Preview subscription changes */
|
||||
SubscriptionController_previewSubscriptionChangeV2: {
|
||||
SubscriptionController_previewSubscriptionChange: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Organization slug */
|
||||
@@ -6489,7 +6514,7 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
/** Gets the upcoming invoice */
|
||||
OrgInvoicesController_getUpcomingInvoiceV2: {
|
||||
OrgInvoicesController_getUpcomingInvoice: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Organization slug */
|
||||
@@ -8436,32 +8461,6 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets daily project stats */
|
||||
DailyStatsController_getDailyStats: {
|
||||
parameters: {
|
||||
query: {
|
||||
attribute: string
|
||||
interval: string
|
||||
endDate: string
|
||||
startDate: string
|
||||
}
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': Record<string, never>
|
||||
}
|
||||
}
|
||||
/** @description Failed to get daily project stats */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets non-removed databases of a specified project */
|
||||
DatabasesController_getDatabases: {
|
||||
parameters: {
|
||||
@@ -8632,48 +8631,6 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's invoices */
|
||||
InvoicesController_getInvoices: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: string
|
||||
offset?: string
|
||||
}
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': Record<string, never>[]
|
||||
}
|
||||
}
|
||||
/** @description Failed to get project's invoices */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's invoice count */
|
||||
InvoicesController_getInvoiceCount: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
/** @description Failed to get project's invoice count */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Pauses the project */
|
||||
PauseController_pauseProject: {
|
||||
parameters: {
|
||||
@@ -9371,7 +9328,6 @@ export interface operations {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
addon_variant: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
@@ -9814,6 +9770,28 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets invoices for the given customer */
|
||||
InvoicesController_getInvoices: {
|
||||
parameters: {
|
||||
query: {
|
||||
customer: string
|
||||
slug?: string
|
||||
limit: string
|
||||
offset: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Invoice'][]
|
||||
}
|
||||
}
|
||||
/** @description Failed to retrieve invoices */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets the total count of invoices for the given customer */
|
||||
InvoicesController_countInvoices: {
|
||||
parameters: {
|
||||
@@ -9990,6 +9968,29 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets the Vercel project with the given ID */
|
||||
VercelProjectsController_getVercelProject: {
|
||||
parameters: {
|
||||
header: {
|
||||
vercel_authorization: string
|
||||
}
|
||||
path: {
|
||||
id: string
|
||||
teamId: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': Record<string, never>
|
||||
}
|
||||
}
|
||||
/** @description Failed to get project */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets the environment variables for the given project ID on behalf of the given team ID */
|
||||
VercelEnvironmentVariablesController_getEnvironmentVariables: {
|
||||
parameters: {
|
||||
@@ -10870,7 +10871,6 @@ export interface operations {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
addon_variant: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
@@ -11122,6 +11122,7 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Get project api keys */
|
||||
ApiKeysController_getProjectApiKeys: {
|
||||
parameters: {
|
||||
path: {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function upsertContent(
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const { data, error } = await put('/platform/projects/{ref}/content', {
|
||||
// @ts-ignore [Joshen] potentially codegen is wrong
|
||||
// @ts-ignore API codegen is wrong
|
||||
params: { path: { ref: projectRef } },
|
||||
body: {
|
||||
id: payload.id,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export const notificationKeys = {
|
||||
list: () => ['notifications'] as const,
|
||||
listV2: (options?: { status?: string; priority?: string; limit: number }) =>
|
||||
['notifications', options] as const,
|
||||
summary: () => ['notifications', 'summary'] as const,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query'
|
||||
import { get } from 'data/fetchers'
|
||||
|
||||
import { components } from 'data/api'
|
||||
import { ResponseError } from 'types'
|
||||
import { notificationKeys } from './keys'
|
||||
|
||||
const NOTIFICATIONS_PAGE_LIMIT = 10
|
||||
|
||||
export type NotificationVariables = {
|
||||
// archived: boolean
|
||||
page: number
|
||||
limit?: number
|
||||
status?: 'new' | 'seen' | 'archived'
|
||||
priority?: 'Critical' | 'Warning' | 'Info'
|
||||
}
|
||||
|
||||
export type Notification = components['schemas']['NotificationResponseV2']
|
||||
|
||||
/**
|
||||
* Notification Data - This is not typed from the API end as it's meant to be open-ended
|
||||
* @param title: Title of the notification
|
||||
* @param message: Rendered as Markdown to support inline links. Should be capped to n characters
|
||||
* @param project_id: (Optional) Only available if notification is specifically for a project (e.g exhaustion notification)
|
||||
* @param actions: (Optional) Any sort of actions that we want to provide users, could be external link, could be something that needs to be handled on FE
|
||||
*/
|
||||
export type NotificationData = {
|
||||
title: string
|
||||
message: string
|
||||
org_slug?: string
|
||||
project_ref?: string
|
||||
actions: { label: string; url?: string; action_type?: string }[]
|
||||
}
|
||||
|
||||
export async function getNotifications(options: NotificationVariables, signal?: AbortSignal) {
|
||||
const { page = 0, limit = NOTIFICATIONS_PAGE_LIMIT, status, priority } = options
|
||||
const { data, error } = await get('/platform/notifications', {
|
||||
params: {
|
||||
// @ts-ignore
|
||||
query: {
|
||||
offset: page * limit,
|
||||
limit,
|
||||
...(status !== undefined ? { status } : { status: ['new', 'seen'] }),
|
||||
...(priority !== undefined ? { priority } : {}),
|
||||
},
|
||||
},
|
||||
headers: { Version: '2' },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export type NotificationsData = Awaited<ReturnType<typeof getNotifications>>
|
||||
export type NotificationsError = ResponseError
|
||||
|
||||
export const useNotificationsV2Query = <TData = NotificationsData>(
|
||||
{ status, priority, limit = NOTIFICATIONS_PAGE_LIMIT }: Omit<NotificationVariables, 'page'>,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseInfiniteQueryOptions<NotificationsData, NotificationsError, TData> = {}
|
||||
) => {
|
||||
return useInfiniteQuery<NotificationsData, NotificationsError, TData>(
|
||||
notificationKeys.listV2({ status, priority, limit }),
|
||||
({ signal, pageParam }) =>
|
||||
getNotifications({ status, priority, limit, page: pageParam }, signal),
|
||||
{
|
||||
enabled: enabled,
|
||||
getNextPageParam(lastPage, pages) {
|
||||
const page = pages.length
|
||||
if (lastPage.length < limit) return undefined
|
||||
return page
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Notification } from '@supabase/shared-types/out/notifications'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { get } from 'data/fetchers'
|
||||
import { ResponseError } from 'types'
|
||||
import { notificationKeys } from './keys'
|
||||
|
||||
export type NotificationsResponse = Notification[]
|
||||
|
||||
export async function getNotificationsSummary(signal?: AbortSignal) {
|
||||
const { data, error } = await get('/platform/notifications/summary', {
|
||||
signal,
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export type NotificationsData = Awaited<ReturnType<typeof getNotificationsSummary>>
|
||||
export type NotificationsError = ResponseError
|
||||
|
||||
export const useNotificationsSummaryQuery = <TData = NotificationsData>(
|
||||
options: UseQueryOptions<NotificationsData, NotificationsError, TData> = {}
|
||||
) =>
|
||||
useQuery<NotificationsData, NotificationsError, TData>(
|
||||
notificationKeys.summary(),
|
||||
({ signal }) => getNotificationsSummary(signal),
|
||||
options
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
import { patch } from 'data/fetchers'
|
||||
import { ResponseError } from 'types'
|
||||
|
||||
export type NotificationsUpdateVariables = {
|
||||
ids: string[]
|
||||
status: 'new' | 'seen' | 'archived'
|
||||
}
|
||||
|
||||
export async function updateNotifications({ ids, status }: NotificationsUpdateVariables) {
|
||||
const { data, error } = await patch('/platform/notifications', {
|
||||
body: ids.map((id) => {
|
||||
return { id, status }
|
||||
}),
|
||||
headers: { Version: '2' },
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
type NotificationsUpdateData = Awaited<ReturnType<typeof updateNotifications>>
|
||||
|
||||
export const useNotificationsV2UpdateMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<NotificationsUpdateData, ResponseError, NotificationsUpdateVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation<NotificationsUpdateData, ResponseError, NotificationsUpdateVariables>(
|
||||
(vars) => updateNotifications(vars),
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
await queryClient.invalidateQueries(['notifications'])
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to update notifications: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@
|
||||
"react-grid-layout": "^1.4.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-inlinesvg": "^4.0.4",
|
||||
"react-intersection-observer": "^9.5.3",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-simple-maps": "4.0.0-beta.6",
|
||||
"react-split": "^2.0.13",
|
||||
|
||||
Generated
+3
-1
@@ -788,6 +788,7 @@
|
||||
"react-grid-layout": "^1.4.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-inlinesvg": "^4.0.4",
|
||||
"react-intersection-observer": "^9.5.3",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-simple-maps": "4.0.0-beta.6",
|
||||
"react-split": "^2.0.13",
|
||||
@@ -30255,7 +30256,8 @@
|
||||
},
|
||||
"node_modules/react-intersection-observer": {
|
||||
"version": "9.5.3",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.5.3.tgz",
|
||||
"integrity": "sha512-NJzagSdUPS5rPhaLsHXYeJbsvdpbJwL6yCHtMk91hc0ufQ2BnXis+0QQ9NBh6n9n+Q3OyjR6OQLShYbaNBkThQ==",
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface TabsProps {
|
||||
addOnBefore?: React.ReactNode
|
||||
addOnAfter?: React.ReactNode
|
||||
listClassNames?: string
|
||||
baseClassNames?: string
|
||||
}
|
||||
|
||||
interface TabsSubComponents {
|
||||
@@ -40,6 +41,7 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
addOnBefore,
|
||||
addOnAfter,
|
||||
listClassNames,
|
||||
baseClassNames,
|
||||
children: _children,
|
||||
}) => {
|
||||
// toArray is used here to filter out invalid children
|
||||
@@ -95,12 +97,11 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
if (listClassNames) listClasses.push(listClassNames)
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Root value={active} className={__styles.base}>
|
||||
<TabsPrimitive.Root value={active} className={[__styles.base, baseClassNames].join(' ')}>
|
||||
<TabsPrimitive.List className={listClasses.join(' ')}>
|
||||
{addOnBefore}
|
||||
{children.map((tab) => {
|
||||
const isActive = active === tab.props.id
|
||||
|
||||
const triggerClasses = [__styles[type].base, __styles.size[size]]
|
||||
if (isActive) {
|
||||
triggerClasses.push(__styles[type].active)
|
||||
@@ -126,6 +127,7 @@ const Tabs: React.FC<PropsWithChildren<TabsProps>> & TabsSubComponents = ({
|
||||
>
|
||||
{tab.props.icon}
|
||||
<span>{tab.props.label}</span>
|
||||
{tab.props.iconRight}
|
||||
</TabsPrimitive.Trigger>
|
||||
)
|
||||
})}
|
||||
@@ -145,6 +147,7 @@ interface PanelProps {
|
||||
id: string
|
||||
label?: string
|
||||
icon?: React.ReactNode
|
||||
iconRight?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user