MoveQueryModal.tsx376 lines · main
| 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 | |
| 40 | import { getContentById } from '@/data/content/content-id-query' |
| 41 | import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' |
| 42 | import { useSQLSnippetFolderCreateMutation } from '@/data/content/sql-folder-create-mutation' |
| 43 | import { Snippet } from '@/data/content/sql-folders-query' |
| 44 | import { |
| 45 | SnippetWithContent, |
| 46 | useSnippetFolders, |
| 47 | useSqlEditorV2StateSnapshot, |
| 48 | } from '@/state/sql-editor-v2' |
| 49 | import { createTabId, useTabsStateSnapshot } from '@/state/tabs' |
| 50 | |
| 51 | interface 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 | |
| 65 | export 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 | } |