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