mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
## 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>
297 lines
11 KiB
TypeScript
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
|