ReportsMenu.tsx266 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { Plus } from 'lucide-react'
4import { useRouter } from 'next/router'
5import { parseAsBoolean, useQueryState } from 'nuqs'
6import { useMemo, useState } from 'react'
7import { toast } from 'sonner'
8import { Menu } from 'ui'
9import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
10import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
11
12import { ReportMenuItem } from './ReportMenuItem'
13import { CreateReportModal } from '@/components/interfaces/Reports/CreateReportModal'
14import { UpdateCustomReportModal } from '@/components/interfaces/Reports/UpdateModal'
15import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
16import { ProductMenu } from '@/components/ui/ProductMenu'
17import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
18import { Content, useContentQuery } from '@/data/content/content-query'
19import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
20import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
21import { useProfile } from '@/lib/profile'
22
23const 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
266export default ReportsMenu