ReportsMenu.tsx266 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { useParams } from 'common' |
| 3 | import { Plus } from 'lucide-react' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import { parseAsBoolean, useQueryState } from 'nuqs' |
| 6 | import { useMemo, useState } from 'react' |
| 7 | import { toast } from 'sonner' |
| 8 | import { Menu } from 'ui' |
| 9 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 10 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 11 | |
| 12 | import { ReportMenuItem } from './ReportMenuItem' |
| 13 | import { CreateReportModal } from '@/components/interfaces/Reports/CreateReportModal' |
| 14 | import { UpdateCustomReportModal } from '@/components/interfaces/Reports/UpdateModal' |
| 15 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 16 | import { ProductMenu } from '@/components/ui/ProductMenu' |
| 17 | import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' |
| 18 | import { Content, useContentQuery } from '@/data/content/content-query' |
| 19 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 20 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 21 | import { useProfile } from '@/lib/profile' |
| 22 | |
| 23 | const ReportsMenu = () => { |
| 24 | const router = useRouter() |
| 25 | const { profile } = useProfile() |
| 26 | const { ref, id } = useParams() |
| 27 | const pageKey = (id || router.pathname.split('/')[4]) as string |
| 28 | |
| 29 | const storageSupported = useIsFeatureEnabled('project_storage:all') |
| 30 | |
| 31 | const { can: canCreateCustomReport } = useAsyncCheckPermissions( |
| 32 | PermissionAction.CREATE, |
| 33 | 'user_content', |
| 34 | { |
| 35 | resource: { type: 'report', owner_id: profile?.id }, |
| 36 | subject: { id: profile?.id }, |
| 37 | } |
| 38 | ) |
| 39 | |
| 40 | // Preserve date range query parameters when navigating |
| 41 | const preservedQueryParams = useMemo(() => { |
| 42 | const { its, ite, isHelper, helperText } = router.query |
| 43 | const params = new URLSearchParams() |
| 44 | |
| 45 | if (its && typeof its === 'string') params.set('its', its) |
| 46 | if (ite && typeof ite === 'string') params.set('ite', ite) |
| 47 | if (isHelper && typeof isHelper === 'string') params.set('isHelper', isHelper) |
| 48 | if (helperText && typeof helperText === 'string') params.set('helperText', helperText) |
| 49 | |
| 50 | const queryString = params.toString() |
| 51 | return queryString ? `?${queryString}` : '' |
| 52 | }, [router.query]) |
| 53 | |
| 54 | const { data: content, isPending: isLoading } = useContentQuery({ |
| 55 | projectRef: ref, |
| 56 | type: 'report', |
| 57 | }) |
| 58 | const { mutate: deleteReport, isPending: isDeleting } = useContentDeleteMutation({ |
| 59 | onSuccess: () => { |
| 60 | setDeleteModalOpen(false) |
| 61 | toast.success('Successfully deleted report') |
| 62 | router.push(`/project/${ref}/reports`) |
| 63 | }, |
| 64 | onError: (error) => { |
| 65 | toast.error(`Failed to delete report: ${error.message}`) |
| 66 | }, |
| 67 | }) |
| 68 | |
| 69 | const [deleteModalOpen, setDeleteModalOpen] = useState(false) |
| 70 | const [showNewReportModal, setShowNewReportModal] = useQueryState( |
| 71 | 'newReport', |
| 72 | parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) |
| 73 | ) |
| 74 | const [selectedReportToDelete, setSelectedReportToDelete] = useState<Content>() |
| 75 | const [selectedReportToUpdate, setSelectedReportToUpdate] = useState<Content>() |
| 76 | |
| 77 | const onConfirmDeleteReport = () => { |
| 78 | if (ref === undefined) return console.error('Project ref is required') |
| 79 | if (selectedReportToDelete?.id === undefined) return console.error('Report ID is required') |
| 80 | deleteReport({ projectRef: ref, ids: [selectedReportToDelete.id] }) |
| 81 | } |
| 82 | |
| 83 | function getReportMenuItems() { |
| 84 | if (!content) return [] |
| 85 | |
| 86 | const reports = content?.content.filter((c) => c.type === 'report') |
| 87 | |
| 88 | const sortedReports = reports?.sort((a, b) => { |
| 89 | if (a.name < b.name) { |
| 90 | return -1 |
| 91 | } |
| 92 | if (a.name > b.name) { |
| 93 | return 1 |
| 94 | } |
| 95 | return 0 |
| 96 | }) |
| 97 | |
| 98 | const reportMenuItems = sortedReports.map((r, idx) => ({ |
| 99 | id: r.id, |
| 100 | name: r.name, |
| 101 | description: r.description || '', |
| 102 | key: r.id || idx + '-report', |
| 103 | url: `/project/${ref}/reports/${r.id}${preservedQueryParams}`, |
| 104 | hasDropdownActions: true, |
| 105 | report: r, |
| 106 | })) |
| 107 | |
| 108 | return reportMenuItems |
| 109 | } |
| 110 | |
| 111 | const reportMenuItems = getReportMenuItems() |
| 112 | |
| 113 | const menuItems = [ |
| 114 | { |
| 115 | title: 'Built-in reports', |
| 116 | key: 'builtin-reports', |
| 117 | items: [ |
| 118 | { |
| 119 | name: 'API Gateway', |
| 120 | key: 'api-overview', |
| 121 | url: `/project/${ref}/reports/api-overview${preservedQueryParams}`, |
| 122 | }, |
| 123 | { |
| 124 | name: 'Auth', |
| 125 | key: 'auth', |
| 126 | url: `/project/${ref}/reports/auth${preservedQueryParams}`, |
| 127 | }, |
| 128 | { |
| 129 | name: 'Database', |
| 130 | key: 'database', |
| 131 | url: `/project/${ref}/reports/database${preservedQueryParams}`, |
| 132 | }, |
| 133 | { |
| 134 | name: 'Edge Functions', |
| 135 | key: 'edge-functions', |
| 136 | url: `/project/${ref}/reports/edge-functions${preservedQueryParams}`, |
| 137 | }, |
| 138 | { |
| 139 | name: 'Query Performance', |
| 140 | key: 'query-performance', |
| 141 | url: `/project/${ref}/reports/query-performance${preservedQueryParams}`, |
| 142 | }, |
| 143 | { |
| 144 | name: 'PostgREST', |
| 145 | key: 'postgrest', |
| 146 | url: `/project/${ref}/reports/postgrest${preservedQueryParams}`, |
| 147 | }, |
| 148 | { |
| 149 | name: 'Realtime', |
| 150 | key: 'realtime', |
| 151 | url: `/project/${ref}/reports/realtime${preservedQueryParams}`, |
| 152 | }, |
| 153 | ...(storageSupported |
| 154 | ? [ |
| 155 | { |
| 156 | name: 'Storage', |
| 157 | key: 'storage', |
| 158 | url: `/project/${ref}/reports/storage${preservedQueryParams}`, |
| 159 | }, |
| 160 | ] |
| 161 | : []), |
| 162 | ], |
| 163 | }, |
| 164 | ] |
| 165 | |
| 166 | return ( |
| 167 | <div className="mt-6"> |
| 168 | {isLoading ? ( |
| 169 | <div className="px-5 my-4 space-y-2"> |
| 170 | <ShimmeringLoader /> |
| 171 | <ShimmeringLoader className="w-3/4" /> |
| 172 | <ShimmeringLoader className="w-1/2" /> |
| 173 | </div> |
| 174 | ) : ( |
| 175 | <div className="flex flex-col px-2 gap-y-6"> |
| 176 | <div className="px-2"> |
| 177 | <ButtonTooltip |
| 178 | block |
| 179 | type="default" |
| 180 | icon={<Plus />} |
| 181 | disabled={!canCreateCustomReport} |
| 182 | className="justify-start grow" |
| 183 | onClick={() => { |
| 184 | setShowNewReportModal(true) |
| 185 | }} |
| 186 | tooltip={{ |
| 187 | content: { |
| 188 | side: 'bottom', |
| 189 | text: !canCreateCustomReport |
| 190 | ? 'You need additional permissions to create custom reports' |
| 191 | : undefined, |
| 192 | }, |
| 193 | }} |
| 194 | > |
| 195 | New custom report |
| 196 | </ButtonTooltip> |
| 197 | </div> |
| 198 | |
| 199 | {reportMenuItems.length > 0 ? ( |
| 200 | <Menu type="pills"> |
| 201 | <Menu.Group |
| 202 | title={<span className="uppercase font-mono">Your custom reports</span>} |
| 203 | /> |
| 204 | {reportMenuItems.map((item) => ( |
| 205 | <ReportMenuItem |
| 206 | key={item.id} |
| 207 | item={item as any} |
| 208 | pageKey={pageKey} |
| 209 | onSelectEdit={() => { |
| 210 | setSelectedReportToUpdate(item.report) |
| 211 | }} |
| 212 | onSelectDelete={() => { |
| 213 | setSelectedReportToDelete(item.report) |
| 214 | setDeleteModalOpen(true) |
| 215 | }} |
| 216 | /> |
| 217 | ))} |
| 218 | </Menu> |
| 219 | ) : null} |
| 220 | |
| 221 | <ProductMenu |
| 222 | page={pageKey} |
| 223 | menu={menuItems.map((item) => ({ |
| 224 | ...item, |
| 225 | items: item.items.map((subItem) => ({ ...subItem, items: [] })), |
| 226 | }))} |
| 227 | /> |
| 228 | |
| 229 | <UpdateCustomReportModal |
| 230 | onCancel={() => setSelectedReportToUpdate(undefined)} |
| 231 | selectedReport={selectedReportToUpdate} |
| 232 | initialValues={{ |
| 233 | name: selectedReportToUpdate?.name || '', |
| 234 | description: selectedReportToUpdate?.description || '', |
| 235 | }} |
| 236 | /> |
| 237 | |
| 238 | <ConfirmationModal |
| 239 | title="Delete custom report" |
| 240 | confirmLabel="Delete report" |
| 241 | confirmLabelLoading="Deleting report" |
| 242 | size="medium" |
| 243 | loading={isDeleting} |
| 244 | visible={deleteModalOpen} |
| 245 | onCancel={() => setDeleteModalOpen(false)} |
| 246 | onConfirm={onConfirmDeleteReport} |
| 247 | > |
| 248 | <div className="text-sm text-foreground-light grid gap-4"> |
| 249 | <div className="grid gap-1"> |
| 250 | <p>Are you sure you want to delete '{selectedReportToDelete?.name}'?</p> |
| 251 | </div> |
| 252 | </div> |
| 253 | </ConfirmationModal> |
| 254 | |
| 255 | <CreateReportModal |
| 256 | visible={showNewReportModal} |
| 257 | onCancel={() => setShowNewReportModal(false)} |
| 258 | afterSubmit={() => setShowNewReportModal(false)} |
| 259 | /> |
| 260 | </div> |
| 261 | )} |
| 262 | </div> |
| 263 | ) |
| 264 | } |
| 265 | |
| 266 | export default ReportsMenu |