From 065468f8ac62f5d6f8a7eda6703285b86cc2db51 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:05:46 -0400 Subject: [PATCH] fix(studio): reset rename form after renaming a SQL snippet (#48951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix ## What is the current behavior? The SQL snippet rename modal is mounted once per nav and reused for every snippet, so a single form instance is shared across renames. On a successful rename the form was never re-baselined, leaving it dirty, and the effect that synced the form to the selected snippet bailed out whenever the form was dirty. Renaming a second snippet therefore opened the modal pre-filled with the previous snippet's name, with the submit button enabled — one careless confirm renamed the wrong query. ## What is the new behavior? The form is reset after a successful rename, and the hand-rolled sync effect is replaced with react-hook-form's `values` option so the form follows whichever snippet is selected. `keepDirtyValues` keeps a background refetch from clobbering in-progress input, which is what the old dirty guard was protecting against. It has to be disabled explicitly on the resets that discard input, since `resetOptions` on `useForm` applies to every `reset` call — not just the `values`-driven one. Adds component tests covering the submit path, the rename-then-rename regression, and discarding an abandoned edit on cancel. ## Additional context Fixes FE-4114 ## Summary by CodeRabbit - **Bug Fixes** - Improved the rename query experience by ensuring the selected snippet name is displayed correctly when reopening the rename dialog. - Cancelled edits are now discarded reliably, preventing unsaved changes from persisting. - After a successful rename, the form reflects the updated query name and maintains consistent input and button behavior. - **Tests** - Added coverage for successful renaming, cancellation, reopening with a newly selected snippet, and submitted values. --- .../SQLEditor/RenameQueryModal.test.tsx | 138 ++++++++++++++++++ .../interfaces/SQLEditor/RenameQueryModal.tsx | 11 +- 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx new file mode 100644 index 00000000000..1113bd5bf07 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx @@ -0,0 +1,138 @@ +import { untrustedSql } from '@supabase/pg-meta' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { http, HttpResponse } from 'msw' +import { describe, expect, test, vi } from 'vitest' + +import { RenameQueryModal } from './RenameQueryModal' +import type { SnippetWithContent } from '@/data/content/sql-folders-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, mswServer } from '@/tests/lib/msw' + +mockAnimationsApi() + +const createSnippet = (id: string, name: string): SnippetWithContent => ({ + id, + name, + description: undefined, + status: 'saved', + visibility: 'user', + owner_id: 1, + project_id: 1, + favorite: false, + inserted_at: '2026-08-07T14:19:25.711Z', + updated_at: '2026-08-07T14:19:25.711Z', + folder_id: 'private-folder', + type: 'sql', + content: { + content_id: id, + schema_version: '1', + unchecked_sql: untrustedSql('select 1;'), + }, +}) + +const SNIPPET_A = createSnippet('snippet-a', 'First query') +const SNIPPET_B = createSnippet('snippet-b', 'Second query') + +/** The modal renders the AI title generator, which checks for an OpenAI key when self-hosted. */ +const mockOpenAIKeyCheck = () => + mswServer.use( + http.get('*/api/ai/sql/check-api-key', () => HttpResponse.json({ hasKey: false })), + http.get('*/platform/projects/default', () => HttpResponse.json({}, { status: 404 })) + ) + +const mockUpsert = () => { + const requests: Array<{ name: string; id: string }> = [] + addAPIMock({ + method: 'put', + path: '/platform/projects/:ref/content', + response: async ({ request }) => { + const body = (await request.json()) as { id: string; name: string } + requests.push({ id: body.id, name: body.name }) + return HttpResponse.json({ ...SNIPPET_A, ...body }) + }, + }) + return requests +} + +const getNameInput = () => screen.getByLabelText('Name') + +describe('RenameQueryModal', () => { + test('submits the new name for the selected snippet', async () => { + mockOpenAIKeyCheck() + const requests = mockUpsert() + const onComplete = vi.fn() + + customRender( + + ) + + await userEvent.clear(getNameInput()) + await userEvent.type(getNameInput(), 'Renamed query') + fireEvent.click(screen.getByRole('button', { name: 'Rename query' })) + + await waitFor(() => expect(onComplete).toHaveBeenCalledOnce()) + expect(requests).toEqual([{ id: 'snippet-a', name: 'Renamed query' }]) + }) + + test('shows the next snippet name after a successful rename (FE-4114)', async () => { + mockOpenAIKeyCheck() + mockUpsert() + const onComplete = vi.fn() + + const { rerender } = customRender( + + ) + + await userEvent.clear(getNameInput()) + await userEvent.type(getNameInput(), 'Renamed query') + fireEvent.click(screen.getByRole('button', { name: 'Rename query' })) + await waitFor(() => expect(onComplete).toHaveBeenCalledOnce()) + + // The parent closes the modal, then reopens it for a different snippet + rerender( + + ) + rerender( + + ) + + await waitFor(() => expect(getNameInput()).toHaveValue('Second query')) + // Nothing has changed yet, so there is nothing to submit + expect(screen.getByRole('button', { name: 'Rename query' })).toBeDisabled() + }) + + test('discards an abandoned edit when cancelled', async () => { + mockOpenAIKeyCheck() + const onCancel = vi.fn() + + const { rerender } = customRender( + + ) + + await userEvent.clear(getNameInput()) + await userEvent.type(getNameInput(), 'Half-typed name') + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onCancel).toHaveBeenCalledOnce() + + rerender( + + ) + rerender( + + ) + + await waitFor(() => expect(getNameInput()).toHaveValue('First query')) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 525848f81cf..1ae91dc20c1 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -1,7 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import { useRouter } from 'next/router' -import { useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { @@ -146,6 +145,7 @@ export const RenameQueryModal = ({ } toast.success('Successfully renamed snippet!') + reset({ name, description }, { keepDirtyValues: false }) if (onComplete) onComplete() } catch (error: any) { // [Joshen] We probably need some rollback cause all the saving is async @@ -156,18 +156,15 @@ export const RenameQueryModal = ({ const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { name: name ?? '', description: description ?? '' }, + values: { name: name ?? '', description: description ?? '' }, + resetOptions: { keepDirtyValues: true }, }) const { reset, formState } = form const { isDirty, isSubmitting } = formState - useEffect(() => { - if (isDirty) return - reset({ name: name ?? '', description: description ?? '' }) - }, [id, name, description, reset, isDirty]) - const handleCancel = () => { onCancel() - reset() + reset(undefined, { keepDirtyValues: false }) } return (