SQLEditorNav.tsx822 lines · main
| 1 | import { keepPreviousData } from '@tanstack/react-query' |
| 2 | import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 3 | import { Heart } from 'lucide-react' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import { useEffect, useMemo, useState } from 'react' |
| 6 | import { toast } from 'sonner' |
| 7 | import { TreeView } from 'ui' |
| 8 | import { |
| 9 | InnerSideBarEmptyPanel, |
| 10 | InnerSideMenuCollapsible, |
| 11 | InnerSideMenuCollapsibleContent, |
| 12 | InnerSideMenuCollapsibleTrigger, |
| 13 | InnerSideMenuSeparator, |
| 14 | } from 'ui-patterns' |
| 15 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 16 | |
| 17 | import { CommunitySnippetsSection } from './CommunitySnippetsSection' |
| 18 | import { DeleteSnippetsModal } from './DeleteSnippetsModal' |
| 19 | import { ShareSnippetModal } from './ShareSnippetModal' |
| 20 | import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets' |
| 21 | import { DEFAULT_SECTION_STATE, type SectionState } from './SQLEditorNav.constants' |
| 22 | import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils' |
| 23 | import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem' |
| 24 | import { UnshareSnippetModal } from './UnshareSnippetModal' |
| 25 | import DownloadSnippetModal from '@/components/interfaces/SQLEditor/DownloadSnippetModal' |
| 26 | import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal' |
| 27 | import RenameQueryModal from '@/components/interfaces/SQLEditor/RenameQueryModal' |
| 28 | import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' |
| 29 | import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' |
| 30 | import { EmptyPrivateQueriesPanel } from '@/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty' |
| 31 | import EditorMenuListSkeleton from '@/components/layouts/TableEditorLayout/EditorMenuListSkeleton' |
| 32 | import { useSqlEditorTabsCleanup } from '@/components/layouts/Tabs/Tabs.utils' |
| 33 | import { useContentCountQuery } from '@/data/content/content-count-query' |
| 34 | import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' |
| 35 | import { useSQLSnippetFoldersDeleteMutation } from '@/data/content/sql-folders-delete-mutation' |
| 36 | import { Snippet, SnippetFolder, useSQLSnippetFoldersQuery } from '@/data/content/sql-folders-query' |
| 37 | import { useSqlSnippetsQuery } from '@/data/content/sql-snippets-query' |
| 38 | import { useLocalStorage } from '@/hooks/misc/useLocalStorage' |
| 39 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 40 | import { useProfile } from '@/lib/profile' |
| 41 | import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' |
| 42 | import { createTabId, useTabsStateSnapshot } from '@/state/tabs' |
| 43 | |
| 44 | interface SQLEditorNavProps { |
| 45 | sort?: 'inserted_at' | 'name' |
| 46 | } |
| 47 | |
| 48 | export 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 | } |