EntityListItem.tsx507 lines · main
1import { useParams } from 'common'
2import { Copy, Download, Edit, Globe, Lock, MoreVertical, Trash } from 'lucide-react'
3import Link from 'next/link'
4import { type CSSProperties } from 'react'
5import { toast } from 'sonner'
6import {
7 Badge,
8 Button,
9 cn,
10 copyToClipboard,
11 DropdownMenu,
12 DropdownMenuContent,
13 DropdownMenuItem,
14 DropdownMenuSeparator,
15 DropdownMenuSub,
16 DropdownMenuSubContent,
17 DropdownMenuSubTrigger,
18 DropdownMenuTrigger,
19 Tooltip,
20 TooltipContent,
21 TooltipTrigger,
22 TreeViewItemVariant,
23} from 'ui'
24
25import { useExportAllRowsAsCsv, useExportAllRowsAsSql } from './ExportAllRows'
26import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
27import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
28import { getEntityLintDetails } from '@/components/interfaces/TableGridEditor/TableEntity.utils'
29import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
30import { InlineLink } from '@/components/ui/InlineLink'
31import { getTableDefinition } from '@/data/database/table-definition-query'
32import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
33import { Entity } from '@/data/entity-types/entity-types-infinite-query'
34import { useProjectLintsQuery } from '@/data/lint/lint-query'
35import { EditorTablePageLink } from '@/data/prefetchers/project.$ref.editor.$id'
36import type {
37 TableApiAccessData,
38 TableApiAccessMap,
39} from '@/data/privileges/table-api-access-query'
40import { useTableRowsCountQuery } from '@/data/table-rows/table-rows-count-query'
41import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
42import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
43import { formatSql } from '@/lib/formatSql'
44import {
45 useRoleImpersonationStateSnapshot,
46 type RoleImpersonationState,
47} from '@/state/role-impersonation-state'
48import { useTableEditorStateSnapshot } from '@/state/table-editor'
49import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
50
51export interface EntityListItemProps {
52 id: number | string
53 projectRef: string
54 item: Entity
55 isLocked: boolean
56 isActive?: boolean
57 style?: CSSProperties
58 onExportCLI: () => void
59 apiAccessMap?: TableApiAccessMap
60}
61
62// [jordi] Used to determine the entity is a table and not a view or other unsupported entity type
63function isTableLikeEntityListItem(entity: { type?: string }) {
64 return entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE
65}
66
67export const EntityListItem = ({
68 id,
69 projectRef,
70 item: entity,
71 isLocked,
72 isActive: _isActive,
73 style,
74 onExportCLI,
75 apiAccessMap,
76}: EntityListItemProps) => {
77 const { data: project } = useSelectedProjectQuery()
78 const snap = useTableEditorStateSnapshot()
79 const { selectedSchema } = useQuerySchemaState()
80
81 const tabId = createTabId(entity.type, { id: entity.id })
82 const tabs = useTabsStateSnapshot()
83 const isPreview = tabs.previewTabId === tabId
84
85 const isActive = Number(id) === entity.id
86 const canEdit = isActive && !isLocked
87
88 const { filters } = useTableFilter()
89 const roleImpersonationState = useRoleImpersonationStateSnapshot()
90 const { data: countData } = useTableRowsCountQuery(
91 {
92 projectRef,
93 tableId: entity.id,
94 filters,
95 enforceExactCount: false,
96 roleImpersonationState: roleImpersonationState as RoleImpersonationState,
97 },
98 {
99 enabled: isTableLikeEntityListItem(entity) && isActive,
100 }
101 )
102 const rowCount = countData?.count
103
104 const { data: lints = [] } = useProjectLintsQuery({
105 projectRef: project?.ref,
106 })
107
108 const tableHasRlsDisabledLint: boolean = getEntityLintDetails(
109 entity.name,
110 'rls_disabled_in_public',
111 ['ERROR'],
112 lints,
113 selectedSchema
114 ).hasLint
115
116 const tableHasRlsEnabledNoPolicyLint: boolean = getEntityLintDetails(
117 entity.name,
118 'rls_enabled_no_policy',
119 ['ERROR', 'WARN', 'INFO'],
120 lints,
121 selectedSchema
122 ).hasLint
123
124 const viewHasLints: boolean = getEntityLintDetails(
125 entity.name,
126 'security_definer_view',
127 ['ERROR', 'WARN'],
128 lints,
129 selectedSchema
130 ).hasLint
131
132 const materializedViewHasLints: boolean = getEntityLintDetails(
133 entity.name,
134 'materialized_view_in_api',
135 ['ERROR', 'WARN'],
136 lints,
137 selectedSchema
138 ).hasLint
139
140 const foreignTableHasLints: boolean = getEntityLintDetails(
141 entity.name,
142 'foreign_table_in_api',
143 ['ERROR', 'WARN'],
144 lints,
145 selectedSchema
146 ).hasLint
147
148 const apiAccessData = apiAccessMap?.[entity.name]
149
150 const formatTooltipText = (entityType: string) => {
151 const text =
152 Object.entries(ENTITY_TYPE)
153 .find(([, value]) => value === entityType)?.[0]
154 ?.toLowerCase()
155 ?.split('_')
156 ?.join(' ') || ''
157 // Return sentence case (capitalize first letter only)
158 return text.charAt(0).toUpperCase() + text.slice(1)
159 }
160
161 const { exportCsv, confirmationModal: exportCsvConfirmationModal } = useExportAllRowsAsCsv({
162 enabled: true,
163 projectRef,
164 connectionString: project?.connectionString ?? null,
165 entity,
166 type: 'fetch_all',
167 totalRows: rowCount,
168 })
169
170 const { exportSql, confirmationModal: exportSqlConfirmationModal } = useExportAllRowsAsSql({
171 enabled: true,
172 projectRef,
173 connectionString: project?.connectionString ?? null,
174 entity,
175 type: 'fetch_all',
176 totalRows: rowCount,
177 })
178
179 return (
180 <EditorTablePageLink
181 title={entity.name}
182 style={style}
183 id={String(entity.id)}
184 href={buildTableEditorUrl({ projectRef, tableId: entity.id, schema: entity.schema })}
185 role="button"
186 aria-label={`View ${entity.name}`}
187 className={cn(
188 TreeViewItemVariant({
189 isSelected: isActive && !isPreview,
190 isPreview,
191 }),
192 'pl-4 pr-1'
193 )}
194 onDoubleClick={(e) => {
195 e.preventDefault()
196 const tabId = createTabId(entity.type, { id: entity.id })
197 tabs.makeTabPermanent(tabId)
198 }}
199 >
200 <>
201 {isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
202 <Tooltip disableHoverableContent={true}>
203 <TooltipTrigger className="min-w-4">
204 <EntityTypeIcon type={entity.type} isActive={isActive} />
205 </TooltipTrigger>
206 <TooltipContent side="bottom">{formatTooltipText(entity.type)}</TooltipContent>
207 </Tooltip>
208 <div
209 className={cn(
210 'truncate overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
211 isActive && 'text-foreground'
212 )}
213 >
214 <span
215 className={cn(
216 isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
217 'text-sm transition truncate'
218 )}
219 >
220 {entity.name}
221 </span>
222 <EntityTooltipTrigger
223 entity={entity}
224 tableHasRlsDisabledLint={tableHasRlsDisabledLint}
225 tableHasRlsEnabledNoPolicyLint={tableHasRlsEnabledNoPolicyLint}
226 viewHasLints={viewHasLints}
227 materializedViewHasLints={materializedViewHasLints}
228 foreignTableHasLints={foreignTableHasLints}
229 apiAccessData={apiAccessData}
230 />
231 </div>
232
233 {canEdit && (
234 <DropdownMenu>
235 <DropdownMenuTrigger
236 asChild
237 className="text-foreground-lighter transition-all text-transparent group-hover:text-foreground data-open:text-foreground"
238 >
239 <Button
240 type="text"
241 className="w-6 h-6"
242 icon={<MoreVertical size={14} strokeWidth={2} />}
243 onClick={(e) => e.preventDefault()}
244 />
245 </DropdownMenuTrigger>
246 <DropdownMenuContent side="bottom" align="start" className="w-44">
247 <DropdownMenuItem
248 key="copy-name"
249 className="space-x-2"
250 onClick={(e) => {
251 e.stopPropagation()
252 copyToClipboard(entity.name)
253 }}
254 >
255 <Copy size={12} />
256 <span>Copy name</span>
257 </DropdownMenuItem>
258
259 {isTableLikeEntityListItem(entity) && (
260 <DropdownMenuItem
261 key="copy-schema"
262 className="space-x-2"
263 onClick={async (e) => {
264 e.stopPropagation()
265 const toastId = toast.loading('Getting table schema...')
266
267 const formattedSchema = getTableDefinition({
268 id: entity.id,
269 projectRef: project?.ref,
270 connectionString: project?.connectionString,
271 }).then((tableDefinition) => {
272 if (!tableDefinition) {
273 throw new Error('Failed to get table schema')
274 }
275 return formatSql(tableDefinition)
276 })
277
278 try {
279 await copyToClipboard(formattedSchema, () => {
280 toast.success('Table schema copied to clipboard', { id: toastId })
281 })
282 } catch (err: any) {
283 toast.error('Failed to copy schema: ' + (err.message || err), { id: toastId })
284 }
285 }}
286 >
287 <Copy size={12} />
288 <span>Copy table schema</span>
289 </DropdownMenuItem>
290 )}
291
292 {entity.type === ENTITY_TYPE.TABLE && (
293 <>
294 <DropdownMenuSeparator />
295
296 <DropdownMenuItem
297 key="edit-table"
298 className="space-x-2"
299 onClick={(e) => {
300 e.stopPropagation()
301 snap.onEditTable()
302 }}
303 >
304 <Edit size={12} />
305 <span>Edit table</span>
306 </DropdownMenuItem>
307 <DropdownMenuItem
308 key="duplicate-table"
309 className="space-x-2"
310 onClick={(e) => {
311 e.stopPropagation()
312 snap.onDuplicateTable()
313 }}
314 >
315 <Copy size={12} />
316 <span>Duplicate table</span>
317 </DropdownMenuItem>
318 <DropdownMenuItem key="view-policies" className="space-x-2" asChild>
319 <Link
320 key="view-policies"
321 href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`}
322 >
323 <Lock size={12} />
324 <span>View policies</span>
325 </Link>
326 </DropdownMenuItem>
327
328 <DropdownMenuSub>
329 <DropdownMenuSubTrigger className="gap-x-2">
330 <Download size={12} />
331 Export data
332 </DropdownMenuSubTrigger>
333 <DropdownMenuSubContent>
334 <DropdownMenuItem
335 key="download-table-csv"
336 className="space-x-2"
337 onClick={(e) => {
338 e.stopPropagation()
339 exportCsv()
340 }}
341 >
342 <span>Export table as CSV</span>
343 </DropdownMenuItem>
344 <DropdownMenuItem
345 key="download-table-sql"
346 className="gap-x-2"
347 onClick={(e) => {
348 e.stopPropagation()
349 exportSql()
350 }}
351 >
352 <span>Export table as SQL</span>
353 </DropdownMenuItem>
354 <DropdownMenuItem
355 key="download-table-cli"
356 className="gap-x-2"
357 onClick={(e) => {
358 e.stopPropagation()
359 onExportCLI()
360 }}
361 >
362 <span>Export table via CLI</span>
363 </DropdownMenuItem>
364 </DropdownMenuSubContent>
365 </DropdownMenuSub>
366
367 <DropdownMenuSeparator />
368 <DropdownMenuItem
369 key="delete-table"
370 className="gap-x-2"
371 onClick={(e) => {
372 e.stopPropagation()
373 snap.onDeleteTable()
374 }}
375 >
376 <Trash size={12} />
377 <span>Delete table</span>
378 </DropdownMenuItem>
379 </>
380 )}
381 </DropdownMenuContent>
382 </DropdownMenu>
383 )}
384 </>
385 {exportCsvConfirmationModal}
386 {exportSqlConfirmationModal}
387 </EditorTablePageLink>
388 )
389}
390
391const EntityTooltipTrigger = ({
392 entity,
393 tableHasRlsDisabledLint,
394 tableHasRlsEnabledNoPolicyLint,
395 viewHasLints,
396 materializedViewHasLints,
397 foreignTableHasLints,
398 apiAccessData,
399}: {
400 entity: Entity
401 tableHasRlsDisabledLint: boolean
402 tableHasRlsEnabledNoPolicyLint: boolean
403 viewHasLints: boolean
404 materializedViewHasLints: boolean
405 foreignTableHasLints: boolean
406 apiAccessData?: TableApiAccessData
407}) => {
408 const { ref } = useParams()
409
410 let tooltipContent = null
411 const accessWarning = 'Data is publicly accessible via API'
412 const learnMoreCTA = (
413 <InlineLink
414 href={`/project/${ref}/editor/${entity.id}?schema=${entity.schema}&showWarning=true`}
415 >
416 Learn more
417 </InlineLink>
418 )
419
420 switch (entity.type) {
421 case ENTITY_TYPE.TABLE:
422 if (tableHasRlsDisabledLint) {
423 tooltipContent = (
424 <>
425 This table can be accessed by anyone via the Data API as RLS is disabled. {learnMoreCTA}
426 .
427 </>
428 )
429 }
430 break
431 case ENTITY_TYPE.VIEW:
432 if (viewHasLints) {
433 tooltipContent = (
434 <>
435 {accessWarning} as this is a Security definer view. {learnMoreCTA}.
436 </>
437 )
438 }
439 break
440 case ENTITY_TYPE.MATERIALIZED_VIEW:
441 if (materializedViewHasLints) {
442 tooltipContent = (
443 <>
444 {accessWarning} as this is a Security definer view {learnMoreCTA}.
445 </>
446 )
447 }
448 break
449 case ENTITY_TYPE.FOREIGN_TABLE:
450 if (foreignTableHasLints) {
451 tooltipContent = (
452 <>
453 {accessWarning} as RLS is not enforced on foreign tables. {learnMoreCTA}.
454 </>
455 )
456 }
457 break
458 default:
459 break
460 }
461
462 if (tooltipContent) {
463 return (
464 <Tooltip>
465 <TooltipTrigger className="min-w-4">
466 <Badge variant="destructive">Unrestricted</Badge>
467 </TooltipTrigger>
468 <TooltipContent side="right" className="max-w-52">
469 {tooltipContent}
470 </TooltipContent>
471 </Tooltip>
472 )
473 }
474
475 const isRlsEnabledNoPolicies =
476 entity.type === ENTITY_TYPE.TABLE &&
477 apiAccessData?.apiAccessType === 'access' &&
478 tableHasRlsEnabledNoPolicyLint
479 if (isRlsEnabledNoPolicies) {
480 return (
481 <Tooltip>
482 <TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
483 <Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
484 </TooltipTrigger>
485 <TooltipContent side="right" className="max-w-52">
486 This table can be accessed via the Data API but no RLS policies exist so no data will be
487 returned
488 </TooltipContent>
489 </Tooltip>
490 )
491 }
492
493 const isApiExposedWithRlsAndPolicies =
494 apiAccessData?.apiAccessType === 'access' && !tableHasRlsEnabledNoPolicyLint
495 if (isApiExposedWithRlsAndPolicies) {
496 return (
497 <Tooltip>
498 <TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
499 <Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
500 </TooltipTrigger>
501 <TooltipContent side="right">This table can be accessed via the Data API</TooltipContent>
502 </Tooltip>
503 )
504 }
505
506 return null
507}