Files
supabase/apps/studio/components/interfaces/SQLEditor/MoveQueryModal.tsx
Charis 7743fee3ab feat(studio): log_sql content shape + remap content.sql to unchecked_sql (#48305)
## 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 -->
2026-07-28 10:47:43 -04:00

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>
)
}