SQLEditorTreeViewItem.tsx453 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { keepPreviousData } from '@tanstack/react-query'
3import { IS_PLATFORM } from 'common'
4import { useParams } from 'common/hooks/useParams'
5import {
6 Copy,
7 Download,
8 Edit,
9 ExternalLink,
10 Heart,
11 Lock,
12 Move,
13 Plus,
14 Share,
15 Trash,
16} from 'lucide-react'
17import Link from 'next/link'
18import { useRouter } from 'next/router'
19import { ComponentProps, useEffect } from 'react'
20import {
21 Button,
22 cn,
23 ContextMenu,
24 ContextMenuContent,
25 ContextMenuItem,
26 ContextMenuSeparator,
27 ContextMenuTrigger,
28 TreeViewItem,
29} from 'ui'
30
31import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
32import { getContentById } from '@/data/content/content-id-query'
33import { useSQLSnippetFolderContentsQuery } from '@/data/content/sql-folder-contents-query'
34import { Snippet } from '@/data/content/sql-folders-query'
35import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
36import useLatest from '@/hooks/misc/useLatest'
37import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
38import { useProfile } from '@/lib/profile'
39import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
40
41interface SQLEditorTreeViewItemProps extends Omit<
42 ComponentProps<typeof TreeViewItem>,
43 'name' | 'xPadding'
44> {
45 element: any
46 isMultiSelected?: boolean
47 status?: 'editing' | 'saving' | 'idle'
48 getNodeProps: () => any
49 onSelectCreate?: () => void
50 onSelectDelete?: () => void
51 onSelectRename?: () => void
52 onSelectMove?: () => void
53 onSelectShare?: () => void
54 onSelectUnshare?: () => void
55 onSelectDownload?: () => void
56 onSelectDeleteFolder?: () => void
57 onEditSave?: (name: string) => void
58 onMultiSelect?: (id: string) => void
59
60 // Pagination/filtering options
61 isLastItem: boolean
62 hasNextPage?: boolean
63 fetchNextPage?: () => void
64 isFetchingNextPage?: boolean
65 sort?: 'inserted_at' | 'name'
66 name?: string
67 onFolderContentsChange?: (info: { isLoading: boolean; snippets?: Snippet[] }) => void
68}
69
70export const SQLEditorTreeViewItem = ({
71 element,
72 isBranch,
73 isExpanded,
74 level,
75 status,
76 isSelected,
77 isMultiSelected,
78 getNodeProps,
79 onSelectCreate,
80 onSelectDelete,
81 onSelectRename,
82 onSelectMove,
83 onSelectShare,
84 onSelectUnshare,
85 onSelectDownload,
86 onEditSave,
87 onMultiSelect,
88 isLastItem,
89 hasNextPage: _hasNextPage,
90 fetchNextPage: _fetchNextPage,
91 isFetchingNextPage: _isFetchingNextPage,
92 sort,
93 name,
94 onFolderContentsChange,
95 ...props
96}: SQLEditorTreeViewItemProps) => {
97 const router = useRouter()
98 const { id, ref: projectRef } = useParams()
99 const { profile } = useProfile()
100 const { data: project } = useSelectedProjectQuery()
101 const { className, onClick } = getNodeProps()
102 const snapV2 = useSqlEditorV2StateSnapshot()
103
104 const isOwner = profile?.id === element?.metadata.owner_id
105 const isSharedSnippet = element.metadata.visibility === 'project'
106 const isFavorite = element.metadata.favorite
107
108 const isEditing = status === 'editing'
109 const isSaving = status === 'saving'
110
111 const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
112 PermissionAction.CREATE,
113 'user_content',
114 {
115 resource: { type: 'sql', owner_id: profile?.id },
116 subject: { id: profile?.id },
117 }
118 )
119
120 const parentId = element.parent === 0 ? undefined : element.parent
121
122 const isEnabled = isBranch && isExpanded
123
124 const {
125 data,
126 isSuccess,
127 isLoading,
128 isFetchingNextPage: isFetchingNextPageInFolder,
129 hasNextPage: hasNextPageInFolder,
130 fetchNextPage: fetchNestPageInFolder,
131 isPlaceholderData,
132 isFetching,
133 } = useSQLSnippetFolderContentsQuery(
134 {
135 projectRef,
136 folderId: parentId ?? element.id,
137 name,
138 sort,
139 },
140 {
141 enabled: isEnabled,
142 placeholderData: keepPreviousData,
143 }
144 )
145 useEffect(() => {
146 if (projectRef && isSuccess) {
147 data.pages.forEach((page) => {
148 page.contents?.forEach((snippet) => {
149 snapV2.addSnippet({
150 projectRef,
151 snippet,
152 })
153 })
154 })
155 }
156 }, [projectRef, data?.pages])
157
158 const onFolderContentsChangeRef = useLatest(onFolderContentsChange)
159 useEffect(() => {
160 if (isEnabled) {
161 onFolderContentsChangeRef.current?.({
162 isLoading: isLoading || (isPlaceholderData && isFetching),
163 snippets: data?.pages.flatMap((page) => page.contents ?? []),
164 })
165 }
166 }, [data?.pages, isFetching, isLoading, isPlaceholderData, isEnabled])
167
168 const isInFolder = parentId !== undefined
169
170 const hasNextPage = isInFolder ? hasNextPageInFolder : _hasNextPage
171
172 function fetchNextPage() {
173 if (isInFolder) {
174 fetchNestPageInFolder()
175 } else if (typeof _fetchNextPage === 'function') {
176 _fetchNextPage()
177 }
178 }
179
180 const onToggleFavorite = () => {
181 const snippetId = element.metadata.id
182 if (snippetId) {
183 if (isFavorite) snapV2.removeFavorite(snippetId)
184 else snapV2.addFavorite(snippetId)
185 }
186 }
187
188 const onSelectDuplicate = async () => {
189 if (!profile) return console.error('Profile is required')
190 if (!project) return console.error('Project is required')
191 if (!projectRef) return console.error('Project ref is required')
192 if (!id) return console.error('Snippet ID is required')
193
194 const snippet = element.metadata
195 let sql: string = ''
196
197 if (snippet.content && snippet.content.unchecked_sql) {
198 sql = snippet.content.unchecked_sql
199 } else {
200 // Fetch the content first
201 const { content } = await getContentById({ projectRef, id: snippet.id })
202 if ('unchecked_sql' in content) {
203 sql = content.unchecked_sql
204 }
205 }
206
207 const snippetCopy = createSqlSnippetSkeletonV2({
208 name: `${snippet.name} (Duplicate)`,
209 sql,
210 owner_id: profile?.id,
211 project_id: project?.id,
212 })
213
214 snapV2.addSnippet({ projectRef, snippet: snippetCopy })
215 snapV2.addNeedsSaving(snippetCopy.id!)
216 router.push(`/project/${projectRef}/sql/${snippetCopy.id}`)
217 }
218
219 return (
220 <>
221 <ContextMenu modal={false}>
222 <ContextMenuTrigger asChild>
223 <TreeViewItem
224 className={className}
225 level={level}
226 isExpanded={isExpanded}
227 isBranch={isBranch}
228 isSelected={isSelected}
229 isPreview={props.isPreview}
230 isEditing={isEditing}
231 isLoading={(isEnabled && isLoading) || isSaving}
232 onEditSubmit={(value) => {
233 if (onEditSave !== undefined) onEditSave(value)
234 }}
235 onClick={(e) => {
236 if (!isBranch) {
237 if (!e.shiftKey) {
238 router.push(`/project/${projectRef}/sql/${element.id}`)
239 } else if (id !== 'new') {
240 onMultiSelect?.(element.id)
241 } else {
242 router.push(`/project/${projectRef}/sql/${element.id}`)
243 }
244 } else {
245 // Prevent expanding folder while editing text
246 // as the user may double click to select etc
247 if (isEditing) {
248 return
249 }
250 // When the item is a folder, we want to expand/close it
251 onClick(e)
252 }
253 }}
254 {...props}
255 name={element.name}
256 nameForTitle={props.nameForTitle}
257 description={element.metadata?.description || undefined}
258 xPadding={16}
259 />
260 </ContextMenuTrigger>
261 <ContextMenuContent onCloseAutoFocus={(e) => e.stopPropagation()}>
262 {isBranch ? (
263 <>
264 {onSelectCreate !== undefined && (
265 <ContextMenuItem
266 className="gap-x-2"
267 onSelect={() => onSelectCreate()}
268 onFocusCapture={(e) => e.stopPropagation()}
269 >
270 <Plus size={14} />
271 Create new snippet
272 </ContextMenuItem>
273 )}
274 {onSelectRename !== undefined && isOwner && (
275 <ContextMenuItem
276 className="gap-x-2"
277 onSelect={() => onSelectRename()}
278 onFocusCapture={(e) => e.stopPropagation()}
279 >
280 <Edit size={14} />
281 Rename folder
282 </ContextMenuItem>
283 )}
284 {onSelectDelete !== undefined && isOwner && (
285 <>
286 <ContextMenuSeparator />
287 <ContextMenuItem
288 className="gap-x-2"
289 onSelect={() => onSelectDelete()}
290 onFocusCapture={(e) => e.stopPropagation()}
291 >
292 <Trash size={14} />
293 Delete folder
294 </ContextMenuItem>
295 </>
296 )}
297 </>
298 ) : isMultiSelected ? (
299 <>
300 {onSelectMove !== undefined && (
301 <ContextMenuItem
302 className="gap-x-2"
303 onSelect={() => onSelectMove()}
304 onFocusCapture={(e) => e.stopPropagation()}
305 >
306 <Move size={14} />
307 Move selected queries
308 </ContextMenuItem>
309 )}
310 <ContextMenuSeparator />
311 {onSelectDelete !== undefined && (
312 <ContextMenuItem
313 className="gap-x-2"
314 onSelect={() => onSelectDelete()}
315 onFocusCapture={(e) => e.stopPropagation()}
316 >
317 <Trash size={14} />
318 Delete selected queries
319 </ContextMenuItem>
320 )}
321 </>
322 ) : (
323 <>
324 <ContextMenuItem
325 asChild
326 className="gap-x-2"
327 onFocusCapture={(e) => e.stopPropagation()}
328 >
329 <Link
330 target="_self"
331 rel="noreferrer"
332 href={`/project/${projectRef}/sql/${element.id}`}
333 >
334 <ExternalLink size={14} />
335 Open in new tab
336 </Link>
337 </ContextMenuItem>
338 <ContextMenuSeparator />
339 {onSelectRename !== undefined && isOwner && (
340 <ContextMenuItem
341 className="gap-x-2"
342 onSelect={() => onSelectRename()}
343 onFocusCapture={(e) => e.stopPropagation()}
344 >
345 <Edit size={14} />
346 Rename query
347 </ContextMenuItem>
348 )}
349 {onSelectMove !== undefined && isOwner && (
350 <ContextMenuItem
351 className="gap-x-2"
352 onSelect={() => onSelectMove()}
353 onFocusCapture={(e) => e.stopPropagation()}
354 >
355 <Move size={14} />
356 Move query
357 </ContextMenuItem>
358 )}
359 {onSelectShare !== undefined &&
360 !isSharedSnippet &&
361 canCreateSQLSnippet &&
362 IS_PLATFORM && (
363 <ContextMenuItem
364 className="gap-x-2"
365 onSelect={() => onSelectShare()}
366 onFocusCapture={(e) => e.stopPropagation()}
367 >
368 <Share size={14} />
369 Share query with team
370 </ContextMenuItem>
371 )}
372 {onSelectUnshare !== undefined && isSharedSnippet && isOwner && (
373 <ContextMenuItem
374 className="gap-x-2"
375 onSelect={() => onSelectUnshare()}
376 onFocusCapture={(e) => e.stopPropagation()}
377 >
378 <Lock size={14} />
379 Unshare query with team
380 </ContextMenuItem>
381 )}
382 {onSelectDuplicate !== undefined && canCreateSQLSnippet && (
383 <ContextMenuItem
384 className="gap-x-2"
385 onSelect={() => onSelectDuplicate()}
386 onFocusCapture={(e) => e.stopPropagation()}
387 >
388 <Copy size={14} />
389 Duplicate query
390 </ContextMenuItem>
391 )}
392 {IS_PLATFORM && (
393 <ContextMenuItem
394 className="gap-x-2"
395 onSelect={() => onToggleFavorite()}
396 onFocusCapture={(e) => e.stopPropagation()}
397 >
398 <Heart
399 size={14}
400 className={cn(
401 isFavorite ? 'fill-brand stroke-none' : 'fill-none stroke-foreground-light'
402 )}
403 />
404 {isFavorite ? 'Remove from' : 'Add to'} favorites
405 </ContextMenuItem>
406 )}
407 {onSelectDownload !== undefined && IS_PLATFORM && (
408 <ContextMenuItem
409 className="gap-x-2"
410 onSelect={() => onSelectDownload()}
411 onFocusCapture={(e) => e.stopPropagation()}
412 >
413 <Download size={14} />
414 Download as migration file
415 </ContextMenuItem>
416 )}
417 {onSelectDelete !== undefined && isOwner && (
418 <>
419 <ContextMenuSeparator />
420 <ContextMenuItem className="gap-x-2" onSelect={() => onSelectDelete()}>
421 <Trash size={14} />
422 Delete query
423 </ContextMenuItem>
424 </>
425 )}
426 </>
427 )}
428 </ContextMenuContent>
429 </ContextMenu>
430
431 {hasNextPage && typeof element.id === 'string' && isLastItem && (
432 <div
433 className="px-4 py-1"
434 style={{
435 paddingLeft:
436 !element.isBranch && element.level > 1 ? 48 * (element.level - 1) : undefined,
437 }}
438 >
439 <Button
440 type="outline"
441 size="tiny"
442 block
443 loading={isInFolder ? isFetchingNextPageInFolder : _isFetchingNextPage}
444 disabled={isInFolder ? isFetchingNextPageInFolder : _isFetchingNextPage}
445 onClick={fetchNextPage}
446 >
447 Load More
448 </Button>
449 </div>
450 )}
451 </>
452 )
453}