SQLEditorNav.tsx 31 KB

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