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 ? ( - - ) : ( + + )} + {!isSelectedFeatureEnabled && ( + + + + ) +} 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,