mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## 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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
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(
|
|
<RenameQueryModal snippet={SNIPPET_A} visible onCancel={vi.fn()} onComplete={onComplete} />
|
|
)
|
|
|
|
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(
|
|
<RenameQueryModal snippet={SNIPPET_A} visible onCancel={vi.fn()} onComplete={onComplete} />
|
|
)
|
|
|
|
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(
|
|
<RenameQueryModal
|
|
snippet={SNIPPET_A}
|
|
visible={false}
|
|
onCancel={vi.fn()}
|
|
onComplete={onComplete}
|
|
/>
|
|
)
|
|
rerender(
|
|
<RenameQueryModal snippet={SNIPPET_B} visible onCancel={vi.fn()} onComplete={onComplete} />
|
|
)
|
|
|
|
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(
|
|
<RenameQueryModal snippet={SNIPPET_A} visible onCancel={onCancel} onComplete={vi.fn()} />
|
|
)
|
|
|
|
await userEvent.clear(getNameInput())
|
|
await userEvent.type(getNameInput(), 'Half-typed name')
|
|
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
expect(onCancel).toHaveBeenCalledOnce()
|
|
|
|
rerender(
|
|
<RenameQueryModal
|
|
snippet={SNIPPET_A}
|
|
visible={false}
|
|
onCancel={onCancel}
|
|
onComplete={vi.fn()}
|
|
/>
|
|
)
|
|
rerender(
|
|
<RenameQueryModal snippet={SNIPPET_A} visible onCancel={onCancel} onComplete={vi.fn()} />
|
|
)
|
|
|
|
await waitFor(() => expect(getNameInput()).toHaveValue('First query'))
|
|
})
|
|
})
|