MoveQueryModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { IS_PLATFORM, useParams } from 'common'
  3. import { Check, Code, Plus } from 'lucide-react'
  4. import { useRouter } from 'next/router'
  5. import { useEffect, useState } from 'react'
  6. import { useForm } from 'react-hook-form'
  7. import { toast } from 'sonner'
  8. import {
  9. Button,
  10. Command,
  11. CommandEmpty,
  12. CommandGroup,
  13. CommandInput,
  14. CommandItem,
  15. CommandList,
  16. CommandSeparator,
  17. Dialog,
  18. DialogContent,
  19. DialogDescription,
  20. DialogFooter,
  21. DialogHeader,
  22. DialogSection,
  23. DialogSectionSeparator,
  24. DialogTitle,
  25. Form,
  26. FormControl,
  27. FormField,
  28. FormItem,
  29. FormLabel,
  30. FormMessage,
  31. Input,
  32. Label,
  33. Popover,
  34. PopoverContent,
  35. PopoverTrigger,
  36. ScrollArea,
  37. } from 'ui'
  38. import * as z from 'zod'
  39. import { getContentById } from '@/data/content/content-id-query'
  40. import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation'
  41. import { useSQLSnippetFolderCreateMutation } from '@/data/content/sql-folder-create-mutation'
  42. import { Snippet } from '@/data/content/sql-folders-query'
  43. import {
  44. SnippetWithContent,
  45. useSnippetFolders,
  46. useSqlEditorV2StateSnapshot,
  47. } from '@/state/sql-editor-v2'
  48. import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
  49. interface MoveQueryModalProps {
  50. visible: boolean
  51. snippets?: Snippet[]
  52. onClose: () => void
  53. }
  54. /**
  55. * [Joshen] Just FYI react-accessible-tree-view doesn't support drag and drop for moving
  56. * files out of the box and we'll need to figure out a way to support this ideal UX. Same
  57. * thing for the Storage Explorer actually. So this is just a temporary UX till we can figure
  58. * out drag and drop that works nicely with the tree view. React beautiful dnd unfortunately
  59. * doesn't support drag drop into a folder kind of UX.
  60. */
  61. export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryModalProps) => {
  62. const { ref } = useParams()
  63. const snapV2 = useSqlEditorV2StateSnapshot()
  64. const tabsSnap = useTabsStateSnapshot()
  65. const router = useRouter()
  66. const [open, setOpen] = useState(false)
  67. const [selectedId, setSelectedId] = useState<string>()
  68. const { mutateAsync: createFolder, isPending: isCreatingFolder } =
  69. useSQLSnippetFolderCreateMutation({
  70. onError: (error) => {
  71. toast.error(`Failed to create new folder: ${error.message}`)
  72. },
  73. })
  74. const { mutateAsync: moveSnippetAsync, isPending: isMovingSnippet } = useContentUpsertMutation({
  75. onError: (error) => {
  76. toast.error(`Failed to move query: ${error.message}`)
  77. },
  78. })
  79. const getFormSchema = () => {
  80. if (selectedId === 'new-folder') {
  81. return z
  82. .object({
  83. name: z.string().min(1, 'Please provide a name for the folder'),
  84. })
  85. .refine((data) => !snapV2.allFolderNames.includes(data.name), {
  86. message: 'This folder name already exists',
  87. path: ['name'],
  88. })
  89. } else {
  90. return z.object({})
  91. }
  92. }
  93. const FormSchema = getFormSchema()
  94. const form = useForm<z.infer<typeof FormSchema>>({
  95. mode: 'onSubmit',
  96. reValidateMode: 'onSubmit',
  97. resolver: zodResolver(FormSchema as any),
  98. defaultValues: { name: '' },
  99. })
  100. const folders = useSnippetFolders(ref as string)
  101. const selectedFolder =
  102. selectedId === 'root'
  103. ? 'Root of the editor'
  104. : selectedId === 'new-folder'
  105. ? 'Create a new folder'
  106. : folders.find((f) => f.id === selectedId)?.name
  107. const isCurrentFolder =
  108. snippets.length === 1 &&
  109. ((!snippets[0].folder_id && selectedId === 'root') || snippets[0].folder_id === selectedId)
  110. const isMovingToSameFolder =
  111. snippets.length === 1 &&
  112. ((!snippets[0].folder_id && selectedId === 'root') || snippets[0].folder_id === selectedId)
  113. const onConfirmMove = async (values: z.infer<typeof FormSchema>) => {
  114. if (!ref) return console.error('Project ref is required')
  115. try {
  116. let folderId = selectedId
  117. if (selectedId === 'new-folder' && 'name' in values) {
  118. const { id } = await createFolder({
  119. projectRef: ref,
  120. name: values.name,
  121. })
  122. folderId = id
  123. }
  124. await Promise.all(
  125. snippets.map(async (snippet) => {
  126. let snippetContent = (snippet as SnippetWithContent)?.content
  127. if (snippetContent === undefined) {
  128. const { content } = await getContentById({ projectRef: ref, id: snippet.id })
  129. if ('unchecked_sql' in content) {
  130. snippetContent = content
  131. }
  132. }
  133. if (snippetContent === undefined) {
  134. return toast.error('Failed to save snippet: Unable to retrieve snippet contents')
  135. } else {
  136. const movedSnippet = await moveSnippetAsync({
  137. projectRef: ref,
  138. payload: {
  139. id: snippet.id,
  140. type: 'sql',
  141. name: snippet.name,
  142. description: snippet.description,
  143. visibility: snippet.visibility,
  144. project_id: snippet.project_id,
  145. owner_id: snippet.owner_id,
  146. folder_id: selectedId === 'root' ? null : folderId,
  147. content: snippetContent as any,
  148. },
  149. })
  150. if (IS_PLATFORM) {
  151. snapV2.updateSnippet({
  152. id: snippet.id,
  153. snippet: { ...snippet, folder_id: selectedId === 'root' ? null : folderId },
  154. skipSave: true,
  155. })
  156. } else if (movedSnippet) {
  157. // On selfhosted, we need to update the state with the moved snippet because the snippet depends on the
  158. // folder_id the moved snippet has a different id than the original snippet.
  159. // remove the old snippet from the state without saving to API
  160. snapV2.removeSnippet(snippet.id, true)
  161. snapV2.addSnippet({ projectRef: ref, snippet: movedSnippet })
  162. // remove the tab for the old snippet if the snippet was open. Moving can also happen when the tab is not open.
  163. const tabId = createTabId('sql', { id: snippet.id })
  164. if (tabsSnap.hasTab(tabId)) {
  165. tabsSnap.removeTab(tabId)
  166. await router.push(`/project/${ref}/sql/${movedSnippet.id}`)
  167. }
  168. }
  169. }
  170. })
  171. )
  172. toast.success(
  173. `Successfully moved ${snippets.length === 1 ? `"${snippets[0].name}"` : `${snippets.length} snippets`} to ${selectedId === 'root' ? 'the root of the editor' : selectedFolder}`
  174. )
  175. onClose()
  176. } catch (error: any) {
  177. // error will be handled by the mutation's onError callback
  178. console.error('Error moving snippets:', error)
  179. }
  180. }
  181. useEffect(() => {
  182. if (visible && snippets !== undefined) {
  183. if (snippets.length === 1) {
  184. setSelectedId(snippets[0].folder_id ?? 'root')
  185. } else {
  186. setSelectedId('root')
  187. }
  188. form.reset({ name: '' })
  189. }
  190. }, [visible, snippets])
  191. return (
  192. <Dialog open={visible} onOpenChange={() => onClose()}>
  193. <DialogContent>
  194. <Form {...form}>
  195. <form id="move-snippet" onSubmit={form.handleSubmit(onConfirmMove)}>
  196. <DialogHeader>
  197. <DialogTitle>
  198. Move {snippets.length === 1 ? `"${snippets[0].name}"` : `${snippets.length}`}{' '}
  199. snippet{snippets.length > 1 ? 's' : ''} to a folder
  200. </DialogTitle>
  201. <DialogDescription>
  202. Select which folder to move your quer{snippets.length > 1 ? 'ies' : 'y'} to
  203. </DialogDescription>
  204. </DialogHeader>
  205. <DialogSectionSeparator />
  206. <DialogSection className="py-5 flex flex-col gap-y-4">
  207. <div className="flex flex-col gap-y-2">
  208. <Label className="text-foreground-light">Select a folder</Label>
  209. <Popover open={open} onOpenChange={setOpen} modal={false}>
  210. <PopoverTrigger asChild>
  211. <Button
  212. block
  213. size="small"
  214. type="default"
  215. className="pr-2 justify-between"
  216. iconRight={
  217. <Code
  218. className="text-foreground-light rotate-90"
  219. strokeWidth={2}
  220. size={12}
  221. />
  222. }
  223. >
  224. <div className="flex items-center space-x-2">
  225. {selectedFolder}
  226. {isCurrentFolder && ` (Current)`}
  227. </div>
  228. </Button>
  229. </PopoverTrigger>
  230. <PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
  231. <Command>
  232. <CommandInput placeholder="Find folder..." />
  233. <CommandList>
  234. <CommandEmpty>No folders found</CommandEmpty>
  235. <CommandGroup>
  236. <ScrollArea className={(folders || []).length > 6 ? 'h-[210px]' : ''}>
  237. <CommandItem
  238. key="root"
  239. value="root"
  240. className="cursor-pointer w-full justify-between"
  241. onSelect={() => {
  242. setOpen(false)
  243. setSelectedId('root')
  244. }}
  245. onClick={() => {
  246. setOpen(false)
  247. setSelectedId('root')
  248. }}
  249. >
  250. <span>
  251. Root of the editor
  252. {snippets.length === 1 &&
  253. snippets[0].folder_id === null &&
  254. ` (Current)`}
  255. </span>
  256. {selectedId === 'root' && <Check size={14} />}
  257. </CommandItem>
  258. {folders?.map((folder) => (
  259. <CommandItem
  260. key={folder.id}
  261. value={folder.name}
  262. className="cursor-pointer w-full justify-between"
  263. onSelect={() => {
  264. setOpen(false)
  265. setSelectedId(folder.id)
  266. }}
  267. onClick={() => {
  268. setOpen(false)
  269. setSelectedId(folder.id)
  270. }}
  271. >
  272. <span>
  273. {folder.name}
  274. {snippets.length === 1 &&
  275. snippets[0].folder_id === folder.id &&
  276. ` (Current)`}
  277. </span>
  278. {folder.id === selectedId && <Check size={14} />}
  279. </CommandItem>
  280. ))}
  281. </ScrollArea>
  282. </CommandGroup>
  283. <CommandSeparator />
  284. <CommandGroup>
  285. <CommandItem
  286. className="cursor-pointer w-full justify-start gap-x-2"
  287. onSelect={(_e) => {
  288. setOpen(false)
  289. setSelectedId('new-folder')
  290. }}
  291. onClick={() => {
  292. setOpen(false)
  293. setSelectedId('new-folder')
  294. }}
  295. >
  296. <Plus size={14} strokeWidth={1.5} />
  297. <p>New folder</p>
  298. </CommandItem>
  299. </CommandGroup>
  300. </CommandList>
  301. </Command>
  302. </PopoverContent>
  303. </Popover>
  304. </div>
  305. {selectedId === 'new-folder' && (
  306. <div className="flex flex-col gap-y-2">
  307. <FormField
  308. name="name"
  309. control={form.control}
  310. render={({ field }) => (
  311. <FormItem className="flex flex-col gap-y-2">
  312. <FormLabel>Provide a name for your new folder</FormLabel>
  313. <FormControl>
  314. <Input
  315. autoFocus
  316. {...field}
  317. autoComplete="off"
  318. disabled={isMovingSnippet || isCreatingFolder}
  319. />
  320. </FormControl>
  321. <FormMessage />
  322. </FormItem>
  323. )}
  324. />
  325. </div>
  326. )}
  327. </DialogSection>
  328. <DialogFooter>
  329. <Button
  330. type="default"
  331. disabled={isMovingSnippet || isCreatingFolder}
  332. onClick={() => onClose()}
  333. >
  334. Cancel
  335. </Button>
  336. <Button
  337. type="primary"
  338. htmlType="submit"
  339. disabled={isMovingToSameFolder}
  340. loading={isMovingSnippet || isCreatingFolder}
  341. >
  342. Move file
  343. </Button>
  344. </DialogFooter>
  345. </form>
  346. </Form>
  347. </DialogContent>
  348. </Dialog>
  349. )
  350. }