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 <alaister@users.noreply.github.com>

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
Joshen LimandAlaister Young authored and GitHub committed 2024-01-12 17:02:26 +08:00
1 parent 36476cee5d
commit 9d9cb374e6
7 files changed
+309 -101

No files matched your search

@@ -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 (
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="text" icon={<Settings2Icon size={14} />} className="px-1" />
<Button
type={snap.numFiltersApplied > 0 ? 'default' : 'text'}
icon={<Settings2Icon size={14} />}
className="px-1"
>
{snap.numFiltersApplied > 0 &&
`${snap.numFiltersApplied} filter${snap.numFiltersApplied > 1 ? 's' : ''} applied`}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-52" side="bottom" align="end">
Hello
<PopoverContent_Shadcn_ className="p-0 w-64" side="bottom" align="end">
<div className="p-2">
<p className="text-xs text-foreground-light">Filter notifications</p>
</div>
<Separator />
<ScrollArea className="h-[240px]">
<div className="p-2 flex flex-col gap-y-2">
<p className="text-xs">Status</p>
<div className="flex items-center gap-x-2">
<Checkbox_Shadcn_
disabled={activeTab === 'archived'}
checked={snap.filterStatuses.includes('unread')}
onCheckedChange={() => snap.setFilters('unread', 'status')}
/>
<p className={`text-xs ${activeTab === 'archived' ? 'opacity-50' : ''}`}>Unread</p>
</div>
</div>
<Separator />
<div className="p-2 flex flex-col gap-y-2">
<p className="text-xs">Priority</p>
<div className="flex items-center gap-x-2">
<Checkbox_Shadcn_
checked={snap.filterPriorities.includes('Warning')}
onCheckedChange={() => snap.setFilters('Warning', 'priority')}
/>
<WarningIcon className="w-2 h-2" />
<p className="text-xs">Warning</p>
</div>
<div className="flex items-center gap-x-2">
<Checkbox_Shadcn_
checked={snap.filterPriorities.includes('Critical')}
onCheckedChange={() => snap.setFilters('Critical', 'priority')}
/>
<CriticalIcon className="w-2 h-2" />
<p className="text-xs">Critical</p>
</div>
</div>
<Separator />
<div className="p-2 flex flex-col gap-y-2">
<p className="text-xs">Organizations</p>
{(organizations ?? []).map((org) => (
<div key={org.slug} className="flex items-center gap-x-2">
<Checkbox_Shadcn_
checked={snap.filterOrganizations.includes(org.slug)}
onCheckedChange={() => snap.setFilters(org.slug, 'organizations')}
/>
<p className="text-xs">{org.name}</p>
</div>
))}
</div>
<Separator />
<div className="p-2 flex flex-col gap-y-2">
<p className="text-xs">Projects</p>
{(projects ?? []).map((project) => (
<div key={project.ref} className="flex items-center gap-x-2">
<Checkbox_Shadcn_
checked={snap.filterProjects.includes(project.ref)}
onCheckedChange={() => snap.setFilters(project.ref, 'projects')}
/>
<p className="text-xs">{project.name}</p>
</div>
))}
</div>
</ScrollArea>
<Separator />
<div className="p-2 flex justify-end">
<Button type="default" onClick={() => snap.resetFilters()}>
Reset filters
</Button>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
@@ -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 (
<svg
@@ -1,15 +1,7 @@
import clsx from 'clsx'
import { Settings2Icon, 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_,
@@ -21,24 +13,21 @@ 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 { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query'
import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useNotificationsStateSnapshot } from 'state/notifications'
import NotificationRow from './NotificationRow'
import {
CriticalIcon,
NOTIFICATION_FILTERS,
NOTIFICATION_FILTER_TYPE,
WarningIcon,
} from './NotificationsPopover.constants'
import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query'
import { NotificationsFilter } from './NotificationsFilter'
import { CriticalIcon, WarningIcon } from './NotificationsPopover.constants'
const NotificationsPopverV2 = () => {
const NotificationsPopoverV2 = () => {
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')
const snap = useNotificationsStateSnapshot()
// Storing in ref as no re-rendering required
const markedRead = useRef<string[]>([])
@@ -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')
}
}}
>
<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>
isSuccessSummary ? (
<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>
) : null
}
/>
<Tabs.Panel id="archived" label="Archived" />
</Tabs>
<Popover_Shadcn_ modal={false} open={openFilters} onOpenChange={setOpenFilters}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="text" icon={<Settings2Icon 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_>
<NotificationsFilter activeTab={activeTab} />
</div>
</div>
<div className="border-t">
@@ -205,7 +174,7 @@ const NotificationsPopverV2 = () => {
{isSuccess && (
<div className="flex flex-1 h-[400px]">
{notifications.length > 0 &&
!(activeTab === 'archived' && selectedFilter === 'unread') ? (
!(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? (
<InfiniteList
items={notifications}
ItemComponent={NotificationRow}
@@ -239,16 +208,22 @@ const NotificationsPopverV2 = () => {
<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">
<p className="text-foreground-light text-sm mx-auto text-center">
{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'}
</p>
<p className="text-foreground-lighter text-xs w-64 text-center">
<p className="text-foreground-lighter text-xs w-60 mx-auto text-center">
{activeTab === 'archived'
? 'Notifications that you have previously archived will be shown here'
: 'You will be notified here for any notices on your organizations and projects'}
@@ -264,4 +239,4 @@ const NotificationsPopverV2 = () => {
)
}
export default NotificationsPopverV2
export default NotificationsPopoverV2
+81 -4
View File
@@ -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<string, never>
'application/json': Record<string, never>[]
}
}
/** @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: {
+1 -1
View File
@@ -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,
}
@@ -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<ReturnType<typeof getNotifications>>
export type NotificationsError = ResponseError
export const useNotificationsV2Query = <TData = NotificationsData>(
{ status, priority, limit = NOTIFICATIONS_PAGE_LIMIT }: Omit<NotificationVariables, 'page'>,
{ status, filters, limit = NOTIFICATIONS_PAGE_LIMIT }: Omit<NotificationVariables, 'page'>,
{
enabled = true,
...options
}: UseInfiniteQueryOptions<NotificationsData, NotificationsError, TData> = {}
) => {
return useInfiniteQuery<NotificationsData, NotificationsError, TData>(
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) {
+69
View File
@@ -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<typeof useSnapshot>[1]) =>
useSnapshot(notificationsState, options)