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:
Joshen Lim authored and GitHub committed 2024-01-08 12:52:48 +08:00
1 parent 661d8c0668
commit 34a2ccdae3
18 files changed
+838 -144

No files matched your search

@@ -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}
@@ -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>
)
@@ -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}
@@ -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
@@ -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' },
]
@@ -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
+36 -22
View File
@@ -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>
+100 -99
View File
@@ -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,
+3
View File
@@ -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,
}
)
}
+1
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+5 -2
View File
@@ -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
}