mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(sql-editor): roll out manual saving by default (#48706)
## 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? Feature — progresses the SQL Editor manual saving rollout, and removes a fully rolled out feature flag. ## What is the current behavior? Manual saving (snippets save only on an explicit Save / Cmd+S rather than autosaving every edit) is opt-in. It requires both the `sqlEditorManualSave` ConfigCat flag and the user turning on the "Disable snippet auto-saving" feature preview themselves. That flag is now fully rolled out. ## What is the new behavior? - `sqlEditorManualSave` is removed, so the feature preview is listed for everyone. (Delete the flag in ConfigCat after a few months.) - New `sqlEditorManualSaveForced` flag progresses the rollout. It forces manual saving on regardless of what the user stored previously, including an explicit opt-out, via a new `isForced` field on `FeaturePreview` that `initializeFlags` resolves ahead of the localStorage lookup — so the feature preview modal reflects the forced state too, not just the save behavior. Turning the flag off reverts everyone who never opted in, so it remains a working kill switch. - Users the rollout switches over get a one-time dialog on their first SQL Editor route, explaining that snippets no longer autosave. Dismissal persists in `sql-editor-manual-save-notice-dismissed` (allowlisted, so it survives sign-out). - Users who opted into the preview themselves never see the dialog — it records their dismissal up front instead, since the notice needs to outlive the feature preview and once the preview is retired there's no stored opt-in left to recognize them by. - The preview keeps its switch so users who lose their local storage can opt in early, but once the rollout reaches them the "Disable feature" button is disabled with a tooltip explaining why. ### To test Turn on `sqlEditorManualSaveForced` on via the dev toolbar. - No `supabase-ui-sql-editor-manual-save` and no dismissal key → dialog appears on `/project/<ref>/sql`, toolbar shows the Save button. Dismiss, reload → no dialog. - `supabase-ui-sql-editor-manual-save` set to `false` (previously opted out) → still forced onto manual saving, and still gets the dialog. - `supabase-ui-sql-editor-manual-save` set to `true` → no dialog, and the dismissal key is written automatically. - Flag off, no opt-in → autosave, no dialog, and the "Disable autosave" power-off button still opens the preview modal. - Preview modal while forced → "Disable feature" is disabled with a tooltip; another preview (e.g. Column-level privileges) still disables normally. ## Additional context
This commit is contained in:
1 parent
e7d9c88cbc
commit
0791b04eb8
10 files changed
+221
-17
No files matched your search
@@ -49,6 +49,14 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) =
|
||||
return { ...a, [b.key]: false }
|
||||
}
|
||||
|
||||
// A forced preview has become the default behavior, so it's on whatever
|
||||
// the user stored previously — including an explicit opt-out. Applied
|
||||
// here rather than in the individual `useIsXEnabled` helpers so that the
|
||||
// feature preview modal reflects it too.
|
||||
if (b.isForced) {
|
||||
return { ...a, [b.key]: true }
|
||||
}
|
||||
|
||||
const defaultOptIn = b.isDefaultOptIn
|
||||
const localStorageValue = safeLocalStorage.getItem(b.key)
|
||||
return {
|
||||
@@ -127,10 +135,14 @@ export const useIsJitDbAccessEnabled = () => {
|
||||
return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SQL Editor saves snippets only on request. True when the user
|
||||
* opted into the preview themselves *or* the `sqlEditorManualSaveForced` rollout
|
||||
* has reached them — the preview's `isForced` flag folds the latter into `flags`.
|
||||
*/
|
||||
export const useIsSqlEditorManualSaveEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
|
||||
return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
|
||||
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
|
||||
}
|
||||
|
||||
export const useIsMarketplaceEnabled = () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview'
|
||||
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
|
||||
import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews'
|
||||
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
@@ -83,6 +84,7 @@ export const FeaturePreviewModal = () => {
|
||||
allFeaturePreviews.find((preview) => preview.key === selectedFeatureKey) ??
|
||||
allFeaturePreviews[0]
|
||||
const isSelectedFeatureEnabled = flags[selectedFeature?.key]
|
||||
const canDisableSelectedFeature = selectedFeature?.isForced !== true
|
||||
|
||||
const selectedFeatureRoute = selectedFeature?.getRoute?.(ref)
|
||||
const hasRoute = selectedFeatureRoute !== undefined && ref !== undefined
|
||||
@@ -229,11 +231,25 @@ export const FeaturePreviewModal = () => {
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{isSelectedFeatureEnabled ? (
|
||||
<Button variant="default" onClick={() => toggleFeature()}>
|
||||
{isSelectedFeatureEnabled && (
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
disabled={!canDisableSelectedFeature}
|
||||
onClick={() => toggleFeature()}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
className: 'max-w-64 text-center',
|
||||
text: canDisableSelectedFeature
|
||||
? undefined
|
||||
: 'This feature is now the default and can no longer be turned off',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Disable feature
|
||||
</Button>
|
||||
) : (
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
{!isSelectedFeatureEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="default" onClick={() => toggleFeature()}>
|
||||
|
||||
@@ -12,6 +12,11 @@ export const SqlEditorManualSavePreview = () => {
|
||||
<li>Let you save with Cmd+S at any time</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Manual saving is becoming the default for the SQL Editor. This preview can no longer be
|
||||
turned off. To opt into manual saving before the change reaches your account, enable this
|
||||
preview.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export type FeaturePreview = {
|
||||
isDefaultOptIn: boolean
|
||||
/** Visibility in the feature preview modal (For feature flagging a feature preview) */
|
||||
enabled: boolean
|
||||
/**
|
||||
* Forces the preview on for this user, whatever they previously chose — for a
|
||||
* preview that has become the default behavior. Overrides both `isDefaultOptIn`
|
||||
* and a stored opt-out, and takes away the ability to turn the preview back off.
|
||||
*/
|
||||
isForced?: boolean
|
||||
/** Optional category that the feature preview falls under, defaults to "Others" in the UI otherwise */
|
||||
category?: 'observability' | 'database'
|
||||
/**
|
||||
@@ -28,10 +34,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
const isPlatformWebhooksEnabled = useFlag('platformWebhooks')
|
||||
const jitDbAccessEnabled = useFlag('jitDbAccess')
|
||||
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
|
||||
const isSqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
|
||||
const isDatabaseConnectionsEnabled = useFlag('topForPostgres')
|
||||
|
||||
const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn')
|
||||
const isSqlEditorManualSaveForced = useFlag('sqlEditorManualSaveForced')
|
||||
|
||||
return useMemo(() => {
|
||||
const previews: FeaturePreview[] = [
|
||||
@@ -114,7 +120,11 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
isNew: true,
|
||||
isPlatformOnly: true,
|
||||
isDefaultOptIn: false,
|
||||
enabled: isSqlEditorManualSaveEnabled,
|
||||
enabled: true,
|
||||
// Manual saving is becoming the default for the SQL Editor. The preview
|
||||
// stays listed so users who lose their local storage can opt back in
|
||||
// before the rollout reaches them.
|
||||
isForced: isSqlEditorManualSaveForced,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_DATABASE_CONNECTIONS,
|
||||
@@ -133,10 +143,10 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
return previews.sort((a, b) => Number(b.isNew) - Number(a.isNew))
|
||||
}, [
|
||||
unifiedLogsDefaultOptIn,
|
||||
isSqlEditorManualSaveForced,
|
||||
isPlatformWebhooksEnabled,
|
||||
jitDbAccessEnabled,
|
||||
isMarketplaceEnabled,
|
||||
isSqlEditorManualSaveEnabled,
|
||||
isDatabaseConnectionsEnabled,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { LOCAL_STORAGE_KEYS, safeLocalStorage, useFlag } from 'common'
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
KeyboardShortcut,
|
||||
} from 'ui'
|
||||
|
||||
import { shouldShowManualSaveNotice } from './SqlEditorManualSaveNoticeDialog.utils'
|
||||
import { useFeaturePreviewContext } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* One-time notice for users the manual saving rollout switches over, shown the
|
||||
* first time they land on a SQL Editor route. Users who opted into the preview
|
||||
* themselves already know the behavior and never see it.
|
||||
*/
|
||||
export const SqlEditorManualSaveNoticeDialog = () => {
|
||||
const { isInitialized } = useFeaturePreviewContext()
|
||||
const isForced = useFlag('sqlEditorManualSaveForced')
|
||||
|
||||
const [isNoticeDismissed, setIsNoticeDismissed, { isSuccess: isDismissalLoaded }] =
|
||||
useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED, false)
|
||||
|
||||
const hasOptedIntoPreview =
|
||||
safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE) === 'true'
|
||||
|
||||
// Record the dismissal up front for users who opted in themselves. The notice
|
||||
// outlives the feature preview — once the preview is retired there's no stored
|
||||
// opt-in left to recognize them by, and without this they'd be shown a notice
|
||||
// about behavior they chose.
|
||||
useEffect(() => {
|
||||
if (isDismissalLoaded && hasOptedIntoPreview && !isNoticeDismissed) {
|
||||
setIsNoticeDismissed(true)
|
||||
}
|
||||
}, [isDismissalLoaded, hasOptedIntoPreview, isNoticeDismissed, setIsNoticeDismissed])
|
||||
|
||||
const isOpen =
|
||||
isDismissalLoaded &&
|
||||
shouldShowManualSaveNotice({
|
||||
isPlatform: IS_PLATFORM,
|
||||
isFeaturePreviewInitialized: isInitialized,
|
||||
isForced,
|
||||
hasOptedIntoPreview,
|
||||
isNoticeDismissed,
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && setIsNoticeDismissed(true)}>
|
||||
<DialogContent size="medium">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Snippets no longer save automatically</DialogTitle>
|
||||
<DialogDescription>
|
||||
The SQL Editor now saves your snippets only when you save them.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<DialogSection className="text-sm">
|
||||
<ul className="list-disc pl-6 text-foreground-light space-y-1">
|
||||
<li>
|
||||
Save with the Save button next to Run, or with{' '}
|
||||
<KeyboardShortcut keys={['Meta', 's']} />
|
||||
</li>
|
||||
<li>Tabs with unsaved edits show a dot</li>
|
||||
<li>Closing a tab with unsaved edits discards them</li>
|
||||
</ul>
|
||||
</DialogSection>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setIsNoticeDismissed(true)}>Understood</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
shouldShowManualSaveNotice,
|
||||
type ShouldShowManualSaveNoticeParams,
|
||||
} from './SqlEditorManualSaveNoticeDialog.utils'
|
||||
|
||||
const eligible: ShouldShowManualSaveNoticeParams = {
|
||||
isPlatform: true,
|
||||
isFeaturePreviewInitialized: true,
|
||||
isForced: true,
|
||||
hasOptedIntoPreview: false,
|
||||
isNoticeDismissed: false,
|
||||
}
|
||||
|
||||
describe('shouldShowManualSaveNotice', () => {
|
||||
it('shows the notice to a user the rollout switches over', () => {
|
||||
expect(shouldShowManualSaveNotice(eligible)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show the notice to a user who opted into the preview themselves', () => {
|
||||
expect(shouldShowManualSaveNotice({ ...eligible, hasOptedIntoPreview: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show the notice once dismissed', () => {
|
||||
expect(shouldShowManualSaveNotice({ ...eligible, isNoticeDismissed: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show the notice when the rollout flag is off', () => {
|
||||
expect(shouldShowManualSaveNotice({ ...eligible, isForced: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show the notice outside of the hosted platform', () => {
|
||||
expect(shouldShowManualSaveNotice({ ...eligible, isPlatform: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show the notice before the feature previews have initialized', () => {
|
||||
expect(shouldShowManualSaveNotice({ ...eligible, isFeaturePreviewInitialized: false })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface ShouldShowManualSaveNoticeParams {
|
||||
/** Manual saving is a platform-only feature preview */
|
||||
isPlatform: boolean
|
||||
/** Whether the feature preview flags reflect loaded values rather than pre-load defaults */
|
||||
isFeaturePreviewInitialized: boolean
|
||||
/** Whether the rollout flag forces manual saving on for this user */
|
||||
isForced: boolean
|
||||
/** Whether the user explicitly enabled the manual save preview themselves */
|
||||
hasOptedIntoPreview: boolean
|
||||
isNoticeDismissed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the one-time notice explaining that snippets no longer
|
||||
* autosave.
|
||||
*
|
||||
* Everyone the rollout switches over needs telling — including users who
|
||||
* explicitly opted *out* of the preview, since the rollout overrides that
|
||||
* choice. Only users who turned manual saving on themselves already know what
|
||||
* the behavior is.
|
||||
*/
|
||||
export function shouldShowManualSaveNotice({
|
||||
isPlatform,
|
||||
isFeaturePreviewInitialized,
|
||||
isForced,
|
||||
hasOptedIntoPreview,
|
||||
isNoticeDismissed,
|
||||
}: ShouldShowManualSaveNoticeParams): boolean {
|
||||
return (
|
||||
isPlatform &&
|
||||
isFeaturePreviewInitialized &&
|
||||
isForced &&
|
||||
!hasOptedIntoPreview &&
|
||||
!isNoticeDismissed
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { PowerOff } from 'lucide-react'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
@@ -19,13 +19,7 @@ export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
|
||||
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
|
||||
const { selectFeaturePreview } = useFeaturePreviewModal()
|
||||
|
||||
// Whether the manual-save preview can actually be opted into. Mirrors the
|
||||
// feature preview modal's own filter (platform-only + ConfigCat flag), so we
|
||||
// don't offer to "disable autosave" when there's no preview to switch to.
|
||||
// `isManualSaveEnabled` also being false for self-hosted / flag-off users is
|
||||
// why it can't gate this affordance.
|
||||
const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave')
|
||||
const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag
|
||||
const canEnableManualSave = IS_PLATFORM
|
||||
|
||||
if (isManualSaveEnabled) {
|
||||
const snippet = snapV2.snippets[id]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { OngoingQueriesPanel } from '@/components/interfaces/SQLEditor/OngoingQueriesPanel'
|
||||
import { SqlEditorManualSaveNoticeDialog } from '@/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog'
|
||||
import { withAuth } from '@/hooks/misc/withAuth'
|
||||
|
||||
export interface SQLEditorLayoutProps {
|
||||
@@ -12,6 +13,7 @@ const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => {
|
||||
<>
|
||||
{children}
|
||||
<OngoingQueriesPanel />
|
||||
<SqlEditorManualSaveNoticeDialog />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,
|
||||
SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`,
|
||||
SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`,
|
||||
SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED: 'sql-editor-manual-save-notice-dismissed',
|
||||
|
||||
LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size',
|
||||
GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>
|
||||
@@ -158,6 +159,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS,
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_MANUAL_SAVE_NOTICE_DISMISSED,
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
|
||||
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
|
||||
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
|
||||
|
||||
Reference in new issue
Block a user