Files
Jordi EnricandClaude Sonnet 4.6 4c011cf9c0 feat(reports): add optimistic delete for custom reports (#46803)
## Problem

Deleting a custom report waited for the API round trip before updating
the UI. The confirmation modal showed a loading spinner, the report
stayed visible in the sidebar until the request resolved, and the
interaction felt sluggish.

## Fix

The delete now applies optimistically. On confirm, the report is removed
from the sidebar immediately and the user is navigated away. The actual
delete runs in the background. If it fails, the cached list is rolled
back to its previous state and an error toast is shown.

The optimistic behavior lives inside `useContentDeleteMutation` (via
`onMutate` snapshot + `onError` rollback), so any current or future
caller of that hook gets it for free, no per-call wiring required.

## How to test

- Open a project with at least one custom report
- Click the kebab menu on a report and choose Delete report, then
confirm
- Expected result: the report disappears from the sidebar instantly and
a success toast appears
- To test rollback: throttle/offline the network or force the delete
endpoint to fail, then delete again
- Expected result: the report reappears in the sidebar and an error
toast is shown

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Deletion flows now provide explicit loading, success and error
feedback; UI updates immediately on delete and will restore if the
action fails.

* **Removals**
* Removed the reports menu and individual report menu item UI components
(affects report-level rename/delete dropdowns and related menu
navigation).

* **Tests**
* Added tests covering content deletion behavior, multiple-deletion
cases, and data integrity after removals.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:57:19 +02:00

297 lines
11 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Menu } from 'ui'
import { InnerSideBarEmptyPanel } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { generateObservabilityMenuItems } from './ObservabilityMenu.utils'
import { ObservabilityMenuItem } from './ObservabilityMenuItem'
import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus'
import { CreateReportModal } from '@/components/interfaces/Reports/CreateReportModal'
import { UpdateCustomReportModal } from '@/components/interfaces/Reports/UpdateModal'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { ProductMenu } from '@/components/ui/ProductMenu'
import { ProductMenuShortcuts } from '@/components/ui/ProductMenu/ProductMenuShortcuts'
import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
import { Content, ContentBase, useContentQuery } from '@/data/content/content-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
import type { Dashboards } from '@/types'
const ObservabilityMenu = () => {
const router = useRouter()
const { profile } = useProfile()
const { ref, id } = useParams()
const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string
const showOverview = useFlag('observabilityOverview')
const { isSupamonitorEnabled } = useSupamonitorStatus()
const storageSupported = useIsFeatureEnabled('project_storage:all')
const { can: canCreateCustomReport } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'user_content',
{
resource: { type: 'report', owner_id: profile?.id },
subject: { id: profile?.id },
}
)
// Preserve date range query parameters when navigating
const preservedQueryParams = useMemo(() => {
const { its, ite, isHelper, helperText } = router.query
const params = new URLSearchParams()
if (its && typeof its === 'string') params.set('its', its)
if (ite && typeof ite === 'string') params.set('ite', ite)
if (isHelper && typeof isHelper === 'string') params.set('isHelper', isHelper)
if (helperText && typeof helperText === 'string') params.set('helperText', helperText)
const queryString = params.toString()
return queryString ? `?${queryString}` : ''
}, [router.query])
const { data: content, isPending: isLoading } = useContentQuery({
projectRef: ref,
type: 'report',
})
const { mutateAsync: deleteReport } = useContentDeleteMutation({
// Toasts are driven by toast.promise in onConfirmDeleteReport. This no-op keeps the hook
// from showing its own default error toast, while its optimistic rollback still runs.
onError: () => {},
})
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
const [showNewReportModal, setShowNewReportModal] = useQueryState(
'newReport',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const [selectedReportToDelete, setSelectedReportToDelete] = useState<Content>()
const [selectedReportToUpdate, setSelectedReportToUpdate] = useState<Content>()
const onConfirmDeleteReport = () => {
if (ref === undefined) return console.error('Project ref is required')
if (selectedReportToDelete?.id === undefined) return console.error('Report ID is required')
const reportId = selectedReportToDelete.id
const isViewingDeletedReport = id === reportId
setDeleteModalOpen(false)
const deletion = deleteReport({ projectRef: ref, ids: [reportId] })
toast.promise(deletion, {
loading: 'Deleting report...',
success: 'Report deleted',
error: (err) => `Failed to delete report: ${err?.message ?? 'Unknown error'}`,
})
// Only navigate away when the open report is the one deleted, and only after it
// succeeds so a failed delete (which rolls the cache back) doesn't strand the route.
deletion
.then(() => {
if (isViewingDeletedReport) router.push(`/project/${ref}/observability`)
})
.catch(() => {
// Error is already surfaced by toast.promise; keep the user on the current route.
})
}
function isReportContent(c: Content): c is ContentBase & {
type: 'report'
content: Dashboards.Content
} {
return c.type === 'report'
}
function getReportMenuItems() {
if (!content) return []
const reports = content?.content.filter(isReportContent)
const sortedReports = reports?.sort((a, b) => {
if (a.name < b.name) {
return -1
}
if (a.name > b.name) {
return 1
}
return 0
})
const reportMenuItems = sortedReports.map((r, idx) => ({
id: r.id,
name: r.name,
description: r.description || '',
key: r.id || idx + '-report',
url: `/project/${ref}/observability/${r.id}${preservedQueryParams}`,
hasDropdownActions: true,
report: r,
}))
return reportMenuItems
}
const reportMenuItems = getReportMenuItems()
const menuItems = generateObservabilityMenuItems({
ref,
preservedQueryParams,
showOverview,
isSupamonitorEnabled,
storageSupported,
isPlatform: IS_PLATFORM,
})
useShortcut(
SHORTCUT_IDS.OBSERVABILITY_NEW_REPORT,
() => {
setShowNewReportModal(true)
},
{ enabled: IS_PLATFORM && canCreateCustomReport }
)
return (
<div>
<ProductMenuShortcuts menu={menuItems} />
{isLoading ? (
<div className="px-5 my-4 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : (
<div className="flex flex-col gap-y-6">
<ProductMenu
page={pageKey}
menu={menuItems.map((item) => ({
...item,
items: item.items.map((subItem) => ({ ...subItem, items: [] })),
}))}
/>
{IS_PLATFORM && (
<>
<div className="h-px w-full bg-border-overlay" />
<div className="mx-2">
<Menu type="pills">
<Menu.Group
title={
<span className="flex w-full items-center justify-between relative h-6">
<span className="uppercase font-mono">Custom Reports</span>
{reportMenuItems.length > 0 && (
<ButtonTooltip
type="default"
size="tiny"
icon={<Plus />}
disabled={!canCreateCustomReport}
className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1"
onClick={() => {
setShowNewReportModal(true)
}}
tooltip={{
content: {
side: 'bottom',
text: !canCreateCustomReport
? 'You need additional permissions to create custom reports'
: undefined,
},
}}
/>
)}
</span>
}
/>
{reportMenuItems.length > 0 &&
reportMenuItems.map((item) => (
<ObservabilityMenuItem
key={item.id}
item={item}
pageKey={pageKey}
onSelectEdit={() => {
setSelectedReportToUpdate(item.report)
}}
onSelectDelete={() => {
setSelectedReportToDelete(item.report)
setDeleteModalOpen(true)
}}
/>
))}
</Menu>
{reportMenuItems.length === 0 ? (
<div className="px-2">
<InnerSideBarEmptyPanel
title="No custom reports yet"
description="Create and save custom reports to track your project metrics"
actions={
<ButtonTooltip
type="default"
icon={<Plus />}
disabled={!canCreateCustomReport}
onClick={() => {
setShowNewReportModal(true)
}}
tooltip={{
content: {
side: 'bottom',
text: !canCreateCustomReport
? 'You need additional permissions to create custom reports'
: undefined,
},
}}
>
New custom report
</ButtonTooltip>
}
/>
</div>
) : null}
</div>
</>
)}
<UpdateCustomReportModal
onCancel={() => setSelectedReportToUpdate(undefined)}
selectedReport={selectedReportToUpdate}
initialValues={{
name: selectedReportToUpdate?.name || '',
description: selectedReportToUpdate?.description || '',
}}
/>
<ConfirmationModal
title="Delete custom report"
confirmLabel="Delete report"
size="medium"
loading={false}
visible={deleteModalOpen}
onCancel={() => setDeleteModalOpen(false)}
onConfirm={onConfirmDeleteReport}
>
<div className="text-sm text-foreground-light grid gap-4">
<div className="grid gap-1">
<p>Are you sure you want to delete '{selectedReportToDelete?.name}'?</p>
</div>
</div>
</ConfirmationModal>
<CreateReportModal
visible={showNewReportModal}
onCancel={() => setShowNewReportModal(false)}
afterSubmit={() => setShowNewReportModal(false)}
/>
</div>
)}
</div>
)
}
export default ObservabilityMenu