diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 6845cd7f0fc..5ae834caf3b 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -104,11 +104,13 @@ const InvoicesSettings = () => {

{x.number}

- +
- {[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && ( + {[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes( + x.status as any + ) && (
{customHeaderComponents && customHeaderComponents} - {IS_PLATFORM && } - {IS_PLATFORM && } - {IS_PLATFORM && } + {IS_PLATFORM && } + {IS_PLATFORM && !notificationsV2 && ( + <> + + + + )} + {IS_PLATFORM && notificationsV2 && ( + <> + + + + )}
) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx index 8bb638124e8..6ca59e7b333 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx @@ -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} /> + { onSelectCancel={() => setProjectToApplyMigration(undefined)} onSelectConfirm={onConfirmProjectApplyMigration} /> + 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(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 ( +
+
+ {(project !== undefined || organization !== undefined) && ( +
+ {organization !== undefined && ( + + {organization.name} + + )} + {organization !== undefined && project !== undefined && ( + + + + + + )} + {project !== undefined && ( + + {project.name} + + )} +
+ )} +
+

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

+
+ {data.message !== undefined && ( + + )} + {(data.actions ?? []).length > 0 && ( +
+ {data.actions.map((action, idx) => { + const key = `${notification.id}-action-${idx}` + if (action.url !== undefined) { + const url = action.url.includes('[ref]') + ? action.url.replace('[ref]', project?.ref ?? '_') + : action.url.includes('[slug]') + ? action.url.replace('[slug]', organization?.slug ?? '_') + : action.url + return ( + + ) + } else if (action.action_type !== undefined) { + return ( + + ) + } else { + return null + } + })} +
+ )} +
+
+ {priority === 'Warning' && ( + + )} + {priority === 'Critical' && ( + + )} + {notification.status !== 'archived' && ( + + +
+
+ ) +} + +export default NotificationRow diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.ts new file mode 100644 index 00000000000..9f81e2ed3b2 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.ts @@ -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' }, +] diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx new file mode 100644 index 00000000000..665f66fb91c --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -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('all') + const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox') + + // Storing in ref as no re-rendering required + const markedRead = useRef([]) + + // [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 ( + { + setOpen(open) + if (!open) markNotificationsRead() + }} + > + + + + + + + + {NOTIFICATION_FILTERS.map((filter) => ( + onSelectFilter(filter.id)} + onClick={() => onSelectFilter(filter.id)} + > +

{filter.label}

+ {selectedFilter === filter.id && } +
+ ))} +
+
+
+
+
+ + +
+ {isLoading && ( +
+ +
+ )} + {isError && ( +
+ +
+ )} + {isSuccess && ( +
+ {notifications.length > 0 && + !(activeTab === 'archived' && selectedFilter === 'unread') ? ( + + +
+ } + 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()} + /> + ) : ( +
+ +
+

+ {activeTab === 'archived' + ? `No archived ${ + ['warning', 'critical'].includes(selectedFilter) + ? `${selectedFilter} ` + : '' + }notifications` + : 'All caught up'} +

+

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

+
+
+ )} +
+ )} + + + + ) +} + +export default NotificationsPopverV2 diff --git a/apps/studio/components/ui/InfiniteList.tsx b/apps/studio/components/ui/InfiniteList.tsx index 78eac67aeaa..bf5670a05b2 100644 --- a/apps/studio/components/ui/InfiniteList.tsx +++ b/apps/studio/components/ui/InfiniteList.tsx @@ -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 ? ( -
- -
- ) : ( -
- - - -
- ) -}, areEqual) - export interface InfiniteListProps { items?: T[] itemProps?: any @@ -46,8 +31,29 @@ export interface InfiniteListProps { 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 ? ( +
+ +
+ ) : LoaderComponent !== undefined ? ( +
{LoaderComponent}
+ ) : ( +
+ + + +
+ ) +}, areEqual) + function InfiniteList({ items = [], itemProps = {}, @@ -56,7 +62,10 @@ function InfiniteList({ getItemSize = () => 40, onLoadNextPage = () => {}, ItemComponent = () => null, + LoaderComponent, }: InfiniteListProps) { + const listRef = useRef() + // 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({ > {({ onItemsRendered, ref }) => ( { + ref(refy) + listRef.current = refy + }} height={height ?? 0} width={width ?? 0} itemCount={itemCount} @@ -89,7 +101,9 @@ function InfiniteList({ itemSize={getItemSize} onItemsRendered={onItemsRendered} > - {Item} + {(props) => ( + + )} )} diff --git a/apps/studio/data/api.d.ts b/apps/studio/data/api.d.ts index 90a2c1827be..9b86b55740c 100644 --- a/apps/studio/data/api.d.ts +++ b/apps/studio/data/api.d.ts @@ -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 meta: Record } + 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 /** @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 /** @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 - } - } - /** @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[] - } - } - /** @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 + } + } + /** @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: { diff --git a/apps/studio/data/content/content-upsert-mutation.ts b/apps/studio/data/content/content-upsert-mutation.ts index 892ffea71bd..b98be8d6395 100644 --- a/apps/studio/data/content/content-upsert-mutation.ts +++ b/apps/studio/data/content/content-upsert-mutation.ts @@ -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, diff --git a/apps/studio/data/notifications/keys.ts b/apps/studio/data/notifications/keys.ts index 9142775070a..8aae9441f17 100644 --- a/apps/studio/data/notifications/keys.ts +++ b/apps/studio/data/notifications/keys.ts @@ -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, } diff --git a/apps/studio/data/notifications/notifications-v2-query.ts b/apps/studio/data/notifications/notifications-v2-query.ts new file mode 100644 index 00000000000..d895bc5dfc6 --- /dev/null +++ b/apps/studio/data/notifications/notifications-v2-query.ts @@ -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> +export type NotificationsError = ResponseError + +export const useNotificationsV2Query = ( + { status, priority, limit = NOTIFICATIONS_PAGE_LIMIT }: Omit, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions = {} +) => { + return useInfiniteQuery( + 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, + } + ) +} diff --git a/apps/studio/data/notifications/notifications-v2-summary-query.ts b/apps/studio/data/notifications/notifications-v2-summary-query.ts new file mode 100644 index 00000000000..8cf0b4ed091 --- /dev/null +++ b/apps/studio/data/notifications/notifications-v2-summary-query.ts @@ -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> +export type NotificationsError = ResponseError + +export const useNotificationsSummaryQuery = ( + options: UseQueryOptions = {} +) => + useQuery( + notificationKeys.summary(), + ({ signal }) => getNotificationsSummary(signal), + options + ) diff --git a/apps/studio/data/notifications/notifications-v2-update-mutation.ts b/apps/studio/data/notifications/notifications-v2-update-mutation.ts new file mode 100644 index 00000000000..f8269138cf8 --- /dev/null +++ b/apps/studio/data/notifications/notifications-v2-update-mutation.ts @@ -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> + +export const useNotificationsV2UpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (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, + } + ) +} diff --git a/apps/studio/package.json b/apps/studio/package.json index d4610a38b4f..397cf1be983 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/package-lock.json b/package-lock.json index da3acbbe0e7..3cf3ff01d89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index 60c5e054517..6c86c1640e5 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -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> & TabsSubComponents = ({ addOnBefore, addOnAfter, listClassNames, + baseClassNames, children: _children, }) => { // toArray is used here to filter out invalid children @@ -95,12 +97,11 @@ const Tabs: React.FC> & TabsSubComponents = ({ if (listClassNames) listClasses.push(listClassNames) return ( - + {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> & TabsSubComponents = ({ > {tab.props.icon} {tab.props.label} + {tab.props.iconRight} ) })} @@ -145,6 +147,7 @@ interface PanelProps { id: string label?: string icon?: React.ReactNode + iconRight?: React.ReactNode className?: string }