SQLEditorTreeViewItem.tsx 14 KB

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