Chore/fix layout header alignment right buttons (#21225)

* Fix NotificationV2 popover trigger alignment

* Remove unnecessary alt prop on FeedbackDropdown

* Deprecate old NotificationsPopover folder
This commit is contained in:
Joshen Lim authored and GitHub committed 2024-02-13 23:46:24 +08:00
1 parent 700a7a106d
commit 71305867b2
9 files changed
+212 -964

No files matched your search

@@ -1,11 +1,11 @@
import { useParams } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useParams } from 'common'
import { useFlag, useSelectedOrganization, useSelectedProject } from 'hooks'
import FeedbackDropdown from '../ProjectLayout/LayoutHeader/FeedbackDropdown'
import HelpPopover from '../ProjectLayout/LayoutHeader/HelpPopover'
import NotificationsPopover from '../ProjectLayout/LayoutHeader/NotificationsPopover'
import NotificationsPopoverV2 from '../ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover'
import BranchDropdown from './BranchDropdown'
import EnableBranchingButton from './EnableBranchingButton/EnableBranchingButton'
import OrganizationDropdown from './OrganizationDropdown'
@@ -13,6 +13,9 @@ import ProjectDropdown from './ProjectDropdown'
import SettingsButton from './SettingsButton'
import UserSettingsDropdown from './UserSettingsDropdown'
// [Joshen] Just FYI this is only for Nav V2 which is still going through design iteration
// Component is not currently in use
const AppHeader = () => {
const router = useRouter()
const { ref } = useParams()
@@ -47,9 +50,9 @@ const AppHeader = () => {
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<FeedbackDropdown alt />
<NotificationsPopover alt />
<HelpPopover alt />
<FeedbackDropdown />
<NotificationsPopoverV2 />
<HelpPopover />
<SettingsButton />
</div>
<div className="flex items-center gap-3">
@@ -1,18 +1,8 @@
import { useState } from 'react'
import {
Button,
IconMessageCircle,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui'
import FeedbackWidget from './FeedbackWidget'
interface FeedbackDropdownProps {
alt?: boolean
}
const FeedbackDropdown = ({ alt = false }: FeedbackDropdownProps) => {
const FeedbackDropdown = () => {
const [isOpen, setIsOpen] = useState(false)
const [feedback, setFeedback] = useState('')
const [screenshot, setScreenshot] = useState<string>()
@@ -37,16 +27,7 @@ const FeedbackDropdown = ({ alt = false }: FeedbackDropdownProps) => {
}}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
asChild
onClick={onOpen}
type={alt ? 'outline' : 'default'}
icon={
alt ? null : (
<IconMessageCircle size={16} strokeWidth={1.5} className="text-foreground-lighter" />
)
}
>
<Button asChild onClick={onOpen} type="outline">
<span className="hidden md:flex">Feedback</span>
</Button>
</PopoverTrigger_Shadcn_>
@@ -17,11 +17,7 @@ import {
Popover_Shadcn_,
} from 'ui'
interface HelpPopoverProps {
alt?: boolean
}
const HelpPopover = ({ alt = false }: HelpPopoverProps) => {
const HelpPopover = () => {
const router = useRouter()
const projectRef = router.query.ref
const supportUrl = `/support/new${projectRef ? `?ref=${projectRef}` : ''}`
@@ -34,36 +30,28 @@ const HelpPopover = ({ alt = false }: HelpPopoverProps) => {
<div className="relative flex items-center">
<Button
id="help-popover-button"
type={alt ? 'text' : 'default'}
className={alt ? 'px-1' : ''}
type="text"
className="px-1"
icon={
<IconHelpCircle
size={alt ? 18 : 16}
strokeWidth={1.5}
className={alt ? 'text-foreground-light' : 'text-foreground-lighter'}
/>
<IconHelpCircle size={16} strokeWidth={1.5} className="text-foreground-light" />
}
>
{!alt && <span>Help</span>}
</Button>
/>
</div>
</Tooltip.Trigger>
</PopoverTrigger_Shadcn_>
{alt ? (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'space-y-2 border border-background',
].join(' ')}
>
<p className="text-xs text-foreground">Help</p>
</div>
</Tooltip.Content>
</Tooltip.Portal>
) : null}
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'space-y-2 border border-background',
].join(' ')}
>
<p className="text-xs text-foreground">Help</p>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<PopoverContent_Shadcn_ className="w-[400px] space-y-4 p-0 py-5" align="end" side="bottom">
<div className="mb-5 space-y-4 px-5">
@@ -15,7 +15,6 @@ import { IS_PLATFORM } from 'lib/constants'
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) => {
@@ -24,7 +23,6 @@ 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
@@ -117,19 +115,13 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
{/* Additional breadcrumbs are supplied */}
<BreadcrumbsView defaultValue={breadcrumbs} />
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
{customHeaderComponents && customHeaderComponents}
{IS_PLATFORM && <FeedbackDropdown alt={notificationsV2} />}
{IS_PLATFORM && !notificationsV2 && (
<>
<HelpPopover />
<NotificationsPopover />
</>
)}
{IS_PLATFORM && notificationsV2 && (
{IS_PLATFORM && (
<>
<FeedbackDropdown />
<NotificationsPopoverV2 />
<HelpPopover alt />
<HelpPopover />
</>
)}
</div>
@@ -1,83 +0,0 @@
import { Action, ActionReason, ActionType } from '@supabase/shared-types/out/notifications'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Fragment } from 'react'
import { Button, IconExternalLink } from 'ui'
import { Organization } from 'types'
interface NotificationActionsProps {
organization: Organization
changelogLink?: string
availableActions: Action[]
onSelectRestartProject: () => void
onSelectApplyMigration: () => void
onSelectRollbackMigration: () => void
}
const NotificationActions = ({
organization,
changelogLink,
availableActions,
onSelectRestartProject,
onSelectApplyMigration,
onSelectRollbackMigration,
}: NotificationActionsProps) => {
const router = useRouter()
const onSelectUpgradePlan = () => {
return router.push(`/org/${organization.slug}/billing?panel=subscriptionPlan`)
}
const renderActionButton = (action: Action) => {
switch (action.action_type) {
case ActionType.UpgradeProjectToPro:
return (
<Button type="default" onClick={onSelectUpgradePlan}>
Upgrade plan
</Button>
)
case ActionType.SchedulePostgresRestart:
return (
<Button type="default" onClick={onSelectRestartProject}>
Restart project
</Button>
)
case ActionType.MigratePostgresSchema:
if (action.reason === ActionReason.Rollback) {
return (
<Button type="default" onClick={onSelectRollbackMigration}>
Rollback
</Button>
)
} else {
return (
<Button type="default" onClick={onSelectApplyMigration}>
Apply now
</Button>
)
}
}
}
return (
<div className="flex items-center space-x-2">
{availableActions.map((action) => {
return (
<Fragment key={`${action.action_type}_${action.reason}`}>
{renderActionButton(action)}
</Fragment>
)
})}
{changelogLink && (
<Button asChild type="default" icon={<IconExternalLink size={12} strokeWidth={2} />}>
<Link href={changelogLink} target="_blank" rel="noreferrer">
More info
</Link>
</Button>
)}
</div>
)
}
export default NotificationActions
@@ -1,104 +0,0 @@
import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications'
import dayjs from 'dayjs'
import { Button, IconX } from 'ui'
import { useNotificationsDismissMutation } from 'data/notifications/notifications-dismiss-mutation'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { ProjectInfo, useProjectsQuery } from 'data/projects/projects-query'
import { useStore } from 'hooks'
import NotificationActions from './NotificationActions'
import { formatNotificationCTAText, formatNotificationText } from './NotificationRows.utils'
export interface NotificationRowProps {
notification: Notification
onSelectRestartProject: (project: ProjectInfo, notification: Notification) => void
onSelectApplyMigration: (project: ProjectInfo, notification: Notification) => void
onSelectRollbackMigration: (project: ProjectInfo, notification: Notification) => void
}
const NotificationRow = ({
notification,
onSelectRestartProject,
onSelectApplyMigration,
onSelectRollbackMigration,
}: NotificationRowProps) => {
const { ui } = useStore()
const { data: projects } = useProjectsQuery()
const { data: organizations } = useOrganizationsQuery()
const project = projects?.find((project) => project.id === notification.project_id)
const organization = organizations?.find((org) => org.id === project?.organization_id)
const insertedAt = dayjs(notification.inserted_at).format('DD MMM YYYY, HH:mma')
const changelogLink = (notification.data as any).changelog_link
const availableActions = notification.meta?.actions_available ?? []
const { mutate: dismissNotifications, isLoading: isDismissing } = useNotificationsDismissMutation(
{
onError: (error) => {
ui.setNotification({
error,
category: 'error',
message: `Failed to dismiss notification: ${error.message}`,
duration: 4000,
})
},
}
)
const dismissNotification = async (notificationId: string) => {
if (!notificationId) return
dismissNotifications({ ids: [notificationId] })
}
if (!project || !organization) return null
return (
<div className="flex py-2">
<div className="flex min-w-[50px] justify-center">
{notification.notification_status !== NotificationStatus.Seen && (
<div className="mt-1.5 h-2 w-2 rounded-full bg-green-900" />
)}
</div>
<div className="flex-grow mr-8 flex flex-col gap-4">
<div className="w-full flex justify-between">
<div className="w-9/10 space-y-2">
{formatNotificationText(project.name, notification)}
{formatNotificationCTAText(availableActions)}
<p className="text-foreground-light text-sm !mt-2">{insertedAt}</p>
</div>
<div className="w-1/10 flex justify-end">
<div>
<Button
className="!px-1 group"
type="text"
loading={isDismissing}
icon={
<IconX
size={14}
strokeWidth={2}
className="text-foreground-light group-hover:text-foreground transition"
/>
}
onClick={() => dismissNotification(notification.id)}
/>
</div>
</div>
</div>
{(availableActions.length > 0 || changelogLink !== undefined) && (
<div className="flex items-center">
<NotificationActions
organization={organization}
changelogLink={changelogLink}
availableActions={availableActions}
onSelectRestartProject={() => onSelectRestartProject(project, notification)}
onSelectApplyMigration={() => onSelectApplyMigration(project, notification)}
onSelectRollbackMigration={() => onSelectRollbackMigration(project, notification)}
/>
</div>
)}
</div>
</div>
)
}
export default NotificationRow
@@ -1,178 +0,0 @@
import {
Action,
ActionType,
ExtensionsUpgrade,
Notification,
NotificationName,
ServerUpgrade,
ServiceUpgrade,
ViolatedLimit,
} from '@supabase/shared-types/out/notifications'
import dayjs from 'dayjs'
import Link from 'next/link'
import { Button, IconArrowRight, IconExternalLink } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
export const formatNotificationText = (projectName: string, notification: Notification) => {
if (notification.data.name === NotificationName.ProjectExceedingTierLimit) {
const { violations } = notification.data
const violationsText = violations
.map((violation: ViolatedLimit) => violation.dimension as string)
.reduce((a: string, b: string) => `${a}, ${b}`)
return (
<p className="text-sm">
Your project "{projectName}" has exceeded its limits in the following areas:{' '}
{violationsText}.
</p>
)
} else if (notification.data.name === NotificationName.PostgresqlUpgradeAvailable) {
const { upgrade_type, additional } = notification.data
if (upgrade_type === 'postgresql-server') {
const { version_to } = additional as ServerUpgrade
return (
<p className="text-sm">
New version of Postgres ({version_to}) is now available for project "{projectName}".
</p>
)
} else if (upgrade_type === 'extensions') {
const { name, version_to } = additional as ExtensionsUpgrade
return (
<p className="text-sm">
New version of "{name}" ({version_to}) is now available for project "{projectName}".
</p>
)
} else if (upgrade_type === 'schema-migration') {
const { name, version_to } = additional as ExtensionsUpgrade
return (
<div className="text-sm space-y-1">
<p>A new schema migration is available for your project "{projectName}".</p>
<ol className="list-disc pl-6">
<li>
<div className="flex items-center space-x-1">
<p>{name}</p>
<IconArrowRight size={12} strokeWidth={2} />
<p>{version_to}</p>
</div>
</li>
</ol>
</div>
)
}
return ''
} else if (notification.data.name === NotificationName.PostgresqlUpgradeCompleted) {
const { upgrade_type, additional } = notification.data
if (upgrade_type === 'postgresql-server') {
const { version_to } = additional as ServerUpgrade
return (
<p className="text-sm">
Postgres ({version_to}) has been successfully updated to {version_to} for project "
{projectName}".
</p>
)
} else if (upgrade_type === 'extensions') {
const { name, version_to } = additional as ExtensionsUpgrade
return (
<p className="text-sm">
The extension "{name}" has been successfully updated to {version_to} for project "
{projectName}".
</p>
)
} else if (upgrade_type === 'schema-migration') {
const { version_to } = additional
return (
<p className="text-sm">
The schema migration "{version_to}" has been successfully applied for project "
{projectName}".
</p>
)
}
} else if (notification.data.name === NotificationName.ProjectUpdateCompleted) {
const { upgrades } = notification.data
return (
<div>
<p className="text-sm">
The following services have been successfully updated for project "{projectName}":
</p>
<ol className="list-disc pl-6">
{upgrades.map((upgrade: ServiceUpgrade) => (
<li key={upgrade.name}>
<div className="flex items-center space-x-1">
<p className="text-sm">{upgrade.name}</p>
<IconArrowRight size={12} strokeWidth={2} />
<p className="text-sm">{upgrade.version_to}</p>
{upgrade.changelog_link && (
<div className="!ml-4">
<Link href={upgrade.changelog_link} target="_blank" rel="noreferrer">
<IconExternalLink
className="cursor-pointer text-foreground-light hover:text-foreground transition"
size={12}
strokeWidth={2}
/>
</Link>
</div>
)}
</div>
</li>
))}
</ol>
</div>
)
} else if (notification.data.name === NotificationName.ProjectInformational) {
const buttons = notification.data.linked_buttons ?? []
return (
<>
<Markdown content={notification.data.message} className="text-foreground" />
{buttons.map((button, index) => {
return (
<a href={button.url} key={index} target="_blank">
<Button
asChild
type="default"
className="mr-2 mt-2 mb-2"
icon={<IconExternalLink size={12} strokeWidth={2} />}
>
<span>{button.text}</span>
</Button>
</a>
)
})}
</>
)
} else {
return (
<p className="text-sm">
Unknown notification type: {notification.notification_name} - Please reach out to support
for more information.
</p>
)
}
}
export const formatNotificationCTAText = (availableActions: Action[]) => {
const [action] = availableActions
if (!action) return <p className="text-sm"></p>
switch (action.action_type) {
case ActionType.SchedulePostgresRestart:
return <p className="text-sm">Restart your project to get the latest updates.</p>
case ActionType.UpgradeProjectToPro:
return <p className="text-sm">Upgrade your project to ensure continued availability.</p>
case ActionType.PgBouncerRestart:
return <p className="text-sm">Restart your connection pooler to get the latest updates.</p>
case ActionType.MigratePostgresSchema:
if (action.deadline) {
return (
<p className="text-sm space-x-1">
This patch will be automatically applied after{' '}
{dayjs(new Date(action.deadline)).format('DD MMM YYYY, HH:mma')}
</p>
)
}
default:
return ''
}
}
@@ -1,347 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import {
ActionType,
Notification,
NotificationStatus,
PostgresqlUpgradeData,
} from '@supabase/shared-types/out/notifications'
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useRouter } from 'next/router'
import { Fragment, useState } from 'react'
import { Button, IconArrowRight, IconBell, IconInbox, Popover } from 'ui'
import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog'
import { useNotificationsQuery } from 'data/notifications/notifications-query'
import { useNotificationsUpdateMutation } from 'data/notifications/notifications-update-mutation'
import { getProjectDetail } from 'data/projects/project-detail-query'
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
import { ProjectInfo, setProjectPostgrestStatus } from 'data/projects/projects-query'
import { useStore } from 'hooks'
import { delete_, post } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import NotificationRow from './NotificationRow'
interface NotificationsPopoverProps {
alt?: boolean
}
const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => {
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'),
})
const [projectToRestart, setProjectToRestart] = useState<ProjectInfo>()
const [projectToApplyMigration, setProjectToApplyMigration] = useState<ProjectInfo>()
const [projectToRollbackMigration, setProjectToRollbackMigration] = useState<ProjectInfo>()
const [targetNotification, setTargetNotification] = useState<Notification>()
const { mutate: restartProjectServices } = useProjectRestartServicesMutation({
onSuccess: (res, variables) => {
setProjectToRestart(undefined)
setTargetNotification(undefined)
setProjectPostgrestStatus(queryClient, variables.ref, 'OFFLINE')
ui.setNotification({ category: 'success', message: `Restarting services` })
router.push(`/project/${variables.ref}`)
},
})
const newNotifications =
notifications?.filter(
(notification) => notification.notification_status === NotificationStatus.New
) ?? []
const hasNewNotifications = newNotifications.length > 0
const onOpenChange = async (open: boolean) => {
// Mark notifications as seen
if (!open) {
const notificationIds =
notifications
?.filter((notification) => notification.notification_status === NotificationStatus.New)
.map((notification) => notification.id) ?? []
if (notificationIds.length > 0) updateNotifications({ ids: notificationIds })
}
}
const onConfirmProjectRestart = async () => {
if (!projectToRestart || !targetNotification) return
const { id } = targetNotification
const { ref, region } = projectToRestart
if (!ref || !region) return console.error('Ref and region required')
const serviceNamesByActionName: Record<string, string> = {
[ActionType.PgBouncerRestart]: 'pgbouncer',
[ActionType.SchedulePostgresRestart]: 'postgresql',
[ActionType.MigratePostgresSchema]: 'postgresql',
}
const services: string[] = targetNotification.meta.actions_available
.map((action) => action.action_type)
.filter((actionName) => Object.keys(serviceNamesByActionName).indexOf(actionName) !== -1)
.map((actionName) => serviceNamesByActionName[actionName])
restartProjectServices({ ref, region, services: services as any, source_notification_id: id })
}
const onConfirmProjectApplyMigration = async () => {
if (!projectToApplyMigration) return
const data = targetNotification?.data as PostgresqlUpgradeData
const { resource } = data.additional as any
if (!resource) return
// [Joshen] Leaving this as an exception for RQ due to dynamic URL
const res = await post(`${API_URL}/database/${projectToApplyMigration.ref}/${resource}`, {})
if (!res.error) {
const project = await getProjectDetail({ ref: projectToApplyMigration.ref })
if (project) meta.setProjectDetails(project)
ui.setNotification({
category: 'success',
message: `Successfully applied migration for project "${projectToApplyMigration.name}"`,
})
} else {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to apply migration: ${res.error.message}`,
})
}
setProjectToApplyMigration(undefined)
}
const onConfirmProjectRollbackMigration = async () => {
if (!projectToRollbackMigration) return
const data = targetNotification?.data as PostgresqlUpgradeData
const { resource } = data.additional as any
if (!resource) return
// [Joshen] Leaving this as an exception for RQ due to dynamic URL
const res = await delete_(
`${API_URL}/database/${projectToRollbackMigration.ref}/${resource}`,
{}
)
if (!res.error) {
const project = await getProjectDetail({ ref: projectToRollbackMigration.ref })
if (project) meta.setProjectDetails(project)
ui.setNotification({
category: 'success',
message: `Successfully rolled back migration for project "${projectToRollbackMigration.name}"`,
})
} else {
ui.setNotification({
error: res.error,
category: 'error',
message: `Failed to roll back migration: ${res.error.message}`,
})
}
setProjectToRollbackMigration(undefined)
}
if (!notifications || !Array.isArray(notifications)) return null
return (
<>
<Popover
size="content"
align="end"
side="bottom"
sideOffset={8}
onOpenChange={onOpenChange}
overlay={
<div className="w-[400px] lg:w-[700px]">
<div className="flex items-center justify-between border-b border-default bg-surface-200 px-4 py-2">
<p className="text-sm">Notifications</p>
{/* Area for improvement: Paginate notifications and show in a side panel */}
{/* <p className="text-foreground-light hover:text-foreground cursor-pointer text-sm transition">
See all{' '}
{notifications.length > MAX_NOTIFICATIONS_TO_SHOW && `(${notifications.length})`}
</p> */}
</div>
<div className="max-h-[380px] overflow-y-auto py-2">
{notifications.length === 0 ? (
<div className="py-2 px-4">
<p className="text-sm text-foreground-light">No notifications available</p>
</div>
) : (
<>
{notifications.map((notification, i: number) => (
<Fragment key={notification.id}>
<NotificationRow
notification={notification}
onSelectRestartProject={(project, notification) => {
setProjectToRestart(project)
setTargetNotification(notification)
}}
onSelectApplyMigration={(project, notification) => {
setProjectToApplyMigration(project)
setTargetNotification(notification)
}}
onSelectRollbackMigration={(project, notification) => {
setProjectToRollbackMigration(project)
setTargetNotification(notification)
}}
/>
{i !== notifications.length - 1 && <Popover.Separator />}
</Fragment>
))}
</>
)}
</div>
</div>
}
>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<div className="relative flex items-center">
{hasNewNotifications && (
<>
{alt ? null : (
<div className="absolute -top-1 -right-1 z-50 flex h-3 w-3 items-center justify-center">
<div className="h-full w-full animate-ping rounded-full bg-green-800 opacity-60"></div>
<div className="z-60 absolute top-0 right-0 h-full w-full rounded-full bg-green-900 opacity-80"></div>
</div>
)}
</>
)}
<Button
asChild
id="notification-button"
type={alt ? 'text' : 'default'}
className={alt ? 'px-1' : ''}
icon={
hasNewNotifications ? (
<div className="-mr-3.5 z-10 h-4 w-4 flex items-center justify-center rounded-full bg-foreground">
<p className="text-xs text-background-alternative">
{newNotifications.length}
</p>
</div>
) : alt ? (
<IconInbox size={18} strokeWidth={1.5} className="text-foreground-light" />
) : (
<IconBell size={16} strokeWidth={1.5} className="text-foreground" />
)
}
iconRight={
hasNewNotifications ? (
<>
{alt ? (
<IconInbox size={18} strokeWidth={1.5} className="text-foreground-light" />
) : (
<IconBell size={16} strokeWidth={1.5} className="text-foreground" />
)}
</>
) : null
}
>
<span></span>
</Button>
</div>
</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 flex items-center space-x-1',
].join(' ')}
>
<span className="text-xs text-foreground">Notifications</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Popover>
<ConfirmModal
danger
visible={projectToRestart !== undefined}
title={`Restart project "${projectToRestart?.name}"`}
description={`Are you sure you want to restart the project? There will be a few minutes of downtime.`}
buttonLabel="Restart"
buttonLoadingLabel="Restarting"
onSelectCancel={() => setProjectToRestart(undefined)}
onSelectConfirm={onConfirmProjectRestart}
/>
<ConfirmModal
size="large"
visible={projectToApplyMigration !== undefined}
title={`Apply schema migration for "${projectToApplyMigration?.name}"`}
// @ts-ignore
description={
<div className="text-foreground space-y-2">
<div className="space-y-1">
<p>The following schema migration will be applied to the project</p>
<ol className="list-disc pl-6">
<li>
<div className="flex items-center space-x-1">
<p>{(targetNotification?.data as any)?.additional?.name}</p>
<IconArrowRight size={12} strokeWidth={2} />
<p>{(targetNotification?.data as any)?.additional?.version_to}</p>
</div>
</li>
</ol>
</div>
<p>
This change can be rolled back anytime up till{' '}
{dayjs(
new Date(targetNotification?.meta.actions_available?.[0]?.deadline ?? 0)
).format('DD MMM YYYY, HH:mma ZZ')}
, after which the changes will be finalized and can no longer be undone.
</p>
</div>
}
buttonLabel="Confirm"
buttonLoadingLabel="Confirm"
onSelectCancel={() => setProjectToApplyMigration(undefined)}
onSelectConfirm={onConfirmProjectApplyMigration}
/>
<ConfirmModal
size="medium"
visible={projectToRollbackMigration !== undefined}
title={`Rollback schema migration for "${projectToRollbackMigration?.name}"`}
// @ts-ignore
description={
<div className="text-foreground space-y-2">
<div className="space-y-1">
<p>The following schema migration will be rolled back for the project</p>
<ol className="list-disc pl-6">
<li>
<div className="flex items-center space-x-1">
<p>{(targetNotification?.data as any)?.additional?.name}</p>
<IconArrowRight size={12} strokeWidth={2} />
<p>{(targetNotification?.data as any)?.additional?.version_to}</p>
</div>
</li>
</ol>
</div>
<p>
This migration however will still be applied and finalized after{' '}
{dayjs(
new Date(targetNotification?.meta.actions_available?.[0]?.deadline ?? 0)
).format('DD MMM YYYY, HH:mma ZZ')}
, after which the changes can no longer be undone.
</p>
</div>
}
buttonLabel="Confirm"
buttonLoadingLabel="Confirm"
onSelectCancel={() => setProjectToRollbackMigration(undefined)}
onSelectConfirm={onConfirmProjectRollbackMigration}
/>
</>
)
}
export default NotificationsPopover
@@ -65,7 +65,7 @@ const NotificationsPopoverV2 = () => {
projects: snap.filterProjects,
},
})
const { data: summary, isSuccess: isSuccessSummary } = useNotificationsSummaryQuery()
const { data: summary } = useNotificationsSummaryQuery()
const { mutate: updateNotifications } = useNotificationsV2UpdateMutation()
const { mutate: archiveAllNotifications, isLoading: isArchiving } =
useNotificationsArchiveAllMutation({
@@ -84,192 +84,188 @@ const NotificationsPopoverV2 = () => {
}
return (
<div>
{/* wrapped in a div to stop space-x class impacting popover content */}
<Popover_Shadcn_
modal={false}
open={open}
onOpenChange={(open) => {
setOpen(open)
if (!open) markNotificationsRead()
}}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={hasNewNotifications ? 'outline' : 'text'}
className={clsx(
'h-[26px]',
// !hasCritical || !hasWarning || !hasNewNotifications ? 'w-[26px]' : '',
'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 ? (
<CriticalIcon className="!w-3.5 !h-3.5 transition-all -mr-3.5 group-hover:-mr-1 z-10" />
) : hasWarning ? (
<WarningIcon className="!w-3.5 !h-3.5 transition-all -mr-3.5 group-hover:-mr-1 z-10" />
) : 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_Shadcn_
className="w-full"
onValueChange={(tab: string) => {
setActiveTab(tab as 'inbox' | 'archived')
if (tab === 'archived' && snap.filterStatuses.includes('unread')) {
snap.setFilters('unread', 'status')
}
}}
value={activeTab}
>
<div className="flex items-center">
<TabsList_Shadcn_ className="flex gap-5 grow border-none">
<TabsTrigger_Shadcn_
id="inbox"
value="inbox"
className="px-0 data-[state=active]:bg-transparent flex gap-2"
>
Inbox
<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>
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_
id="archived"
value="archived"
className="px-0 data-[state=active]:bg-transparent"
>
Archived
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<NotificationsFilter activeTab={activeTab} />
</div>
</Tabs_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] bg-background">
{notifications.length > 0 &&
!(activeTab === 'archived' && snap.filterStatuses.includes('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)!,
onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => {
updateNotifications({ ids: [id], status })
},
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 mx-auto text-center">
{activeTab === 'archived'
? `No archived notifications${
snap.numFiltersApplied > 0
? ` based on the ${snap.numFiltersApplied} filter${
snap.numFiltersApplied > 1 ? 's' : ''
} applied`
: ''
}`
: 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-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'}
</p>
</div>
</div>
<Popover_Shadcn_
modal={false}
open={open}
onOpenChange={(open) => {
setOpen(open)
if (!open) markNotificationsRead()
}}
>
<PopoverTrigger_Shadcn_ asChild>
<Button
type={hasNewNotifications ? 'outline' : 'text'}
className={clsx(
'h-[26px]',
// !hasCritical || !hasWarning || !hasNewNotifications ? 'w-[26px]' : '',
'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 ? (
<CriticalIcon className="!w-3.5 !h-3.5 transition-all -mr-3.5 group-hover:-mr-1 z-10" />
) : hasWarning ? (
<WarningIcon className="!w-3.5 !h-3.5 transition-all -mr-3.5 group-hover:-mr-1 z-10" />
) : 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'
)}
</div>
)}
</div>
{notifications.length > 0 && activeTab === 'inbox' && (
<div className="flex items-center justify-center p-1.5 border-t">
<Button
disabled={isArchiving}
loading={isArchiving}
type="text"
icon={<IconArchive />}
onClick={() => archiveAllNotifications()}
>
Archive all
</Button>
<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_Shadcn_
className="w-full"
onValueChange={(tab: string) => {
setActiveTab(tab as 'inbox' | 'archived')
if (tab === 'archived' && snap.filterStatuses.includes('unread')) {
snap.setFilters('unread', 'status')
}
}}
value={activeTab}
>
<div className="flex items-center">
<TabsList_Shadcn_ className="flex gap-5 grow border-none">
<TabsTrigger_Shadcn_
id="inbox"
value="inbox"
className="px-0 data-[state=active]:bg-transparent flex gap-2"
>
Inbox
<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>
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_
id="archived"
value="archived"
className="px-0 data-[state=active]:bg-transparent"
>
Archived
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<NotificationsFilter activeTab={activeTab} />
</div>
</Tabs_Shadcn_>
</div>
</div>
<div className="border-t">
{isLoading && (
<div className="p-4">
<GenericSkeletonLoader />
</div>
)}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</div>
{isError && (
<div className="p-4">
<AlertError subject="Failed to retrieve notifications" error={error} />
</div>
)}
{isSuccess && (
<div className="flex flex-1 h-[400px] bg-background">
{notifications.length > 0 &&
!(activeTab === 'archived' && snap.filterStatuses.includes('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)!,
onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => {
updateNotifications({ ids: [id], status })
},
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 mx-auto text-center">
{activeTab === 'archived'
? `No archived notifications${
snap.numFiltersApplied > 0
? ` based on the ${snap.numFiltersApplied} filter${
snap.numFiltersApplied > 1 ? 's' : ''
} applied`
: ''
}`
: 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-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'}
</p>
</div>
</div>
)}
</div>
)}
</div>
{notifications.length > 0 && activeTab === 'inbox' && (
<div className="flex items-center justify-center p-1.5 border-t">
<Button
disabled={isArchiving}
loading={isArchiving}
type="text"
icon={<IconArchive />}
onClick={() => archiveAllNotifications()}
>
Archive all
</Button>
</div>
)}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}