SQLEditorNav.tsx822 lines · main
1import { keepPreviousData } from '@tanstack/react-query'
2import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
3import { Heart } from 'lucide-react'
4import { useRouter } from 'next/router'
5import { useEffect, useMemo, useState } from 'react'
6import { toast } from 'sonner'
7import { TreeView } from 'ui'
8import {
9 InnerSideBarEmptyPanel,
10 InnerSideMenuCollapsible,
11 InnerSideMenuCollapsibleContent,
12 InnerSideMenuCollapsibleTrigger,
13 InnerSideMenuSeparator,
14} from 'ui-patterns'
15import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
16
17import { CommunitySnippetsSection } from './CommunitySnippetsSection'
18import { DeleteSnippetsModal } from './DeleteSnippetsModal'
19import { ShareSnippetModal } from './ShareSnippetModal'
20import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets'
21import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants'
22import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
23import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
24import { UnshareSnippetModal } from './UnshareSnippetModal'
25import DownloadSnippetModal from '@/components/interfaces/SQLEditor/DownloadSnippetModal'
26import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal'
27import RenameQueryModal from '@/components/interfaces/SQLEditor/RenameQueryModal'
28import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
29import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
30import { EmptyPrivateQueriesPanel } from '@/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty'
31import EditorMenuListSkeleton from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton'
32import { useSqlEditorTabsCleanup } from '@/components/layouts/Tabs/Tabs.utils'
33import { useContentCountQuery } from '@/data/content/content-count-query'
34import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
35import { useSQLSnippetFoldersDeleteMutation } from '@/data/content/sql-folders-delete-mutation'
36import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from '@/data/content/sql-folders-query'
37import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query'
38import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
39import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
40import { useProfile } from '@/lib/profile'
41import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
42import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
43
44interface SQLEditorNavProps {
45 sort?: 'inserted_at' | 'name'
46}
47
48export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
49 const router = useRouter()
50 const { ref: projectRef, id } = useParams()
51
52 const { profile } = useProfile()
53 const { data: project } = useSelectedProjectQuery()
54 const tabs = useTabsStateSnapshot()
55 const snapV2 = useSqlEditorV2StateSnapshot()
56
57 const [sectionVisibility, setSectionVisibility] = useLocalStorage<SectionState>(
58 LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''),
59 DEFAULT_SECTION_STATE
60 )
61 const {
62 shared: showSharedSnippets,
63 favorite: showFavoriteSnippets,
64 private: showPrivateSnippets,
65 } = sectionVisibility
66
67 const [showMoveModal, setShowMoveModal] = useState(false)
68 const [showDeleteModal, setShowDeleteModal] = useState(false)
69 const [showRenameModal, setShowRenameModal] = useState(false)
70
71 const [expandedFolderIds, setExpandedFolderIds] = useState<string[]>([])
72 const [selectedSnippets, setSelectedSnippets] = useState<Snippet[]>([])
73 const [selectedSnippetToShare, setSelectedSnippetToShare] = useState<Snippet>()
74 const [selectedSnippetToUnshare, setSelectedSnippetToUnshare] = useState<Snippet>()
75 const [selectedSnippetToRename, setSelectedSnippetToRename] = useState<Snippet>()
76 const [selectedSnippetToDownload, setSelectedSnippetToDownload] = useState<Snippet>()
77 const [selectedFolderToDelete, setSelectedFolderToDelete] = useState<SnippetFolder>()
78
79 const snippet = snapV2.snippets[id as string]?.snippet
80
81 // ==========================
82 // Private snippets & folders
83 // ==========================
84 const {
85 data: privateSnippetsPages,
86 isSuccess,
87 isLoading,
88 isPlaceholderData,
89 isFetching,
90 hasNextPage,
91 fetchNextPage,
92 isFetchingNextPage,
93 error: snippetsFoldersError,
94 } = useSQLSnippetFoldersQuery({ projectRef, sort }, { placeholderData: keepPreviousData })
95
96 const [subResults, setSubResults] = useState<{
97 [id: string]: { snippets?: Snippet[]; isLoading: boolean }
98 }>({})
99
100 const filteredSnippets = useMemo(() => {
101 const rootSnippets = privateSnippetsPages?.pages.flatMap((page) => page.contents ?? []) ?? []
102
103 let snippetInfo = Object.values(subResults).reduce(
104 (
105 acc: {
106 snippets: Snippet[]
107 isLoading: boolean
108 snippetIds: Set<string>
109 },
110 curr
111 ) => {
112 // filter out snippets that already exist
113 const newSnippets = (curr.snippets ?? []).filter(
114 (snippet) => !acc.snippetIds.has(snippet.id)
115 )
116 const newSnippetIds = new Set(newSnippets.map((snippet) => snippet.id))
117
118 return {
119 snippets: [...acc.snippets, ...newSnippets],
120 isLoading: acc.isLoading || curr.isLoading,
121 snippetIds: new Set<string>([...acc.snippetIds, ...newSnippetIds]),
122 }
123 },
124 {
125 snippets: rootSnippets,
126 isLoading: isLoading || (isPlaceholderData && isFetching),
127 snippetIds: new Set<string>(rootSnippets.map((snippet) => snippet.id)),
128 }
129 )
130
131 if (snippet && snippet.visibility === 'user' && !snippetInfo.snippetIds.has(snippet.id)) {
132 snippetInfo.snippetIds.add(snippet.id)
133 snippetInfo.snippets = [...snippetInfo.snippets, snippet]
134 }
135
136 return snippetInfo
137 }, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet])
138
139 const privateSnippets = useMemo(
140 () =>
141 filteredSnippets.snippets
142 ?.filter((snippet) => snippet.visibility === 'user')
143 .sort((a, b) => {
144 if (sort === 'name') return a.name.localeCompare(b.name)
145 else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
146 }) ?? [],
147 [filteredSnippets.snippets, sort]
148 )
149 const folders = useSnippetFolders(projectRef!)
150
151 const { data: snippetCountData, error: snippetCountError } = useContentCountQuery({
152 projectRef,
153 type: 'sql',
154 })
155
156 useEffect(() => {
157 if (snippetCountError || snippetsFoldersError) {
158 toast.error(
159 snippetCountError?.message || snippetsFoldersError?.message || 'Failed to load snippets'
160 )
161 }
162 }, [snippetCountError, snippetsFoldersError])
163
164 const numPrivateSnippets = snippetCountData?.private ?? 0
165
166 const privateSnippetsTreeState = useMemo(
167 () =>
168 folders.length === 0 && privateSnippets.length === 0
169 ? [ROOT_NODE]
170 : formatFolderResponseForTreeView({ folders, contents: privateSnippets }),
171 [folders, privateSnippets]
172 )
173
174 const privateSnippetsTreeNodeIds = useMemo(
175 () => new Set(privateSnippetsTreeState.map((node) => node.id.toString())),
176 [privateSnippetsTreeState]
177 )
178
179 const validExpandedFolderIds = useMemo(
180 () => expandedFolderIds.filter((id) => privateSnippetsTreeNodeIds.has(id)),
181 [expandedFolderIds, privateSnippetsTreeNodeIds]
182 )
183
184 const privateSnippetsLastItemIds = useMemo(
185 () => getLastItemIds(privateSnippetsTreeState),
186 [privateSnippetsTreeState]
187 )
188
189 // =================
190 // Favorite snippets
191 // =================
192 const {
193 data: favoriteSqlSnippetsData,
194 isPending: isLoadingFavoriteSqlSnippets,
195 hasNextPage: hasMoreFavoriteSqlSnippets,
196 fetchNextPage: fetchNextFavoriteSqlSnippets,
197 isFetchingNextPage: isFetchingMoreFavoriteSqlSnippets,
198 isSuccess: isFavoriteSnippetsSuccess,
199 } = useSqlSnippetsQuery(
200 {
201 projectRef,
202 favorite: true,
203 sort,
204 },
205 { enabled: showFavoriteSnippets, placeholderData: keepPreviousData }
206 )
207
208 const favoriteSnippets = useMemo(() => {
209 let snippets = favoriteSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
210
211 if (snippet && snippet.favorite && !snippets.find((x) => x.id === snippet.id)) {
212 snippets.push(snippet as any)
213 }
214
215 return (
216 snippets
217 .map((snippet) => ({ ...snippet, folder_id: undefined }))
218 .sort((a, b) => {
219 if (sort === 'name') return a.name.localeCompare(b.name)
220 else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
221 }) ?? []
222 )
223 }, [favoriteSqlSnippetsData?.pages, snippet, sort])
224
225 const numFavoriteSnippets = snippetCountData?.favorites ?? 0
226
227 const favoritesTreeState = useMemo(
228 () =>
229 favoriteSnippets.length === 0
230 ? [ROOT_NODE]
231 : formatFolderResponseForTreeView({ contents: favoriteSnippets, folders: [] }),
232 [favoriteSnippets]
233 )
234
235 const favoriteSnippetsLastItemIds = useMemo(
236 () => getLastItemIds(favoritesTreeState),
237 [favoritesTreeState]
238 )
239
240 // =================
241 // Shared snippets
242 // =================
243 const {
244 data: sharedSqlSnippetsData,
245 isLoading: isLoadingSharedSqlSnippets,
246 hasNextPage: hasMoreSharedSqlSnippets,
247 fetchNextPage: fetchNextSharedSqlSnippets,
248 isFetchingNextPage: isFetchingMoreSharedSqlSnippets,
249 isSuccess: isSharedSqlSnippetsSuccess,
250 } = useSqlSnippetsQuery(
251 {
252 projectRef,
253 visibility: 'project',
254 sort,
255 },
256 { enabled: showSharedSnippets, placeholderData: keepPreviousData }
257 )
258
259 const sharedSnippets = useMemo(() => {
260 let snippets = sharedSqlSnippetsData?.pages.flatMap((page) => page.contents ?? []) ?? []
261
262 if (snippet && snippet.visibility === 'project' && !snippets.find((x) => x.id === snippet.id)) {
263 snippets.push(snippet as any)
264 }
265
266 return (
267 snippets.sort((a, b) => {
268 if (sort === 'name') return a.name.localeCompare(b.name)
269 else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf()
270 }) ?? []
271 )
272 }, [sharedSqlSnippetsData?.pages, snippet, sort])
273
274 const numProjectSnippets = snippetCountData?.shared ?? 0
275
276 const projectSnippetsTreeState = useMemo(
277 () =>
278 sharedSnippets.length === 0
279 ? [ROOT_NODE]
280 : formatFolderResponseForTreeView({ contents: sharedSnippets, folders: [] }),
281 [sharedSnippets]
282 )
283
284 const projectSnippetsLastItemIds = useMemo(
285 () => getLastItemIds(projectSnippetsTreeState),
286 [projectSnippetsTreeState]
287 )
288
289 const allSnippetsInView = useMemo(
290 () => [
291 ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []),
292 ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []),
293 ],
294 [privateSnippetsPages, sharedSqlSnippetsData]
295 )
296
297 // ==========================
298 // Snippet mutations from RQ
299 // ==========================
300
301 const { mutate: deleteContent } = useContentDeleteMutation({
302 onSuccess: (data) => {
303 // Update Tabs state - currently unknown how to differentiate between sql and non-sql content
304 // so we're just deleting all tabs for with matching IDs
305 const tabIds = data.map((id) => createTabId('sql', { id }))
306 tabs.removeTabs(tabIds)
307 },
308 onError: (error, data) => {
309 if (error.message.includes('Contents not found')) {
310 postDeleteCleanup(data.ids)
311 } else {
312 toast.error(`Failed to delete query: ${error.message}`)
313 }
314 },
315 })
316
317 const { mutate: deleteFolder, isPending: isDeletingFolder } = useSQLSnippetFoldersDeleteMutation({
318 onSuccess: (_, vars) => {
319 toast.success('Successfully deleted folder')
320 const { ids } = vars
321 snapV2.removeFolder(ids[0])
322 setSelectedFolderToDelete(undefined)
323 },
324 })
325
326 // ===============
327 // UI functions
328 // ===============
329
330 const postDeleteCleanup = (ids: string[]) => {
331 // [Refactor] To investigate - deleting a snippet while it's open, will have it in the side nav
332 // for a bit, before it gets removed (assumingly invalidated)
333 setShowDeleteModal(false)
334 setSelectedSnippets([])
335 const existingSnippetIds = Object.keys(snapV2.snippets).filter((x) => !ids.includes(x))
336
337 if (existingSnippetIds.length === 0) {
338 router.push(`/project/${projectRef}/sql/new`)
339 } else if (ids.includes(id as string)) {
340 router.push(`/project/${projectRef}/sql/${existingSnippetIds[0]}`)
341 }
342
343 if (ids.length > 0) ids.forEach((id) => snapV2.removeSnippet(id))
344 }
345
346 const onConfirmDeleteFolder = async () => {
347 if (!projectRef) return console.error('Project ref is required')
348 if (selectedFolderToDelete === undefined) return console.error('No folder is selected')
349
350 const folderSnippets = privateSnippets.filter(
351 (content) => content.folder_id === selectedFolderToDelete.id
352 )
353 if (folderSnippets.length > 0) {
354 const ids = folderSnippets.map((x) => x.id)
355 deleteContent(
356 { projectRef, ids },
357 {
358 onSuccess: () => {
359 ids.forEach((id) => snapV2.removeSnippet(id))
360 postDeleteCleanup(ids)
361 deleteFolder({ projectRef, ids: [selectedFolderToDelete?.id] })
362 },
363 }
364 )
365 } else {
366 deleteFolder({ projectRef, ids: [selectedFolderToDelete?.id] })
367 }
368 }
369
370 // [Joshen] Just FYI doing a controlled state instead of letting the TreeView component doing it because
371 // 1. There seems to be no way of accessing the internal state of the TreeView to retrieve the selected nodes
372 // 2. The component itself doesn't handle UUID for node IDs well - trying to multi select doesn't select the expected nodes
373 // We're only supporting shift clicks (not cmd/control click) - this is even with the react tree view component itself
374 const onMultiSelect = (selectedId: string) => {
375 // The base is always the current query thats selected
376 const contentIds = privateSnippets.map((x) => x.id)
377 const baseIndex = contentIds.indexOf(id as string)
378 const targetIndex = contentIds.indexOf(selectedId)
379
380 const floor = Math.min(baseIndex, targetIndex)
381 const ceiling = Math.max(baseIndex, targetIndex)
382
383 const _selectedSnippets = []
384 const sameFolder = privateSnippets[floor].folder_id === privateSnippets[ceiling].folder_id
385
386 for (let i = floor; i <= ceiling; i++) {
387 if (sameFolder) {
388 if (privateSnippets[i].folder_id === privateSnippets[floor].folder_id)
389 _selectedSnippets.push(privateSnippets[i])
390 } else {
391 // [Joshen] Temp don't allow selecting across folders for now
392 // _selectedSnippets.push(contents[i])
393 }
394 }
395
396 setSelectedSnippets(_selectedSnippets)
397 }
398
399 // ===============
400 // useEffects
401 // ===============
402
403 useEffect(() => {
404 if (snippet !== undefined && isSuccess) {
405 if (snippet.visibility === 'project') {
406 setSectionVisibility({ ...sectionVisibility, shared: true })
407 } else if (snippet.visibility === 'user') {
408 setSectionVisibility({ ...sectionVisibility, private: true })
409 }
410 if (snippet.folder_id && !expandedFolderIds.includes(snippet.folder_id)) {
411 setExpandedFolderIds([...expandedFolderIds, snippet.folder_id])
412 }
413 }
414 }, [snippet, sort, isSuccess])
415
416 useEffect(() => {
417 // Unselect all snippets whenever opening another snippet
418 setSelectedSnippets([])
419 }, [id])
420
421 useEffect(() => {
422 if (projectRef && privateSnippetsPages?.pages) {
423 privateSnippetsPages.pages.forEach((page) => {
424 page.contents?.forEach((snippet: Snippet) => {
425 snapV2.addSnippet({ projectRef, snippet })
426 })
427 page.folders?.forEach((folder) => snapV2.addFolder({ projectRef, folder }))
428 })
429 }
430 }, [projectRef, privateSnippetsPages?.pages])
431
432 useEffect(() => {
433 if (projectRef === undefined || !isFavoriteSnippetsSuccess) return
434
435 favoriteSqlSnippetsData.pages.forEach((page) => {
436 page.contents?.forEach((snippet) => {
437 snapV2.addSnippet({ projectRef, snippet })
438 })
439 })
440 }, [projectRef, favoriteSqlSnippetsData?.pages])
441
442 useEffect(() => {
443 if (projectRef === undefined || !isSharedSqlSnippetsSuccess) return
444
445 sharedSqlSnippetsData.pages.forEach((page) => {
446 page.contents?.forEach((snippet) => {
447 snapV2.addSnippet({ projectRef, snippet })
448 })
449 })
450 }, [projectRef, sharedSqlSnippetsData?.pages])
451
452 const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
453 useEffect(() => {
454 if (isSuccess) {
455 sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
456 }
457 }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup])
458
459 return (
460 <>
461 <InnerSideMenuSeparator />
462 {IS_PLATFORM && (
463 <>
464 <InnerSideMenuCollapsible
465 className="px-0"
466 open={showSharedSnippets}
467 onOpenChange={(value) => {
468 setSectionVisibility({
469 ...(sectionVisibility ?? DEFAULT_SECTION_STATE),
470 shared: value,
471 })
472 }}
473 >
474 <InnerSideMenuCollapsibleTrigger
475 title={`Shared ${numProjectSnippets > 0 ? ` (${numProjectSnippets})` : ''}`}
476 />
477 <InnerSideMenuCollapsibleContent className="group-data-open:pt-2">
478 {isLoadingSharedSqlSnippets ? (
479 <SQLEditorLoadingSnippets />
480 ) : sharedSnippets.length === 0 ? (
481 <InnerSideBarEmptyPanel
482 className="mx-2"
483 title="No shared queries"
484 description="Share queries with your team by right-clicking on the query."
485 />
486 ) : (
487 <TreeView
488 data={projectSnippetsTreeState}
489 aria-label="project-level-snippets"
490 nodeRenderer={({ element, ...props }) => {
491 const isOpened = Object.values(tabs.tabsMap).some(
492 (tab) => tab.metadata?.sqlId === element.metadata?.id
493 )
494 const tabId = createTabId('sql', {
495 id: element?.metadata?.id as unknown as Snippet['id'],
496 })
497 const isPreview = tabs.previewTabId === tabId
498 const isActive = !isPreview && element.metadata?.id === id
499 const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
500
501 return (
502 <SQLEditorTreeViewItem
503 {...props}
504 isOpened={isOpened && !isPreview}
505 isSelected={isActive || isSelected}
506 isPreview={isPreview}
507 onDoubleClick={(e) => {
508 e.preventDefault()
509 tabs.makeTabPermanent(tabId)
510 }}
511 element={element}
512 onSelectDelete={() => {
513 setShowDeleteModal(true)
514 setSelectedSnippets([element.metadata as unknown as Snippet])
515 }}
516 onSelectRename={() => {
517 setShowRenameModal(true)
518 setSelectedSnippetToRename(element.metadata as Snippet)
519 }}
520 onSelectDownload={() => {
521 setSelectedSnippetToDownload(element.metadata as Snippet)
522 }}
523 onSelectUnshare={() => {
524 setSelectedSnippetToUnshare(element.metadata as Snippet)
525 }}
526 isLastItem={projectSnippetsLastItemIds.has(element.id as string)}
527 hasNextPage={hasMoreSharedSqlSnippets}
528 fetchNextPage={fetchNextSharedSqlSnippets}
529 isFetchingNextPage={isFetchingMoreSharedSqlSnippets}
530 />
531 )
532 }}
533 />
534 )}
535 </InnerSideMenuCollapsibleContent>
536 </InnerSideMenuCollapsible>
537
538 <InnerSideMenuSeparator />
539
540 <InnerSideMenuCollapsible
541 className="px-0"
542 open={showFavoriteSnippets}
543 onOpenChange={(value) => {
544 setSectionVisibility({
545 ...(sectionVisibility ?? DEFAULT_SECTION_STATE),
546 favorite: value,
547 })
548 }}
549 >
550 <InnerSideMenuCollapsibleTrigger
551 title={`Favorites ${numFavoriteSnippets > 0 ? ` (${numFavoriteSnippets})` : ''}`}
552 />
553 <InnerSideMenuCollapsibleContent className="group-data-open:pt-2">
554 {isLoadingFavoriteSqlSnippets ? (
555 <SQLEditorLoadingSnippets />
556 ) : favoriteSnippets.length === 0 ? (
557 <InnerSideBarEmptyPanel
558 title="No favorite queries"
559 className="mx-2 px-3"
560 description={
561 <>
562 Save a query to favorites for easy accessibility by clicking the{' '}
563 <Heart size={12} className="inline-block relative align-center -top-px" />{' '}
564 icon.
565 </>
566 }
567 />
568 ) : (
569 <TreeView
570 data={favoritesTreeState}
571 aria-label="favorite-snippets"
572 nodeRenderer={({ element, ...props }) => {
573 const isOpened = Object.values(tabs.tabsMap).some(
574 (tab) => tab.metadata?.sqlId === element.metadata?.id
575 )
576 const tabId = createTabId('sql', {
577 id: element?.metadata?.id as unknown as Snippet['id'],
578 })
579 const isPreview = tabs.previewTabId === tabId
580 const isActive = !isPreview && element.metadata?.id === id
581 const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
582
583 return (
584 <SQLEditorTreeViewItem
585 {...props}
586 isSelected={isActive || isSelected}
587 isOpened={isOpened && !isPreview}
588 isPreview={isPreview}
589 onDoubleClick={(e) => {
590 e.preventDefault()
591 tabs.makeTabPermanent(tabId)
592 }}
593 element={element}
594 onSelectDelete={() => {
595 setShowDeleteModal(true)
596 setSelectedSnippets([element.metadata as unknown as Snippet])
597 }}
598 onSelectRename={() => {
599 setShowRenameModal(true)
600 setSelectedSnippetToRename(element.metadata as Snippet)
601 }}
602 onSelectDownload={() => {
603 setSelectedSnippetToDownload(element.metadata as Snippet)
604 }}
605 onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
606 onSelectUnshare={() => {
607 setSelectedSnippetToUnshare(element.metadata as Snippet)
608 }}
609 isLastItem={favoriteSnippetsLastItemIds.has(element.id as string)}
610 hasNextPage={hasMoreFavoriteSqlSnippets}
611 fetchNextPage={fetchNextFavoriteSqlSnippets}
612 isFetchingNextPage={isFetchingMoreFavoriteSqlSnippets}
613 />
614 )
615 }}
616 />
617 )}
618 </InnerSideMenuCollapsibleContent>
619 </InnerSideMenuCollapsible>
620
621 <InnerSideMenuSeparator />
622 </>
623 )}
624 <InnerSideMenuCollapsible
625 open={showPrivateSnippets}
626 onOpenChange={(value) => {
627 setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), private: value })
628 }}
629 className="px-0"
630 >
631 <InnerSideMenuCollapsibleTrigger
632 title={`PRIVATE
633 ${numPrivateSnippets > 0 ? ` (${numPrivateSnippets})` : ''}`}
634 />
635 <InnerSideMenuCollapsibleContent className="group-data-open:pt-2">
636 {isLoading ? (
637 <EditorMenuListSkeleton />
638 ) : folders.length === 0 && privateSnippets.length === 0 ? (
639 <EmptyPrivateQueriesPanel />
640 ) : (
641 <TreeView
642 multiSelect
643 togglableSelect
644 clickAction="EXCLUSIVE_SELECT"
645 data={privateSnippetsTreeState}
646 selectedIds={selectedSnippets.map((x) => x.id)}
647 aria-label="private-snippets"
648 onExpand={(props) => {
649 const folderId = props.element.id.toString()
650 if (props.isExpanded && !expandedFolderIds.includes(folderId)) {
651 setExpandedFolderIds([...expandedFolderIds, folderId])
652 }
653 if (!props.isExpanded && expandedFolderIds.includes(folderId)) {
654 setExpandedFolderIds(expandedFolderIds.filter((x) => x !== folderId))
655 }
656 }}
657 expandedIds={validExpandedFolderIds}
658 nodeRenderer={({ element, ...props }) => {
659 const isOpened = Object.values(tabs.tabsMap).some(
660 (tab) => tab.metadata?.sqlId === element.metadata?.id
661 )
662 const tabId = createTabId('sql', {
663 id: element?.metadata?.id as unknown as Snippet['id'],
664 })
665 const isPreview = tabs.previewTabId === tabId
666 const isActive = !isPreview && element.metadata?.id === id
667 const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
668
669 return (
670 <SQLEditorTreeViewItem
671 {...props}
672 element={element}
673 isOpened={isOpened && !isPreview}
674 isSelected={isActive || isSelected}
675 isPreview={isPreview}
676 isMultiSelected={selectedSnippets.length > 1}
677 isLastItem={privateSnippetsLastItemIds.has(element.id as string)}
678 status={props.isBranch ? snapV2.folders[element.id].status : 'idle'}
679 onMultiSelect={onMultiSelect}
680 onSelectCreate={() => {
681 if (profile && project) {
682 const snippet = createSqlSnippetSkeletonV2({
683 name: generateSnippetTitle(),
684 owner_id: profile?.id,
685 project_id: project?.id,
686 folder_id: element.id as string,
687 sql: '',
688 })
689 snapV2.addSnippet({ projectRef: project.ref, snippet })
690 router.push(`/project/${projectRef}/sql/${snippet.id}`)
691 }
692 }}
693 onSelectDelete={() => {
694 if (props.isBranch) {
695 setSelectedFolderToDelete(element.metadata as SnippetFolder)
696 } else {
697 setShowDeleteModal(true)
698 if (selectedSnippets.length === 0) {
699 setSelectedSnippets([element.metadata as unknown as Snippet])
700 }
701 }
702 }}
703 onSelectRename={() => {
704 if (props.isBranch) {
705 snapV2.editFolder(element.id as string)
706 } else {
707 setShowRenameModal(true)
708 setSelectedSnippetToRename(element.metadata as Snippet)
709 }
710 }}
711 onSelectMove={() => {
712 setShowMoveModal(true)
713 if (selectedSnippets.length === 0) {
714 setSelectedSnippets([element.metadata as Snippet])
715 }
716 }}
717 onSelectDownload={() =>
718 setSelectedSnippetToDownload(element.metadata as Snippet)
719 }
720 onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)}
721 onEditSave={(name: string) => {
722 // [Joshen] Inline editing only for folders for now
723 if (name.length === 0 && element.id === 'new-folder') {
724 snapV2.removeFolder(element.id as string)
725 } else if (name.length > 0) {
726 snapV2.saveFolder({ id: element.id as string, name })
727 }
728 }}
729 hasNextPage={hasNextPage}
730 fetchNextPage={fetchNextPage}
731 isFetchingNextPage={isFetchingNextPage}
732 sort={sort}
733 onFolderContentsChange={({ isLoading, snippets }) => {
734 setSubResults((prev) => ({
735 ...prev,
736 [element.id as string]: { snippets, isLoading },
737 }))
738 }}
739 onDoubleClick={(e) => {
740 e.preventDefault()
741 tabs.makeTabPermanent(tabId)
742 }}
743 />
744 )
745 }}
746 />
747 )}
748 </InnerSideMenuCollapsibleContent>
749 </InnerSideMenuCollapsible>
750
751 <InnerSideMenuSeparator />
752
753 <CommunitySnippetsSection />
754
755 <InnerSideMenuSeparator />
756
757 <RenameQueryModal
758 snippet={selectedSnippetToRename}
759 visible={showRenameModal}
760 onCancel={() => setShowRenameModal(false)}
761 onComplete={() => setShowRenameModal(false)}
762 />
763
764 <MoveQueryModal
765 snippets={selectedSnippets}
766 visible={showMoveModal}
767 onClose={() => {
768 setShowMoveModal(false)
769 setSelectedSnippets([])
770 }}
771 />
772
773 <DownloadSnippetModal
774 id={selectedSnippetToDownload?.id ?? ''}
775 visible={selectedSnippetToDownload !== undefined}
776 onCancel={() => setSelectedSnippetToDownload(undefined)}
777 />
778
779 <ShareSnippetModal
780 snippet={selectedSnippetToShare}
781 onClose={() => setSelectedSnippetToShare(undefined)}
782 onSuccess={() => setSectionVisibility({ ...sectionVisibility, shared: true })}
783 />
784
785 <UnshareSnippetModal
786 snippet={selectedSnippetToUnshare}
787 onClose={() => setSelectedSnippetToUnshare(undefined)}
788 onSuccess={() => setSectionVisibility({ ...sectionVisibility, private: true })}
789 />
790
791 <DeleteSnippetsModal
792 visible={showDeleteModal}
793 snippets={selectedSnippets}
794 onClose={() => {
795 setShowDeleteModal(false)
796 setSelectedSnippets([])
797 }}
798 />
799
800 <ConfirmationModal
801 size="small"
802 title="Confirm to delete folder"
803 confirmLabel="Delete folder"
804 confirmLabelLoading="Deleting folder"
805 loading={isDeletingFolder}
806 visible={selectedFolderToDelete !== undefined}
807 variant="destructive"
808 onCancel={() => setSelectedFolderToDelete(undefined)}
809 onConfirm={onConfirmDeleteFolder}
810 alert={{
811 title: 'This action cannot be undone',
812 description:
813 'All SQL snippets within the folder will be permanently removed, and cannot be recovered.',
814 }}
815 >
816 <p className="text-sm">
817 Are you sure you want to delete the folder '{selectedFolderToDelete?.name}'?
818 </p>
819 </ConfirmationModal>
820 </>
821 )
822}