diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index a0aaea6f28e..557c0e1327e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -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 = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index c6134343abb..24ad9662f92 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -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 = () => { )} - {isSelectedFeatureEnabled ? ( - toggleFeature()}> + {isSelectedFeatureEnabled && ( + 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 - - ) : ( + + )} + {!isSelectedFeatureEnabled && ( toggleFeature()}> diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx index 31d24422693..11b2a4c8813 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx @@ -12,6 +12,11 @@ export const SqlEditorManualSavePreview = () => { Let you save with Cmd+S at any time + + 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. + ) } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index da9c80cf2ba..3587e6b175b 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -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, ]) } diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx new file mode 100644 index 00000000000..fbbf96cff05 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.tsx @@ -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 ( + !open && setIsNoticeDismissed(true)}> + + + Snippets no longer save automatically + + The SQL Editor now saves your snippets only when you save them. + + + + + + + + + Save with the Save button next to Run, or with{' '} + + + Tabs with unsaved edits show a dot + Closing a tab with unsaved edits discards them + + + + + setIsNoticeDismissed(true)}>Understood + + + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts new file mode 100644 index 00000000000..3b68fef5667 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.test.ts @@ -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 + ) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts new file mode 100644 index 00000000000..86556c08bba --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorManualSaveNoticeDialog.utils.ts @@ -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 + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx index abff6c14b7a..2391bdc4400 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx @@ -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] diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index 6ce9d4ddad2..f97145a1a25 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -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} + > ) } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 39f7bf1bd8a..edf85c0a1f8 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -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,
+ 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. +