From 9d9cb374e65b7e505cd0acb9abd55240ea265fbb Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 12 Jan 2024 17:02:26 +0800 Subject: [PATCH] Chore/update notification v2 filter popover (#20375) * Update notification v2 popover * Update notifications empty state language based on filters * Fix typo * Update apps/studio/state/notifications.ts Co-authored-by: Alaister Young --------- Co-authored-by: Alaister Young --- .../NotificationsFilter.tsx | 104 ++++++++++++++- .../NotificationsPopover.constants.tsx | 12 -- .../NotificationsPopover.tsx | 119 +++++++----------- apps/studio/data/api.d.ts | 85 ++++++++++++- apps/studio/data/notifications/keys.ts | 2 +- .../notifications/notifications-v2-query.ts | 19 +-- apps/studio/state/notifications.ts | 69 ++++++++++ 7 files changed, 309 insertions(+), 101 deletions(-) create mode 100644 apps/studio/state/notifications.ts diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx index 02e74115e51..8c87b01c3f3 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx @@ -1,16 +1,110 @@ import { Settings2Icon } from 'lucide-react' import { useState } from 'react' -import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui' +import { + Button, + Checkbox_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + ScrollArea, + Separator, +} from 'ui' +import { CriticalIcon, WarningIcon } from './NotificationsPopover.constants' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useNotificationsStateSnapshot } from 'state/notifications' -export const NotificationsFilter = () => { +export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archived' }) => { const [open, setOpen] = useState(false) + const snap = useNotificationsStateSnapshot() + + const { data: organizations } = useOrganizationsQuery() + const { data: projects } = useProjectsQuery() + return ( - - - Hello + +
+

Filter notifications

+
+ + + +
+

Status

+
+ snap.setFilters('unread', 'status')} + /> +

Unread

+
+
+ +
+

Priority

+
+ snap.setFilters('Warning', 'priority')} + /> + +

Warning

+
+
+ snap.setFilters('Critical', 'priority')} + /> + +

Critical

+
+
+ +
+

Organizations

+ {(organizations ?? []).map((org) => ( +
+ snap.setFilters(org.slug, 'organizations')} + /> +

{org.name}

+
+ ))} +
+ +
+

Projects

+ {(projects ?? []).map((project) => ( +
+ snap.setFilters(project.ref, 'projects')} + /> +

{project.name}

+
+ ))} +
+
+ + +
+ +
) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.tsx index 69087a19cfb..56d82de565b 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.constants.tsx @@ -1,15 +1,3 @@ -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' }, -] - export const CriticalIcon = ({ className }: { className?: string }) => { return ( { +const NotificationsPopoverV2 = () => { const [open, setOpen] = useState(false) - const [openFilters, setOpenFilters] = useState(false) - const [selectedFilter, setSelectedFilter] = useState('all') const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox') + const snap = useNotificationsStateSnapshot() + // Storing in ref as no re-rendering required const markedRead = useRef([]) @@ -59,15 +48,19 @@ const NotificationsPopverV2 = () => { isFetchingNextPage, fetchNextPage, } = useNotificationsV2Query({ - status: activeTab === 'archived' ? 'archived' : selectedFilter === 'unread' ? 'new' : undefined, - priority: - selectedFilter === 'critical' - ? 'Critical' - : selectedFilter === 'warning' - ? 'Warning' + status: + activeTab === 'archived' + ? 'archived' + : snap.filterStatuses.includes('unread') + ? 'new' : undefined, + filters: { + priority: snap.filterPriorities, + organizations: snap.filterOrganizations, + projects: snap.filterProjects, + }, }) - const { data: summary } = useNotificationsSummaryQuery() + const { data: summary, isSuccess: isSuccessSummary } = useNotificationsSummaryQuery() const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() const notifications = useMemo(() => data?.pages.flatMap((page) => page) ?? [], [data?.pages]) @@ -75,11 +68,6 @@ const NotificationsPopverV2 = () => { 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' }) @@ -145,50 +133,31 @@ const NotificationsPopverV2 = () => { activeId={activeTab} onChange={(tab: 'inbox' | 'archived') => { setActiveTab(tab) + if (tab === 'archived' && snap.filterStatuses.includes('unread')) { + snap.setFilters('unread', 'status') + } }} > 9 ? 'px-0.5 w-auto' : 'w-4', - ])} - > - {summary?.unread_count} - + isSuccessSummary ? ( +
9 ? 'px-0.5 w-auto' : 'w-4', + ])} + > + {summary?.unread_count} +
+ ) : null } /> - - - - - - - - - {NOTIFICATION_FILTERS.map((filter) => ( - onSelectFilter(filter.id)} - onClick={() => onSelectFilter(filter.id)} - > -

{filter.label}

- {selectedFilter === filter.id && } -
- ))} -
-
-
-
-
+ +
@@ -205,7 +174,7 @@ const NotificationsPopverV2 = () => { {isSuccess && (
{notifications.length > 0 && - !(activeTab === 'archived' && selectedFilter === 'unread') ? ( + !(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? ( {
-

+

{activeTab === 'archived' - ? `No archived ${ - ['warning', 'critical'].includes(selectedFilter) - ? `${selectedFilter} ` + ? `No archived notifications${ + snap.numFiltersApplied > 0 + ? ` based on the ${snap.numFiltersApplied} filter${ + snap.numFiltersApplied > 1 ? 's' : '' + } applied` : '' - }notifications` + }` + : snap.numFiltersApplied > 0 + ? `No notifications based on the ${snap.numFiltersApplied} filter${ + snap.numFiltersApplied > 1 ? 's' : '' + } applied` : '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'} @@ -264,4 +239,4 @@ const NotificationsPopverV2 = () => { ) } -export default NotificationsPopverV2 +export default NotificationsPopoverV2 diff --git a/apps/studio/data/api.d.ts b/apps/studio/data/api.d.ts index 85e1b46997a..e0e09be4a33 100644 --- a/apps/studio/data/api.d.ts +++ b/apps/studio/data/api.d.ts @@ -724,7 +724,7 @@ export interface paths { get: operations['VercelAccessTokenController_getAccessToken'] } '/platform/vercel/projects': { - /** Gets the project with the given ID if provided, otherwise gets the list of projects */ + /** Gets the list of Vercel projects */ get: operations['VercelProjectsController_getVercelProjects'] } '/platform/vercel/projects/{id}': { @@ -1610,6 +1610,10 @@ export interface paths { /** Enables Database Webhooks on the project */ post: operations['V1DatabaseWebhooksController_v1EnableDatabaseWebhooks'] } + '/v1/projects/{ref}/database/backups': { + /** Lists all backups */ + get: operations['V1BackupsController_getBackups'] + } '/v1/projects/{ref}/database/backups/restore-pitr': { /** Restores a PITR backup for a database */ post: operations['V1RestorePitrController_v1RestorePitr'] @@ -1650,6 +1654,10 @@ export interface paths { */ get: operations['FunctionSlugController_getFunctionBody'] } + '/v1/projects/{ref}/storage/buckets': { + /** Lists all buckets */ + get: operations['V1StorageBucketsController_getBuckets'] + } '/v1/organizations': { /** * List all organizations @@ -4789,6 +4797,22 @@ export interface components { created_at?: string updated_at?: string } + V1Backup: { + /** @enum {string} */ + status: 'COMPLETED' | 'FAILED' | 'PENDING' | 'REMOVED' | 'ARCHIVED' + is_physical_backup: boolean + inserted_at: string + } + V1BackupsResponse: { + region: string + walg_enabled: boolean + pitr_enabled: boolean + backups: components['schemas']['V1Backup'][] + physical_backup_data: { + earliest_physical_backup_date_unix?: number + latest_physical_backup_date_unix?: number + } + } V1RestorePitrBody: { recovery_time_target_unix: number } @@ -4806,6 +4830,14 @@ export interface components { entrypoint_path?: string import_map_path?: string } + V1StorageBucketResponse: { + id: string + name: string + owner: string + created_at: string + updated_at: string + public: boolean + } OrganizationResponseV1: { id: string name: string @@ -5040,6 +5072,8 @@ export interface operations { query: { status: 'new' | 'seen' | 'archived' priority: 'Critical' | 'Warning' | 'Info' + org_slug: string[] + project_ref: string[] offset: number limit: number } @@ -10055,7 +10089,7 @@ export interface operations { } } } - /** Gets the project with the given ID if provided, otherwise gets the list of projects */ + /** Gets the list of Vercel projects */ VercelProjectsController_getVercelProjects: { parameters: { query: { @@ -10069,10 +10103,10 @@ export interface operations { responses: { 200: { content: { - 'application/json': Record + 'application/json': Record[] } } - /** @description Failed to get project(s) */ + /** @description Failed to get projects */ 500: { content: never } @@ -12334,6 +12368,26 @@ export interface operations { } } } + /** Lists all backups */ + V1BackupsController_getBackups: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['V1BackupsResponse'] + } + } + /** @description Failed to get backups */ + 500: { + content: never + } + } + } /** Restores a PITR backup for a database */ V1RestorePitrController_v1RestorePitr: { parameters: { @@ -12475,6 +12529,29 @@ export interface operations { } } } + /** Lists all buckets */ + V1StorageBucketsController_getBuckets: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['V1StorageBucketResponse'][] + } + } + 403: { + content: never + } + /** @description Failed to get list of buckets */ + 500: { + content: never + } + } + } /** Create an organization */ OrganizationsController_createOrganization: { requestBody: { diff --git a/apps/studio/data/notifications/keys.ts b/apps/studio/data/notifications/keys.ts index 8aae9441f17..29945c796f3 100644 --- a/apps/studio/data/notifications/keys.ts +++ b/apps/studio/data/notifications/keys.ts @@ -1,6 +1,6 @@ export const notificationKeys = { list: () => ['notifications'] as const, - listV2: (options?: { status?: string; priority?: string; limit: number }) => + listV2: (options?: { status?: string; limit: number; filters: any }) => ['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 index d895bc5dfc6..9df9b8e5412 100644 --- a/apps/studio/data/notifications/notifications-v2-query.ts +++ b/apps/studio/data/notifications/notifications-v2-query.ts @@ -8,11 +8,14 @@ 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' + filters: { + priority: readonly string[] + organizations: readonly string[] + projects: readonly string[] + } } export type Notification = components['schemas']['NotificationResponseV2'] @@ -33,7 +36,7 @@ export type NotificationData = { } export async function getNotifications(options: NotificationVariables, signal?: AbortSignal) { - const { page = 0, limit = NOTIFICATIONS_PAGE_LIMIT, status, priority } = options + const { status, filters, page = 0, limit = NOTIFICATIONS_PAGE_LIMIT } = options const { data, error } = await get('/platform/notifications', { params: { // @ts-ignore @@ -41,7 +44,9 @@ export async function getNotifications(options: NotificationVariables, signal?: offset: page * limit, limit, ...(status !== undefined ? { status } : { status: ['new', 'seen'] }), - ...(priority !== undefined ? { priority } : {}), + ...(filters.priority.length > 0 ? { priority: filters.priority } : {}), + ...(filters.organizations.length > 0 ? { org_slug: filters.organizations } : {}), + ...(filters.projects.length > 0 ? { project_ref: filters.projects } : {}), }, }, headers: { Version: '2' }, @@ -57,16 +62,16 @@ export type NotificationsData = Awaited> export type NotificationsError = ResponseError export const useNotificationsV2Query = ( - { status, priority, limit = NOTIFICATIONS_PAGE_LIMIT }: Omit, + { status, filters, limit = NOTIFICATIONS_PAGE_LIMIT }: Omit, { enabled = true, ...options }: UseInfiniteQueryOptions = {} ) => { return useInfiniteQuery( - notificationKeys.listV2({ status, priority, limit }), + notificationKeys.listV2({ status, filters, limit }), ({ signal, pageParam }) => - getNotifications({ status, priority, limit, page: pageParam }, signal), + getNotifications({ status, filters, limit, page: pageParam }, signal), { enabled: enabled, getNextPageParam(lastPage, pages) { diff --git a/apps/studio/state/notifications.ts b/apps/studio/state/notifications.ts new file mode 100644 index 00000000000..4a1c0be56bb --- /dev/null +++ b/apps/studio/state/notifications.ts @@ -0,0 +1,69 @@ +import { proxy, snapshot, useSnapshot } from 'valtio' + +export const notificationsState = proxy({ + filterStatuses: [] as string[], + filterPriorities: [] as string[], + filterOrganizations: [] as string[], + filterProjects: [] as string[], + get numFiltersApplied() { + return [ + ...this.filterStatuses, + ...this.filterPriorities, + ...this.filterOrganizations, + ...this.filterProjects, + ].length + }, + setFilters: (value: string, type: 'status' | 'priority' | 'organizations' | 'projects') => { + switch (type) { + case 'status': + if (notificationsState.filterStatuses.includes(value)) { + notificationsState.filterStatuses = notificationsState.filterStatuses.filter( + (x) => x !== value + ) + } else { + notificationsState.filterStatuses = notificationsState.filterStatuses.concat([value]) + } + break + case 'priority': + if (notificationsState.filterPriorities.includes(value)) { + notificationsState.filterPriorities = notificationsState.filterPriorities.filter( + (x) => x !== value + ) + } else { + notificationsState.filterPriorities = notificationsState.filterPriorities.concat([value]) + } + break + case 'organizations': + if (notificationsState.filterOrganizations.includes(value)) { + notificationsState.filterOrganizations = notificationsState.filterOrganizations.filter( + (x) => x !== value + ) + } else { + notificationsState.filterOrganizations = notificationsState.filterOrganizations.concat([ + value, + ]) + } + break + case 'projects': + if (notificationsState.filterProjects.includes(value)) { + notificationsState.filterProjects = notificationsState.filterProjects.filter( + (x) => x !== value + ) + } else { + notificationsState.filterProjects = notificationsState.filterProjects.concat([value]) + } + break + } + }, + resetFilters: () => { + notificationsState.filterStatuses = [] + notificationsState.filterPriorities = [] + notificationsState.filterOrganizations = [] + notificationsState.filterProjects = [] + }, +}) + +export const getNotificationsStateSnapshot = () => snapshot(notificationsState) + +export const useNotificationsStateSnapshot = (options?: Parameters[1]) => + useSnapshot(notificationsState, options)