optional auto saving

This commit is contained in:
Saxon Fletcher committed 2026-06-16 09:16:24 +10:00
1 parent 544bee134b
commit 76e7051a20
12 files changed
+418 -177

No files matched your search

@@ -1,7 +1,6 @@
import Editor, { Monaco, OnMount } from '@monaco-editor/react'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { MutableRefObject, useEffect, useRef, useState } from 'react'
import { MutableRefObject, useEffect, useRef } from 'react'
import { cn } from 'ui'
import { Admonition } from 'ui-patterns'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
@@ -68,14 +67,14 @@ const MonacoEditor = ({
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
const [autoSaveSnippets] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
true
)
const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
const setCommandMenuOpen = useSetCommandMenuOpen()
// [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce
const [value, setValue] = useState('')
const debouncedValue = useDebounce(value, 1000)
const snippet = snapV2.snippets[id]
const disableEdit =
snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id
@@ -244,24 +243,19 @@ const MonacoEditor = ({
function handleEditorChange(value: string | undefined) {
tabsSnap.makeActiveTabPermanent()
if (id && value !== undefined) {
setValue(value)
}
}
useEffect(() => {
if (debouncedValue.length > 0 && snippet) {
// Drafts persist to local storage (handled within setSql); saved snippets queue a debounced
// DB save exactly as before.
if (id && value !== undefined && snippet) {
// Drafts persist to local storage (handled within setSql); saved snippets either autosave or
// stay local until the user explicitly saves, depending on the preference.
if (snippet.snippet.isDraftTab) {
snapV2.setSql({ id, sql: value, skipSave: true })
} else {
const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet
snapV2.setSql({ id, sql: value, shouldInvalidate })
snapV2.setSql({ id, sql: value })
if (autoSaveSnippets) {
snapV2.addNeedsSaving(id, { saveSql: true })
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue])
}
// if an SQL query is passed by the content parameter, set the editor value to its content. This
// is usually used for sending the user to SQL editor from other pages with SQL.
@@ -142,6 +142,7 @@ export const RenameQueryModal = ({
snippet: { isDraftTab: false, isNotSavedInDatabaseYet: false },
skipSave: true,
})
snapV2.setSavedSql(id)
}
const tabId = createTabId('sql', { id })
@@ -32,7 +32,11 @@ import {
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { RenameQueryModal } from './RenameQueryModal'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import { ROWS_PER_PAGE_OPTIONS, sqlAiDisclaimerComment } from './SQLEditor.constants'
import {
ROWS_PER_PAGE_OPTIONS,
sqlAiDisclaimerComment,
untitledSnippetTitle,
} from './SQLEditor.constants'
import {
DiffType,
IStandaloneCodeEditor,
@@ -62,15 +66,18 @@ import {
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
import { GridFooter } from '@/components/ui/GridFooter'
import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
import { constructHeaders, isValidConnString } from '@/data/fetchers'
import { lintKeys } from '@/data/lint/keys'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { isError } from '@/data/utils/error-check'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { BASE_PATH } from '@/lib/constants'
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
import { formatSql } from '@/lib/formatSql'
import { detectOS } from '@/lib/helpers'
import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
@@ -108,7 +115,12 @@ export const SQLEditor = () => {
const snapV2 = useSqlEditorV2StateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const showPrettyExplain = useFlag('ShowPrettyExplain')
const [autoSaveSnippets] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
true
)
const {
sourceSqlDiff,
@@ -279,6 +291,23 @@ export const SQLEditor = () => {
},
})
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
const setAiTitle = useCallback(
async (id: string, sql: string) => {
try {
const { title: name } = await generateSqlTitle({ sql })
snapV2.updateSnippet({ id, snippet: { name } })
snapV2.addNeedsSaving(id)
const tabId = createTabId('sql', { id })
tabs.updateTab(tabId, { label: name })
} catch {
// Title generation is best-effort and should not interrupt query execution.
}
},
[generateSqlTitle, snapV2, tabs]
)
const prettifyQuery = useCallback(async () => {
if (isDiffOpen) return
@@ -364,6 +393,17 @@ export const SQLEditor = () => {
return
}
if (
autoSaveSnippets &&
aiOptInLevel !== 'disabled' &&
!isHipaaProjectDisallowed &&
snippet?.snippet.name.startsWith(untitledSnippetTitle) &&
IS_PLATFORM
) {
// Intentionally don't await title generation.
setAiTitle(id, sql)
}
if (lineHighlights.length > 0) {
editor?.deltaDecorations(lineHighlights, [])
setLineHighlights([])
@@ -404,8 +444,12 @@ export const SQLEditor = () => {
id,
isExecuting,
project,
autoSaveSnippets,
aiOptInLevel,
isHipaaProjectDisallowed,
execute,
getImpersonatedRoleState,
setAiTitle,
databaseSelectorState.selectedDatabaseId,
databases,
eventTriggers,
@@ -532,8 +576,8 @@ export const SQLEditor = () => {
snapV2.setSql({ id, sql, skipSave: true })
setRenameModalOpen(true)
} else {
snapV2.setSql({ id, sql, shouldInvalidate: snippet.isNotSavedInDatabaseYet })
snapV2.addNeedsSaving(id)
snapV2.setSql({ id, sql, skipSave: true })
snapV2.addNeedsSaving(id, { saveSql: true })
}
}, [id, snapV2])
@@ -1,18 +1,14 @@
import { Hotkey } from '@tanstack/react-hotkeys'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
import { AlignLeft, Check, Keyboard, MoreVertical, Save } from 'lucide-react'
import { toast } from 'sonner'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
KeyboardShortcut,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { SqlRunButton } from './RunButton'
@@ -50,19 +46,27 @@ export const UtilityActions = ({
const { profile } = useProfile()
const snapV2 = useSqlEditorV2StateSnapshot()
const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true)
const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
const [autoSaveSnippets, setAutoSaveSnippets] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
true
)
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
''
)
const snippet = snapV2.snippets[id]
const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
const isSaving = snapV2.savingStates[id] === 'UPDATING'
const sql = snippet?.snippet.content?.unchecked_sql ?? ''
const isDraft = snippet?.snippet.isDraftTab === true
const savedSql = snapV2.savedSql[id]
const hasUnsavedSqlChanges = isDraft
? sql.trim().length > 0
: savedSql !== undefined && sql !== savedSql
const isReadOnly =
snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id
@@ -77,9 +81,9 @@ export const UtilityActions = ({
)
}
const addFavorite = () => snapV2.addFavorite(id)
const removeFavorite = () => snapV2.removeFavorite(id)
const toggleAutoSaveSnippets = () => {
setAutoSaveSnippets(!autoSaveSnippets)
}
const onSelectDatabase = (databaseId: string) => {
snapV2.resetResults(id)
@@ -95,7 +99,7 @@ export const UtilityActions = ({
<Button
data-testid="sql-editor-utility-actions"
type="default"
className={cn('px-1', isAiOpen ? 'block 2xl:hidden' : 'hidden')}
className="px-1"
icon={<MoreVertical className="text-foreground-light" />}
/>
</DropdownMenuTrigger>
@@ -114,75 +118,20 @@ export const UtilityActions = ({
</span>
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
</DropdownMenuItem>
<DropdownMenuItem className="justify-between" onClick={toggleAutoSaveSnippets}>
<span className="flex items-center gap-x-2">
<Save size={14} className="text-foreground-light" />
Auto save snippets
</span>
{autoSaveSnippets && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className={cn('items-center gap-x-2', isAiOpen ? 'hidden 2xl:flex' : 'flex')}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="px-1"
icon={<Keyboard className="text-foreground-light" />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuItem className="justify-between" onClick={toggleIntellisense}>
Intellisense enabled
{intellisenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{IS_PLATFORM && (
<Tooltip>
<TooltipTrigger asChild>
{isFavorite ? (
<Button
type="text"
size="tiny"
onClick={removeFavorite}
className="px-1"
icon={<Heart className="fill-brand stroke-none" />}
/>
) : (
<Button
type="text"
size="tiny"
onClick={addFavorite}
className="px-1"
icon={<Heart className="fill-none stroke-foreground-light" />}
/>
)}
</TooltipTrigger>
<TooltipContent side="bottom">
{isFavorite ? 'Remove from' : 'Add to'} favorites
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
onClick={prettifyQuery}
className="px-1"
icon={<AlignLeft strokeWidth={2} className="text-foreground-light" />}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="p-1 pl-2.5">
<div className="flex items-center gap-2.5">
<span>Prettify SQL</span>
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
</div>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between gap-x-2">
{IS_PLATFORM && (
{IS_PLATFORM && (!autoSaveSnippets || isDraft) && (
<SqlSaveButton
isDisabled={isDisabled || isReadOnly}
isDisabled={isDisabled || isReadOnly || !hasUnsavedSqlChanges}
isSaving={isSaving}
onClick={onSave}
/>
@@ -1,15 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
countDraftSqlTabsRequiringCloseConfirmation,
countSqlTabsRequiringCloseConfirmation,
createDraftSqlTab,
getDiscardDraftSqlTabsDialogCopy,
getDiscardSqlTabsDialogCopy,
getDraftSqlTabSql,
getOpenDraftSqlTabIds,
isDraftSqlSnippet,
restoreDraftSqlTab,
restoreOpenDraftSqlTabs,
shouldConfirmCloseDraftSqlTab,
shouldConfirmCloseSqlTab,
shouldHideDraftSqlTabFromNav,
} from './createDraftSqlTab'
import { persistDraftSqlTab, readPersistedDraftSqlTab } from './draftSqlTabStorage.utils'
@@ -83,61 +83,75 @@ describe('getDraftSqlTabSql', () => {
})
})
describe('shouldConfirmCloseDraftSqlTab', () => {
describe('shouldConfirmCloseSqlTab', () => {
const draftTab = { type: 'sql', metadata: { isDraft: true, sqlId: 'draft-1' } }
it('requires confirmation only for draft sql tabs with content', () => {
expect(shouldConfirmCloseDraftSqlTab(draftTab, 'select 1')).toBe(true)
expect(shouldConfirmCloseDraftSqlTab(draftTab, ' ')).toBe(false)
expect(
shouldConfirmCloseDraftSqlTab({ type: 'sql', metadata: { sqlId: 'draft-1' } }, 'select 1')
).toBe(false)
expect(
shouldConfirmCloseDraftSqlTab({ type: 'r', metadata: { isDraft: true } }, 'select 1')
).toBe(false)
it('requires confirmation for draft sql tabs with content', () => {
expect(shouldConfirmCloseSqlTab(draftTab, 'select 1')).toBe(true)
expect(shouldConfirmCloseSqlTab(draftTab, ' ')).toBe(false)
expect(shouldConfirmCloseSqlTab({ type: 'r', metadata: { isDraft: true } }, 'select 1')).toBe(
false
)
})
it('requires confirmation for saved sql tabs with dirty content', () => {
const savedTab = { type: 'sql', metadata: { sqlId: 'saved-1' } }
expect(shouldConfirmCloseSqlTab(savedTab, 'select 2', 'select 1')).toBe(true)
expect(shouldConfirmCloseSqlTab(savedTab, 'select 1', 'select 1')).toBe(false)
expect(shouldConfirmCloseSqlTab(savedTab, 'select 1')).toBe(false)
})
})
describe('countDraftSqlTabsRequiringCloseConfirmation', () => {
describe('countSqlTabsRequiringCloseConfirmation', () => {
const draftTab = (sqlId: string) => ({
type: 'sql',
metadata: { isDraft: true, sqlId },
})
it('counts only draft sql tabs with content', () => {
it('counts dirty saved sql tabs and draft sql tabs with content', () => {
const tabsMap = {
'sql-a': draftTab('draft-a'),
'sql-b': draftTab('draft-b'),
'sql-c': { type: 'sql', metadata: { sqlId: 'saved-c' } },
'sql-d': draftTab('draft-d'),
'sql-e': { type: 'sql', metadata: { sqlId: 'saved-e' } },
}
const getTabSql = (tabId: string) => {
if (tabId === 'sql-b') return ' '
if (tabId === 'sql-d') return 'select d'
if (tabId === 'sql-c') return 'select changed'
if (tabId === 'sql-e') return 'select e'
return 'select a'
}
const getSavedSql = (tabId: string) => {
if (tabId === 'sql-c') return 'select c'
if (tabId === 'sql-e') return 'select e'
return undefined
}
expect(
countDraftSqlTabsRequiringCloseConfirmation(
['sql-a', 'sql-b', 'sql-c', 'sql-d'],
countSqlTabsRequiringCloseConfirmation(
['sql-a', 'sql-b', 'sql-c', 'sql-d', 'sql-e'],
tabsMap,
getTabSql
getTabSql,
getSavedSql
)
).toBe(2)
).toBe(3)
})
})
describe('getDiscardDraftSqlTabsDialogCopy', () => {
describe('getDiscardSqlTabsDialogCopy', () => {
it('uses singular copy for a single tab', () => {
expect(getDiscardDraftSqlTabsDialogCopy(1)).toMatchObject({
title: 'Discard unsaved query?',
expect(getDiscardSqlTabsDialogCopy(1)).toMatchObject({
title: 'Discard unsaved changes?',
confirmLabel: 'Discard query',
})
})
it('uses plural copy with a count for multiple tabs', () => {
expect(getDiscardDraftSqlTabsDialogCopy(3)).toMatchObject({
expect(getDiscardSqlTabsDialogCopy(3)).toMatchObject({
title: 'Close 3 tabs with unsaved changes?',
description:
'You are about to close 3 tabs that have unsaved changes. Their contents will be discarded.',
@@ -183,6 +197,34 @@ describe('createDraftSqlTab', () => {
// Persisted to local storage with the initial sql
expect(readPersistedDraftSqlTab(PROJECT_REF, draftId)).toMatchObject({ sql: 'select 1' })
})
it('registers an autosaved snippet + tab and queues the initial save', () => {
const addSnippet = vi.fn()
const addNeedsSaving = vi.fn()
const addTab = vi.fn()
const snippetId = createDraftSqlTab({
projectRef: PROJECT_REF,
projectId: 1,
ownerId: 2,
snapV2: { addSnippet, addNeedsSaving },
tabs: { addTab },
initialSql: 'select 1',
creationMode: 'saved',
skipNavigation: true,
})
expect(addSnippet).toHaveBeenCalledTimes(1)
const addedSnippet = addSnippet.mock.calls[0][0].snippet
expect(addedSnippet.id).toBe(snippetId)
expect(addedSnippet.isDraftTab).toBeUndefined()
expect(addNeedsSaving).toHaveBeenCalledWith(snippetId, { saveSql: true })
expect(addTab).toHaveBeenCalledTimes(1)
expect(addTab.mock.calls[0][0].metadata).toMatchObject({ sqlId: snippetId })
expect(addTab.mock.calls[0][0].metadata.isDraft).toBeUndefined()
expect(readPersistedDraftSqlTab(PROJECT_REF, snippetId)).toBeUndefined()
})
})
describe('restoreDraftSqlTab', () => {
@@ -13,6 +13,7 @@ import { createTabId } from '@/state/tabs'
type SqlEditorV2Actions = {
addSnippet: (args: { projectRef: string; snippet: SnippetWithContent }) => void
addNeedsSaving?: (id: string, options?: { saveSql?: boolean }) => void
}
type TabsActions = {
@@ -37,6 +38,7 @@ export type CreateDraftSqlTabParams = {
tabs: TabsActions
router?: NextRouter
initialSql?: string
creationMode?: 'draft' | 'saved'
/** When true, only registers state without changing the route */
skipNavigation?: boolean
}
@@ -49,47 +51,54 @@ export function createDraftSqlTab({
tabs,
router,
initialSql = '',
creationMode = 'draft',
skipNavigation = false,
}: CreateDraftSqlTabParams): string {
const name = generateSnippetTitle()
const draftId = generateUuid([`${name}.sql`, Date.now().toString()])
const snippetId = generateUuid([`${name}.sql`, Date.now().toString()])
const snippet = createSqlSnippetSkeletonV2({
idOverride: draftId,
idOverride: snippetId,
name,
sql: initialSql,
owner_id: ownerId,
project_id: projectId,
})
snippet.isDraftTab = true
if (creationMode === 'draft') {
snippet.isDraftTab = true
}
snapV2.addSnippet({ projectRef, snippet })
persistDraftSqlTab(projectRef, draftId, {
sql: initialSql,
name,
})
if (creationMode === 'draft') {
persistDraftSqlTab(projectRef, snippetId, {
sql: initialSql,
name,
})
} else {
snapV2.addNeedsSaving?.(snippetId, { saveSql: true })
}
tabs.addTab({
id: createTabId('sql', { id: draftId }),
id: createTabId('sql', { id: snippetId }),
type: 'sql',
label: name,
metadata: {
sqlId: draftId,
sqlId: snippetId,
name,
isDraft: true,
...(creationMode === 'draft' ? { isDraft: true } : {}),
},
// Drafts are permanent tabs (not preview), so opening another draft never evicts an existing
// New query tabs are permanent (not preview), so opening another one never evicts an existing
// empty one — each "new query" gets its own tab.
isPreview: false,
})
if (!skipNavigation && router) {
void router.push(`/project/${projectRef}/sql/${draftId}`)
void router.push(`/project/${projectRef}/sql/${snippetId}`)
}
return draftId
return snippetId
}
export function restoreDraftSqlTab({
@@ -205,7 +214,7 @@ export function shouldHideDraftSqlTabFromNav(
return false
}
type DraftSqlTabLike = {
type SqlTabLike = {
type?: string
metadata?: {
isDraft?: boolean
@@ -227,25 +236,33 @@ export function getDraftSqlTabSql({
return ''
}
export function shouldConfirmCloseDraftSqlTab(tab: DraftSqlTabLike | undefined, sql: string) {
if (tab?.type !== 'sql' || !tab.metadata?.isDraft || !tab.metadata.sqlId) return false
return sql.trim().length > 0
}
export function countDraftSqlTabsRequiringCloseConfirmation(
tabIds: string[],
tabsMap: Record<string, DraftSqlTabLike | undefined>,
getTabSql: (tabId: string) => string
export function shouldConfirmCloseSqlTab(
tab: SqlTabLike | undefined,
sql: string,
savedSql?: string
) {
return tabIds.filter((tabId) => shouldConfirmCloseDraftSqlTab(tabsMap[tabId], getTabSql(tabId)))
.length
if (tab?.type !== 'sql' || !tab.metadata?.sqlId) return false
if (tab.metadata.isDraft) return sql.trim().length > 0
if (savedSql === undefined) return false
return sql !== savedSql
}
export function getDiscardDraftSqlTabsDialogCopy(count: number) {
export function countSqlTabsRequiringCloseConfirmation(
tabIds: string[],
tabsMap: Record<string, SqlTabLike | undefined>,
getTabSql: (tabId: string) => string,
getSavedSql: (tabId: string) => string | undefined
) {
return tabIds.filter((tabId) =>
shouldConfirmCloseSqlTab(tabsMap[tabId], getTabSql(tabId), getSavedSql(tabId))
).length
}
export function getDiscardSqlTabsDialogCopy(count: number) {
if (count <= 1) {
return {
title: 'Discard unsaved query?',
description: 'This query has not been saved. Closing this tab will discard its contents.',
title: 'Discard unsaved changes?',
description: 'This query has unsaved changes. Closing this tab will discard them.',
confirmLabel: 'Discard query',
}
}
@@ -1,10 +1,11 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { createDraftSqlTab } from './createDraftSqlTab'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProfile } from '@/lib/profile'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
@@ -17,6 +18,10 @@ export function useCreateDraftSqlTab() {
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
const [autoSaveSnippets] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
true
)
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
PermissionAction.CREATE,
@@ -42,9 +47,10 @@ export function useCreateDraftSqlTab() {
tabs,
router,
initialSql: options?.initialSql,
creationMode: autoSaveSnippets ? 'saved' : 'draft',
})
},
[canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs]
[autoSaveSnippets, canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs]
)
return { createDraftTab, canCreateSQLSnippet }
@@ -2,8 +2,8 @@ import { useCallback, useMemo, useState } from 'react'
import {
clearPersistedDraftSqlTab,
countDraftSqlTabsRequiringCloseConfirmation,
getDiscardDraftSqlTabsDialogCopy,
countSqlTabsRequiringCloseConfirmation,
getDiscardSqlTabsDialogCopy,
getDraftSqlTabSql,
} from './createDraftSqlTab'
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
@@ -15,7 +15,7 @@ type TabsSnapshot = {
type PendingClose = {
execute: () => void
unsavedDraftTabCount: number
unsavedSqlTabCount: number
}
export function useDraftSqlTabCloseConfirmation({
@@ -42,7 +42,18 @@ export function useDraftSqlTabCloseConfirmation({
[projectRef, tabs.tabsMap]
)
const cleanupDraftTabs = useCallback(
const getSavedSql = useCallback(
(tabId: string) => {
const tab = tabs.tabsMap[tabId]
const sqlId = tab?.metadata?.sqlId
if (!sqlId) return undefined
return getSqlEditorV2StateSnapshot().savedSql[sqlId]
},
[tabs.tabsMap]
)
const discardUnsavedSqlChanges = useCallback(
(tabIds: string[]) => {
if (!projectRef) return
@@ -50,10 +61,17 @@ export function useDraftSqlTabCloseConfirmation({
const tab = tabs.tabsMap[tabId]
const sqlId = tab?.metadata?.sqlId
if (tab?.type !== 'sql' || !tab.metadata?.isDraft || !sqlId) continue
if (tab?.type !== 'sql' || !sqlId) continue
clearPersistedDraftSqlTab(projectRef, sqlId)
snapV2.removeSnippet(sqlId, true)
if (tab.metadata?.isDraft) {
clearPersistedDraftSqlTab(projectRef, sqlId)
snapV2.removeSnippet(sqlId, true)
} else {
const savedSql = getSqlEditorV2StateSnapshot().savedSql[sqlId]
if (savedSql !== undefined) {
snapV2.setSql({ id: sqlId, sql: savedSql, skipSave: true })
}
}
}
},
[projectRef, snapV2, tabs.tabsMap]
@@ -61,27 +79,28 @@ export function useDraftSqlTabCloseConfirmation({
const requestClose = useCallback(
(tabIds: string[], execute: () => void) => {
const unsavedDraftTabCount = countDraftSqlTabsRequiringCloseConfirmation(
const unsavedSqlTabCount = countSqlTabsRequiringCloseConfirmation(
tabIds,
tabs.tabsMap,
getTabSql
getTabSql,
getSavedSql
)
if (unsavedDraftTabCount === 0) {
cleanupDraftTabs(tabIds)
if (unsavedSqlTabCount === 0) {
discardUnsavedSqlChanges(tabIds)
execute()
return
}
setPendingClose({
unsavedDraftTabCount,
unsavedSqlTabCount,
execute: () => {
cleanupDraftTabs(tabIds)
discardUnsavedSqlChanges(tabIds)
execute()
},
})
},
[cleanupDraftTabs, getTabSql, tabs.tabsMap]
[discardUnsavedSqlChanges, getSavedSql, getTabSql, tabs.tabsMap]
)
const requestCloseSingle = useCallback(
@@ -110,8 +129,8 @@ export function useDraftSqlTabCloseConfirmation({
)
const dialogCopy = useMemo(
() => getDiscardDraftSqlTabsDialogCopy(pendingClose?.unsavedDraftTabCount ?? 0),
[pendingClose?.unsavedDraftTabCount]
() => getDiscardSqlTabsDialogCopy(pendingClose?.unsavedSqlTabCount ?? 0),
[pendingClose?.unsavedSqlTabCount]
)
return {
+19 -1
View File
@@ -1,4 +1,5 @@
import { usePrevious } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams } from 'common/hooks/useParams'
import Link from 'next/link'
import { useRouter } from 'next/router'
@@ -20,6 +21,7 @@ import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayou
import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu'
import { useContentIdQuery } from '@/data/content/content-id-query'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
@@ -38,6 +40,10 @@ const SqlEditor: NextPageWithLayout = () => {
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const { history, setLastVisitedSnippet } = useDashboardHistory()
const [autoSaveSnippets] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
true
)
const allSnippets = useSnippets(ref!)
const snippet = allSnippets.find((x) => x.id === id)
@@ -124,11 +130,23 @@ const SqlEditor: NextPageWithLayout = () => {
snapV2,
tabs,
initialSql: typeof content === 'string' ? content : '',
creationMode: autoSaveSnippets ? 'saved' : 'draft',
skipNavigation: true,
})
router.replace(`/project/${ref}/sql/${draftId}`)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, router.isReady, ref, project, profile, skip, content, allSnippets, history.sql])
}, [
id,
router.isReady,
ref,
project,
profile,
skip,
content,
allSnippets,
history.sql,
autoSaveSnippets,
])
// Restore a draft opened directly by URL (e.g. on refresh) from local storage into the editor state
useEffect(() => {
+113
View File
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sqlEditorState } from './sql-editor-v2'
import { readPersistedDraftSqlTab } from '@/components/interfaces/SQLEditor/draftSqlTabStorage.utils'
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
const mocks = vi.hoisted(() => ({
upsertContent: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/data/content/content-upsert-mutation', () => ({
upsertContent: mocks.upsertContent,
}))
vi.mock('@/data/query-client', () => ({
getQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
}))
const PROJECT_REF = 'test-project'
function resetSqlEditorState() {
sqlEditorState.folders = {}
sqlEditorState.snippets = {}
sqlEditorState.results = {}
sqlEditorState.explainResults = {}
sqlEditorState.savingStates = {}
sqlEditorState.savedSql = {}
sqlEditorState.lastUpdatedFolderName = ''
sqlEditorState.diffContent = undefined
sqlEditorState.needsSaving.clear()
sqlEditorState.saveSqlOnNextSave.clear()
}
function createSnippet(id: string, sql: string) {
const snippet = createSqlSnippetSkeletonV2({
idOverride: id,
name: 'Test snippet',
sql,
owner_id: 1,
project_id: 1,
})
snippet.isNotSavedInDatabaseYet = false
return snippet
}
describe('sqlEditorState manual SQL save mode', () => {
beforeEach(() => {
vi.useRealTimers()
localStorage.clear()
mocks.upsertContent.mockClear()
resetSqlEditorState()
})
it('updates saved snippet SQL locally without queueing a save', () => {
const snippet = createSnippet('saved-1', 'select 1')
sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
sqlEditorState.setSql({ id: 'saved-1', sql: 'select 2' })
expect(sqlEditorState.snippets['saved-1'].snippet.content?.unchecked_sql).toBe('select 2')
expect(sqlEditorState.savedSql['saved-1']).toBe('select 1')
expect(sqlEditorState.needsSaving.has('saved-1')).toBe(false)
})
it('advances the saved SQL baseline after an explicit SQL save succeeds', async () => {
vi.useFakeTimers()
const snippet = createSnippet('saved-1', 'select 1')
sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
sqlEditorState.setSql({ id: 'saved-1', sql: 'select 2' })
sqlEditorState.addNeedsSaving('saved-1', { saveSql: true })
await vi.runAllTimersAsync()
expect(mocks.upsertContent).toHaveBeenCalledWith({
projectRef: PROJECT_REF,
payload: expect.objectContaining({
id: 'saved-1',
content: expect.objectContaining({ unchecked_sql: 'select 2' }),
}),
})
expect(sqlEditorState.savedSql['saved-1']).toBe('select 2')
})
it('keeps the SQL save flag when an explicit SQL save fails so retry saves SQL content', async () => {
vi.useFakeTimers()
mocks.upsertContent.mockRejectedValueOnce(new Error('Failed to save'))
const snippet = createSnippet('saved-1', 'select 1')
sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
sqlEditorState.setSql({ id: 'saved-1', sql: 'select 2' })
sqlEditorState.addNeedsSaving('saved-1', { saveSql: true })
await vi.runAllTimersAsync()
expect(sqlEditorState.savingStates['saved-1']).toBe('UPDATING_FAILED')
expect(sqlEditorState.saveSqlOnNextSave.get('saved-1')).toBe(true)
})
it('persists draft SQL to local storage without queueing a save', () => {
const snippet = createSnippet('draft-1', '')
snippet.isDraftTab = true
sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
sqlEditorState.setSql({ id: 'draft-1', sql: 'select draft' })
expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toMatchObject({
sql: 'select draft',
})
expect(sqlEditorState.needsSaving.has('draft-1')).toBe(false)
})
})
+46 -9
View File
@@ -46,6 +46,10 @@ function isDraftSqlTabId(id: string) {
return sqlEditorState.snippets[id]?.snippet?.isDraftTab === true
}
function getSnippetSql(snippet: SnippetWithContent | undefined) {
return (snippet?.content?.unchecked_sql ?? '') as string
}
export const sqlEditorState = proxy({
// ========================================================================
// ## Data properties within the store
@@ -98,6 +102,16 @@ export const sqlEditorState = proxy({
savingStates: {} as {
[snippetId: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED'
},
/**
* Last SQL content known to be persisted. Used to keep editor SQL changes manual-save only.
*/
savedSql: {} as {
[snippetId: string]: string | undefined
},
/**
* Marks saves that should persist the current editor SQL instead of only snippet metadata.
*/
saveSqlOnNextSave: proxyMap<string, boolean>([]),
/**
* UI-imposed limit for the number of results a query can return (applied to the SQL query being run if applicable).
* Acts as a safeguard to prevent accidentally taking down the database from a really large SELECT query.
@@ -136,6 +150,10 @@ export const sqlEditorState = proxy({
sqlEditorState.results[snippet.id] = []
sqlEditorState.explainResults[snippet.id] = { rows: [] }
sqlEditorState.savingStates[snippet.id] = 'IDLE'
if (snippet.content && !snippet.isDraftTab) {
sqlEditorState.savedSql[snippet.id] = getSnippetSql(snippet)
}
},
/**
@@ -169,6 +187,7 @@ export const sqlEditorState = proxy({
if (storedSnippet) {
if (!storedSnippet.snippet.content) {
storedSnippet.snippet.content = snippet.content
sqlEditorState.savedSql[snippet.id] = getSnippetSql(snippet)
}
} else {
sqlEditorState.addSnippet({ projectRef: projectRef, snippet })
@@ -176,14 +195,12 @@ export const sqlEditorState = proxy({
},
/**
* Update the snippet content of a snippet and queue for sync saving
* Possibly can consolidate with `updateSnippet` to simplify
* Update the snippet content in local editor state. Saved snippets are persisted only when the
* user explicitly saves; drafts continue to persist their local draft content.
*/
setSql: ({
id,
sql,
shouldInvalidate = false,
skipSave = false,
}: {
id: string
sql: string
@@ -203,11 +220,13 @@ export const sqlEditorState = proxy({
}
return
}
if (!skipSave) sqlEditorState.needsSaving.set(id, shouldInvalidate)
}
},
setSavedSql: (id: string, sql?: string) => {
sqlEditorState.savedSql[id] = sql ?? getSnippetSql(sqlEditorState.snippets[id]?.snippet)
},
/**
* Update snippet in Valtio store after renaming
* Renaming a snippet follows an async saving and hence doesnt require queuing for sync saving here
@@ -242,6 +261,9 @@ export const sqlEditorState = proxy({
const { [id]: explainResult, ...otherExplainResults } = sqlEditorState.explainResults
sqlEditorState.explainResults = otherExplainResults
delete sqlEditorState.savedSql[id]
sqlEditorState.saveSqlOnNextSave.delete(id)
if (!skipSave) sqlEditorState.needsSaving.delete(id)
},
@@ -319,8 +341,9 @@ export const sqlEditorState = proxy({
*/
setLimit: (value: number) => (sqlEditorState.limit = value),
addNeedsSaving: (id: string) => {
addNeedsSaving: (id: string, options?: { saveSql?: boolean }) => {
if (isDraftSqlTabId(id)) return
if (options?.saveSql) sqlEditorState.saveSqlOnNextSave.set(id, true)
sqlEditorState.needsSaving.set(id, true)
},
@@ -447,8 +470,15 @@ async function upsertSnippet(
if (snippet?.content && 'isNotSavedInDatabaseYet' in snippet) {
snippet.isNotSavedInDatabaseYet = false
}
const savedPayloadSql = (payload.content as Partial<SqlSnippets.Content>).unchecked_sql
if (savedPayloadSql !== undefined) {
sqlEditorState.savedSql[id] = savedPayloadSql
}
sqlEditorState.savingStates[id] = 'IDLE'
} catch (error) {
if ((payload.content as Partial<SqlSnippets.Content>).unchecked_sql !== undefined) {
sqlEditorState.saveSqlOnNextSave.set(id, true)
}
sqlEditorState.savingStates[id] = 'UPDATING_FAILED'
}
}
@@ -524,6 +554,9 @@ if (typeof window !== 'undefined') {
content,
favorite,
} = snippet.snippet
const shouldSaveSql =
state.saveSqlOnNextSave.get(id) === true || snippet.snippet.isNotSavedInDatabaseYet
const savedSql = state.savedSql[id]
if (visibility === 'project' && !!folder_id) {
toast.error('Shared snippet cannot be within a folder')
@@ -542,13 +575,17 @@ if (typeof window !== 'undefined') {
folder_id: folder_id ?? undefined,
favorite: favorite ?? false,
content: {
...content!,
...(content as SqlSnippets.Content),
unchecked_sql: shouldSaveSql
? content?.unchecked_sql
: (savedSql ?? content?.unchecked_sql),
content_id: id,
},
} as SqlSnippets.Content,
},
shouldInvalidate
)
sqlEditorState.needsSaving.delete(id)
sqlEditorState.saveSqlOnNextSave.delete(id)
}
} else if (folder) {
upsertFolder(id, folder.projectRef, folder.folder.name)
@@ -45,6 +45,7 @@ export const LOCAL_STORAGE_KEYS = {
SQL_EDITOR_AI_SCHEMA: (ref: string) => `supabase_sql-editor-ai-schema-enabled-${ref}`,
SQL_EDITOR_AI_OPEN: 'supabase_sql-editor-ai-open',
SQL_EDITOR_LAST_SELECTED_DB: (ref: string) => `sql-editor-last-selected-db-${ref}`,
SQL_EDITOR_AUTO_SAVE_SNIPPETS: 'supabase_sql-editor-auto-save-snippets',
// Stores unsaved/untitled SQL editor draft tabs (sql + name) so they survive reloads until explicitly saved
SQL_EDITOR_DRAFT_TABS: (ref: string) => `sql-editor-draft-tabs-${ref}`,
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,