Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { toast } from 'sonner'
import { describe, expect, test, vi } from 'vitest'

import { SwitchToPreviewModal } from './SwitchToPreviewModal'
import type { components } from '@/data/api'
import type { Branch } from '@/data/branches/branches-query'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'

mockAnimationsApi()

vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))

type BranchUpdateResponse = components['schemas']['BranchUpdateResponse']

const PARENT_PROJECT_REF = 'parent-project-ref'
const BRANCH_PROJECT_REF = 'branch-project-ref'

const BRANCH: Branch = {
created_at: '2026-01-01T00:00:00.000Z',
id: '00000000-0000-0000-0000-000000000001',
is_default: false,
name: 'docs-local-staging',
parent_project_ref: PARENT_PROJECT_REF,
persistent: true,
project_ref: BRANCH_PROJECT_REF,
status: 'MIGRATIONS_PASSED',
updated_at: '2026-01-01T00:00:00.000Z',
with_data: false,
}

const mockBranchUpdate = () => {
const requests: Array<{ branchRef: string | undefined; body: unknown }> = []

addAPIMock({
method: 'patch',
path: '/v1/branches/:branch_id_or_ref',
response: async ({ request, params }) => {
requests.push({
branchRef: params.branch_id_or_ref as string | undefined,
body: await request.json(),
})
return HttpResponse.json<BranchUpdateResponse>({
message: 'ok',
workflow_run_id: 'workflow-run-1',
})
},
})

return requests
}

const renderModal = (overrides: { branch?: Branch; onClose?: () => void } = {}) => {
const onClose = overrides.onClose ?? vi.fn()
customRender(<SwitchToPreviewModal open branch={overrides.branch} onClose={onClose} />)
return { onClose }
}

describe('SwitchToPreviewModal', () => {
test('switches the branch to preview using the refs on the branch', async () => {
const requests = mockBranchUpdate()

const { onClose } = renderModal({ branch: BRANCH })

await userEvent.click(await screen.findByRole('button', { name: 'Switch to preview' }))

await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
expect(requests).toEqual([{ branchRef: BRANCH_PROJECT_REF, body: { persistent: false } }])
expect(toast.success).toHaveBeenCalledWith('Successfully updated branch')
})

test('surfaces the error and keeps the modal open when the update fails', async () => {
addAPIMock({
method: 'patch',
path: '/v1/branches/:branch_id_or_ref',
response: () =>
HttpResponse.json<APIErrorBody>({ message: 'Something exploded' }, { status: 500 }),
})

const { onClose } = renderModal({ branch: BRANCH })

const confirm = await screen.findByRole('button', { name: 'Switch to preview' })
await userEvent.click(confirm)

await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Failed to update branch: Something exploded')
)
expect(onClose).not.toHaveBeenCalled()
expect(confirm).toBeEnabled()
})

test('disables the confirm button while the branch is unavailable', async () => {
renderModal()

expect(await screen.findByRole('button', { name: 'Switch to preview' })).toBeDisabled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'

import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
import { type Branch } from '@/data/branches/branches-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'

interface SwitchToPreviewModalProps {
open: boolean
Expand All @@ -12,23 +11,20 @@ interface SwitchToPreviewModalProps {
}

export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewModalProps) => {
const { data: project } = useSelectedProjectQuery()

const branchRef = project?.ref
const projectRef = project?.parent_project_ref

const { mutate: updateBranch, isPending: isUpdatingBranch } = useBranchUpdateMutation({
onSuccess() {
toast.success('Successfully updated branch')
onClose()
},
})

const onTogglePersistent = () => {
if (branchRef === undefined || projectRef === undefined || branch === undefined) {
return console.error('Branch metadata is required')
}
updateBranch({ branchRef, projectRef, persistent: !branch.persistent })
const onSwitchToPreview = () => {
if (branch === undefined) return
updateBranch({
branchRef: branch.project_ref,
projectRef: branch.parent_project_ref,
persistent: false,
})
}

return (
Expand All @@ -38,8 +34,9 @@ export const SwitchToPreviewModal = ({ open, branch, onClose }: SwitchToPreviewM
confirmLabel="Switch to preview"
title="Switch branch to preview before deleting"
loading={isUpdatingBranch}
disabled={branch === undefined}
onCancel={() => onClose()}
onConfirm={onTogglePersistent}
onConfirm={onSwitchToPreview}
>
<p className="text-sm text-foreground-light">
You must switch the branch "{branch?.name}" to preview before deleting it.
Expand Down
29 changes: 25 additions & 4 deletions apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { acceptUntrustedSql } from '@supabase/pg-meta'
import { useParams } from 'common'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import {
Check,
FileText,
Keyboard,
Loader2,
MoreVertical,
Notebook,
NotebookText,
Play,
Save,
SearchX,
SquareCode,
Trash,
} from 'lucide-react'
Expand All @@ -33,6 +36,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
Expand All @@ -59,6 +63,7 @@ import {
} from '@/data/content/notebooks/notebook-schema'
import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-upsert-mutation'
import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'

Expand All @@ -68,6 +73,11 @@ export const ExplorerNotebookTab = () => {
const tabs = useTabsStateSnapshot()
const snap = useNotebooksStateSnapshot()

const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)

const currentNotebook = useCurrentNotebook()
const { name, content } = currentNotebook?.notebook ?? {}
const { isNotFound } = useLoadNotebook({ id, projectRef: ref })
Expand Down Expand Up @@ -181,9 +191,9 @@ export const ExplorerNotebookTab = () => {

if (isNotFound) {
return (
<div className="px-20 flex flex-col h-full items-center justify-center bg-surface-100">
<div className="p-4 h-full bg-surface-100">
<EmptyStatePresentational
icon={<Notebook className="text-foreground-lighter" />}
icon={<SearchX className="text-foreground-lighter" />}
title="Notebook not found"
description="This notebook may have been deleted or does not exist."
contentClassName="[&>h3]:text-sm [&>p]:text-xs"
Expand Down Expand Up @@ -231,7 +241,18 @@ export const ExplorerNotebookTab = () => {
<DropdownMenuTrigger asChild>
<ExplorerToolbarAction aria-label="More options" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
className="justify-between"
onClick={() => setIsIntellisenseEnabled(!isIntellisenseEnabled)}
>
<div className="flex items-center gap-x-2">
<Keyboard size={14} />
<span>Intellisense enabled</span>
</div>
{isIntellisenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onClick={() => setIsDeleteModalOpen(true)}>
<Trash size={14} />
<span>Delete notebook</span>
Expand Down
22 changes: 14 additions & 8 deletions apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMonaco } from '@monaco-editor/react'
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useFlag } from 'common'
import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react'
Expand Down Expand Up @@ -34,6 +35,7 @@ import { QueryResultRenderer } from './QueryResultRenderer'
import { QuerySourceMenu } from './QuerySourceMenu'
import { useQueryEditorAi } from './useQueryEditorAi'
import { LegacyLogsRewriteBanner } from '@/components/interfaces/Settings/Logs/LegacyLogsRewriteBanner'
import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions'
import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget'
import { getEditorSelectionParts, type EditorSelection } from '@/components/ui/AIEditor/utils'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
Expand Down Expand Up @@ -173,6 +175,9 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
const dialect = query._tag === 'logs' ? 'clickhouse' : 'postgres'
const { requestCompletion, isCompletionLoading } = useQueryEditorAi({ dialect })

const monaco = useMonaco()
useAddDefinitions('', monaco, { enabled: dialect === 'postgres' })

const { data: databases, isPending: isLoadingDatabases } = useReadReplicasQuery(
{ projectRef: project?.ref },
{
Expand Down Expand Up @@ -339,15 +344,11 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
onClick={() => setShowQuery((value) => !value)}
/>
<ExplorerToolbarAction
loading={isExecuting || isLoadingProject}
loading={isExecuting}
icon={<Play />}
tooltip="Run query"
disabled={
isLoadingProject ||
isExecuting ||
pendingProposal !== null ||
isRunDisabled ||
sql.trim().length === 0
isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0
}
onClick={() => handleRunQuery()}
>
Expand Down Expand Up @@ -381,8 +382,13 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
placeholder={!promptState?.isOpen ? generatePlaceholder(os) : ''}
placeholderClassName="top-[13px]"
className={variant === 'embedded' ? 'h-44' : undefined}
actions={{ runQuery: { enabled: !isRunDisabled, callback: handleRunQuery } }}
options={{ minimap: { enabled: false }, padding: { top: 8 } }}
actions={{
runQuery: { enabled: !isRunDisabled, callback: handleRunQuery },
}}
options={{
minimap: { enabled: false },
padding: { top: 8 },
}}
onInputChange={(value) => onSqlChange(value ?? '')}
onMount={(editor, monaco) => {
editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
import { HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -87,16 +88,25 @@ beforeEach(() => {
seedNotebook([databaseCell, logCell, markdownCell])
})

afterEach(() => notebooksState.needsSaving.clear())
afterEach(() => {
notebooksState.needsSaving.clear()
safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE)
})

describe('ExplorerNotebookTab', () => {
it('runs every database and log cell, and skips markdown cells, on "Run notebook"', async () => {
// `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns
// fetches against this same generic pg-meta query endpoint (differentiated by the `key`
// search param) as soon as a database cell's editor mounts — those are expected and
// unrelated to the actual cell run.
const INTELLISENSE_KEYS = ['keywords', 'database-functions', 'schemas', 'table-columns']
const dbRequests: Request[] = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: ({ request }) => {
dbRequests.push(request)
const key = new URL(request.url).searchParams.get('key')
if (!key || !INTELLISENSE_KEYS.includes(key)) dbRequests.push(request)
return HttpResponse.json([{ result: 1 }])
},
})
Expand Down Expand Up @@ -130,4 +140,22 @@ describe('ExplorerNotebookTab', () => {
const runNotebookButton = await screen.findByRole('button', { name: 'Run notebook' })
expect(runNotebookButton).toBeDisabled()
})

it('toggles and persists the Intellisense enabled preference from "More options"', async () => {
const readPersistedValue = () => {
const item = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE)
return item === null ? true : (JSON.parse(item) as boolean)
}
const initialValue = readPersistedValue()

renderNotebookTab()

const moreOptionsButton = await screen.findByRole('button', { name: 'More options' })
await userEvent.click(moreOptionsButton)

const intellisenseItem = await screen.findByRole('menuitem', { name: 'Intellisense enabled' })
await userEvent.click(intellisenseItem)

await waitFor(() => expect(readPersistedValue()).toBe(!initialValue))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,18 @@ describe('QueryTab execution', () => {
return HttpResponse.json<ReadReplicasData>([])
},
})
// `useAddDefinitions` fires its own background keywords/functions/schemas/table-columns
// fetches against this same generic pg-meta query endpoint (differentiated by the `key`
// search param) as soon as the editor mounts — those are expected and unrelated to the
// actual run. Only a request with no recognized intellisense `key` counts as an execution.
const INTELLISENSE_KEYS = ['keywords', 'database-functions', 'schemas', 'table-columns']
const requests: Request[] = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: ({ request }) => {
requests.push(request)
const key = new URL(request.url).searchParams.get('key')
if (!key || !INTELLISENSE_KEYS.includes(key)) requests.push(request)
return HttpResponse.json([])
},
})
Expand Down
Loading
Loading