mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
optional auto saving
This commit is contained in:
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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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}`,
|
||||
|
||||
Reference in new issue
Block a user