Files
supabase/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx
Gildas Garcia 243e079a2c chore: remove _Shadcn_ suffix from Command components (#46153)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `Command` components

## Solution

- Remove the `_Shadcn_` suffix
- Simplify UI package exports
- Apply prettier

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Simplified command component imports and exports across the UI library
by removing internal naming aliases and adopting direct component
references. Updated the public UI package barrel export to use wildcard
re-exports for cleaner API surface.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46153?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 15:45:32 +02:00

377 lines
14 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { IS_PLATFORM, useParams } from 'common'
import { Check, Code, Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
} from 'ui'
import * as z from 'zod'
import { getContentById } from '@/data/content/content-id-query'
import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation'
import { useSQLSnippetFolderCreateMutation } from '@/data/content/sql-folder-create-mutation'
import { Snippet } from '@/data/content/sql-folders-query'
import {
SnippetWithContent,
useSnippetFolders,
useSqlEditorV2StateSnapshot,
} from '@/state/sql-editor-v2'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
interface MoveQueryModalProps {
visible: boolean
snippets?: Snippet[]
onClose: () => void
}
/**
* [Joshen] Just FYI react-accessible-tree-view doesn't support drag and drop for moving
* files out of the box and we'll need to figure out a way to support this ideal UX. Same
* thing for the Storage Explorer actually. So this is just a temporary UX till we can figure
* out drag and drop that works nicely with the tree view. React beautiful dnd unfortunately
* doesn't support drag drop into a folder kind of UX.
*/
export const MoveQueryModal = ({ visible, snippets = [], onClose }: MoveQueryModalProps) => {
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
const router = useRouter()
const [open, setOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string>()
const { mutateAsync: createFolder, isPending: isCreatingFolder } =
useSQLSnippetFolderCreateMutation({
onError: (error) => {
toast.error(`Failed to create new folder: ${error.message}`)
},
})
const { mutateAsync: moveSnippetAsync, isPending: isMovingSnippet } = useContentUpsertMutation({
onError: (error) => {
toast.error(`Failed to move query: ${error.message}`)
},
})
const getFormSchema = () => {
if (selectedId === 'new-folder') {
return z
.object({
name: z.string().min(1, 'Please provide a name for the folder'),
})
.refine((data) => !snapV2.allFolderNames.includes(data.name), {
message: 'This folder name already exists',
path: ['name'],
})
} else {
return z.object({})
}
}
const FormSchema = getFormSchema()
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onSubmit',
reValidateMode: 'onSubmit',
resolver: zodResolver(FormSchema),
defaultValues: { name: '' },
})
const folders = useSnippetFolders(ref as string)
const selectedFolder =
selectedId === 'root'
? 'Root of the editor'
: selectedId === 'new-folder'
? 'Create a new folder'
: folders.find((f) => f.id === selectedId)?.name
const isCurrentFolder =
snippets.length === 1 &&
((!snippets[0].folder_id && selectedId === 'root') || snippets[0].folder_id === selectedId)
const isMovingToSameFolder =
snippets.length === 1 &&
((!snippets[0].folder_id && selectedId === 'root') || snippets[0].folder_id === selectedId)
const onConfirmMove = async (values: z.infer<typeof FormSchema>) => {
if (!ref) return console.error('Project ref is required')
try {
let folderId = selectedId
if (selectedId === 'new-folder' && 'name' in values) {
const { id } = await createFolder({
projectRef: ref,
name: values.name,
})
folderId = id
}
await Promise.all(
snippets.map(async (snippet) => {
let snippetContent = (snippet as SnippetWithContent)?.content
if (snippetContent === undefined) {
const { content } = await getContentById({ projectRef: ref, id: snippet.id })
if ('unchecked_sql' in content) {
snippetContent = content
}
}
if (snippetContent === undefined) {
return toast.error('Failed to save snippet: Unable to retrieve snippet contents')
} else {
const movedSnippet = await moveSnippetAsync({
projectRef: ref,
payload: {
id: snippet.id,
type: 'sql',
name: snippet.name,
description: snippet.description,
visibility: snippet.visibility,
project_id: snippet.project_id,
owner_id: snippet.owner_id,
folder_id: selectedId === 'root' ? null : folderId,
content: snippetContent as any,
},
})
if (IS_PLATFORM) {
snapV2.updateSnippet({
id: snippet.id,
snippet: { ...snippet, folder_id: selectedId === 'root' ? null : folderId },
skipSave: true,
})
} else if (movedSnippet) {
// On selfhosted, we need to update the state with the moved snippet because the snippet depends on the
// folder_id the moved snippet has a different id than the original snippet.
// remove the old snippet from the state without saving to API
snapV2.removeSnippet(snippet.id, true)
snapV2.addSnippet({ projectRef: ref, snippet: movedSnippet })
// remove the tab for the old snippet if the snippet was open. Moving can also happen when the tab is not open.
const tabId = createTabId('sql', { id: snippet.id })
if (tabsSnap.hasTab(tabId)) {
tabsSnap.removeTab(tabId)
await router.push(`/project/${ref}/sql/${movedSnippet.id}`)
}
}
}
})
)
toast.success(
`Successfully moved ${snippets.length === 1 ? `"${snippets[0].name}"` : `${snippets.length} snippets`} to ${selectedId === 'root' ? 'the root of the editor' : selectedFolder}`
)
onClose()
} catch (error: any) {
// error will be handled by the mutation's onError callback
console.error('Error moving snippets:', error)
}
}
useEffect(() => {
if (visible && snippets !== undefined) {
if (snippets.length === 1) {
setSelectedId(snippets[0].folder_id ?? 'root')
} else {
setSelectedId('root')
}
form.reset({ name: '' })
}
}, [visible, snippets])
return (
<Dialog open={visible} onOpenChange={() => onClose()}>
<DialogContent>
<Form {...form}>
<form id="move-snippet" onSubmit={form.handleSubmit(onConfirmMove)}>
<DialogHeader>
<DialogTitle>
Move {snippets.length === 1 ? `"${snippets[0].name}"` : `${snippets.length}`}{' '}
snippet{snippets.length > 1 ? 's' : ''} to a folder
</DialogTitle>
<DialogDescription>
Select which folder to move your quer{snippets.length > 1 ? 'ies' : 'y'} to
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="py-5 flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<Label className="text-foreground-light">Select a folder</Label>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
<Button
block
size="small"
type="default"
className="pr-2 justify-between"
iconRight={
<Code
className="text-foreground-light rotate-90"
strokeWidth={2}
size={12}
/>
}
>
<div className="flex items-center space-x-2">
{selectedFolder}
{isCurrentFolder && ` (Current)`}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="bottom" align="start" sameWidthAsTrigger>
<Command>
<CommandInput placeholder="Find folder..." />
<CommandList>
<CommandEmpty>No folders found</CommandEmpty>
<CommandGroup>
<ScrollArea className={(folders || []).length > 6 ? 'h-[210px]' : ''}>
<CommandItem
key="root"
value="root"
className="cursor-pointer w-full justify-between"
onSelect={() => {
setOpen(false)
setSelectedId('root')
}}
onClick={() => {
setOpen(false)
setSelectedId('root')
}}
>
<span>
Root of the editor
{snippets.length === 1 &&
snippets[0].folder_id === null &&
` (Current)`}
</span>
{selectedId === 'root' && <Check size={14} />}
</CommandItem>
{folders?.map((folder) => (
<CommandItem
key={folder.id}
value={folder.name}
className="cursor-pointer w-full justify-between"
onSelect={() => {
setOpen(false)
setSelectedId(folder.id)
}}
onClick={() => {
setOpen(false)
setSelectedId(folder.id)
}}
>
<span>
{folder.name}
{snippets.length === 1 &&
snippets[0].folder_id === folder.id &&
` (Current)`}
</span>
{folder.id === selectedId && <Check size={14} />}
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem
className="cursor-pointer w-full justify-start gap-x-2"
onSelect={(_e) => {
setOpen(false)
setSelectedId('new-folder')
}}
onClick={() => {
setOpen(false)
setSelectedId('new-folder')
}}
>
<Plus size={14} strokeWidth={1.5} />
<p>New folder</p>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{selectedId === 'new-folder' && (
<div className="flex flex-col gap-y-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="flex flex-col gap-y-2">
<FormLabel>Provide a name for your new folder</FormLabel>
<FormControl>
<Input
autoFocus
{...field}
autoComplete="off"
disabled={isMovingSnippet || isCreatingFolder}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</DialogSection>
<DialogFooter>
<Button
type="default"
disabled={isMovingSnippet || isCreatingFolder}
onClick={() => onClose()}
>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={isMovingToSameFolder}
loading={isMovingSnippet || isCreatingFolder}
>
Move file
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}