mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 19:08:44 +08:00
## What PR **2 of 9** in the SQL-editor query-source (Database vs Logs) stack. **Base:** `charislam/snippet-source-typing` (#48301) — this is a stacked PR; review/merge that one first. Client-side rename only — **the wire format is unchanged** (the platform API still stores and returns `content.sql`). This moves the frontend `LogSqlSnippets.Content` field to the branded `unchecked_sql`, matching `SqlSnippets.Content`, and hardens the remap boundary so the rename can't silently drop saved query text. ## Changes - **`types/userContent.ts`** — `LogSqlSnippets.Content`'s plain `sql: string` becomes `unchecked_sql: UntrustedLogSqlFragment` (the brand added in PR 1). Shape kept minimal: `{ content_id, unchecked_sql, schema_version }`. - **`data/content/content-remap.ts`** — extend `remapSqlContentField`/`unmapSqlContentField` to `log_sql`, branding **per type** (`untrustedLogSql` for logs, `untrustedSql` for database) and never mixing brands. **Defensive unmap**: content missing `unchecked_sql` is never clobbered with `sql: undefined`; a residual raw `sql` field (a missed save-path rename) throws in development to surface the bug loudly, while production no-ops safely. - **Legacy Logs Explorer consumers** updated to the branded field: the explorer save/update paths, `SavedQueriesItem`, `RecentQueriesItem`, and the recent-queries page. - **Two db-only write sites** that leaned on `LogSqlSnippets.Content.sql`: `EditorPanel` now saves `unchecked_sql`, and `MoveQueryModal` switches to the SQL-editor-specific `getSqlSnippetById` so its content is typed as `SqlSnippets.Content` — no narrowing or casting. ## Tests - **content-remap**: `log_sql` remap/unmap round-trip with the logs brand; the defensive-unmap no-op (prod) and dev throw. - **content-upsert-mutation**: a `log_sql` payload reaches the wire as a plain `content.sql` and the response remaps back to `unchecked_sql` (the data-loss-critical round-trip shared by both explorer save-new and `SavedQueriesItem` update). ## Verification - `pnpm --filter studio run typecheck` ✓ - `pnpm --filter studio run lint:ratchet` ✓ (no new warnings) - `pnpm test:studio` for `data/content` + `Settings/Logs` — 139 passing ✓ - Prettier ✓ Nothing is user-visible yet — logs snippet entry points arrive later in the stack behind the `sqlEditorLogsSource` flag. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved handling of saved and recent log queries across the SQL editor and Logs Explorer. - Log SQL now uses `unchecked_sql` (branded as untrusted) consistently when creating, editing, moving, and reopening queries, with correct remapping to/from the API boundary. - Fixed saved-query update payloads to preserve the right query content and omit legacy fields. - **Tests** - Added/expanded Vitest coverage for saved log query editing, recent-log normalization, and `log_sql` remap/upsert request/response behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
373 lines
14 KiB
TypeScript
373 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 { getSqlSnippetById } 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 type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
|
import { useSnippetFolders, useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
|
|
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) {
|
|
// Move only ever operates on database SQL snippets
|
|
const { content } = await getSqlSnippetById({ projectRef: ref, id: snippet.id })
|
|
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,
|
|
},
|
|
})
|
|
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"
|
|
variant="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
|
|
variant="default"
|
|
disabled={isMovingSnippet || isCreatingFolder}
|
|
onClick={() => onClose()}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={isMovingToSameFolder}
|
|
loading={isMovingSnippet || isCreatingFolder}
|
|
>
|
|
Move file
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|