From cb21b89899ce0ee5b87ca5579916c5eeb7359ad7 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:52:15 +0300 Subject: [PATCH] feat: make feature previews easier to discover (#50086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature previews are only discoverable if you already know to look for the "Feature previews" item under the account profile dropdown. There's no way to find or toggle them from Cmd+K. Fixes: [FE-4305](https://linear.app/supabase/issue/FE-4305/make-feature-previews-easier-to-discover). ## What is the new behavior? - Adds a "Feature previews..." command to Cmd+K that opens a drill-down page listing all available previews, grouped by category (mirroring the existing settings modal's grouping via a new shared useVisibleFeaturePreviewsByCategory hook, so the two can't drift apart). - Each preview can be toggled on/off directly from the list, with a "New" badge for new previews and a "Default" badge + hint for previews that can no longer be turned off. - Each preview also has a hidden "View details" command (surfaces via search) that opens the full settings modal to that preview's description/discussion link. ## Heads up for reviewers/testing Enabling a feature preview only navigates to its page when you're already within a project route in the URL (`/project//...`). In the preview environment, there’s no project in scope, so enabling the feature still works and updates the flag, but it won't navigate to the feature's project-specific page. ## Summary by CodeRabbit - **New Features** - Added feature preview pages and actions to the command menu. - Feature previews are organized by category for easier browsing. - Enabling a preview with a dedicated page takes you directly to that page. - Preview activation and deactivation now provide status notifications. - **Bug Fixes** - Preview toggles and project navigation no longer unexpectedly return users to the root command menu. - Only relevant, available previews are shown based on the current environment. --- .../App/CommandMenu/CommandMenu.tsx | 2 + .../App/CommandMenu/FeaturePreviews.tsx | 158 ++++++++++++++++++ .../FeaturePreview/FeaturePreviewContext.tsx | 3 +- .../FeaturePreview/FeaturePreviewModal.tsx | 58 +++---- .../App/FeaturePreview/useFeaturePreviews.ts | 28 ++++ 5 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 apps/studio/components/interfaces/App/CommandMenu/FeaturePreviews.tsx diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx index 41be54b5fc9..73865eb4e1b 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx @@ -14,6 +14,7 @@ import { useApiKeysCommands } from './ApiKeys' import { useApiUrlCommand } from './ApiUrl' import { useContextSearchCommands } from './ContextSearchCommands' import { useCreateCommands } from './CreateCommands' +import { useFeaturePreviewCommands } from './FeaturePreviews' import { orderCommandSectionsByPriority } from './ordering' import { useConfigureOrganizationCommand, useProjectSwitchCommand } from './OrgProjectSwitcher' import { useSupportCommands } from './Support' @@ -59,6 +60,7 @@ export default function StudioCommandMenu() { useChangelogCommand({ enabled: IS_PLATFORM }) useThemeSwitcherCommands() useContextSearchCommands() + useFeaturePreviewCommands() return ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/FeaturePreviews.tsx b/apps/studio/components/interfaces/App/CommandMenu/FeaturePreviews.tsx new file mode 100644 index 00000000000..095d0c42cdb --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/FeaturePreviews.tsx @@ -0,0 +1,158 @@ +import { useParams } from 'common' +import { Check, ExternalLink, FlaskConical, Square } from 'lucide-react' +import { useRouter } from 'next/router' +import { useMemo } from 'react' +import { useLatest } from 'react-use' +import { toast } from 'sonner' +import { Badge } from 'ui' +import { + PageType, + useRegisterCommands, + useRegisterPage, + useSetCommandMenuOpen, + useSetPage, +} from 'ui-patterns/CommandMenu' + +import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' +import { + useFeaturePreviewContext, + useFeaturePreviewModal, +} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + FeaturePreview, + useVisibleFeaturePreviewsByCategory, +} from '@/components/interfaces/App/FeaturePreview/useFeaturePreviews' +import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' + +const FEATURE_PREVIEWS_PAGE_NAME = 'Feature previews' + +export function useFeaturePreviewCommands() { + const router = useRouter() + const { ref } = useParams() + const { dismissBanner } = useBannerStack() + const setPage = useSetPage() + const setIsOpen = useSetCommandMenuOpen() + const { flags, onUpdateFlag } = useFeaturePreviewContext() + const { selectFeaturePreview, toggleFeaturePreviewModal } = useFeaturePreviewModal() + + const openFeaturePreviewDetails = (key: string) => { + selectFeaturePreview(key) + toggleFeaturePreviewModal(true) + setIsOpen(false) + } + + // Registering this page tears it down and re-adds it (popping it off the + // page stack if it's currently open) whenever `deps` changes identity, so + // `flags` is read through a ref instead of being a dep — toggling a preview + // must not kick the user back to the root menu. Same reasoning applies to + // `ref`/`pathname`: the command's `action` closure is only recreated when + // `previewsByCategory` changes identity, so without reading these through a + // ref too, navigating between projects (or to/from the org view) after that + // page was first registered would keep routing to whatever project was + // current back then. + const flagsRef = useLatest(flags) + const routeContextRef = useLatest({ ref, pathname: router.pathname }) + + // Mirrors FeaturePreviewModal's toggleFeature: if the preview has a route to + // try it out in, enabling it takes the user there instead of just flipping + // the flag silently. + const toggleFeaturePreview = (preview: FeaturePreview) => { + if (preview.isForced) return + + const isEnabling = !flagsRef.current[preview.key] + onUpdateFlag(preview.key, isEnabling) + + if (!isEnabling) { + toast(`${preview.name} disabled`) + return + } + + // Only route into a project when actually on a project-scoped page — + // `ref` alone isn't enough, since it can retain a stale value after + // client-side navigating to a route without a `ref` segment (e.g. the org + // view). + const { ref: currentRef, pathname } = routeContextRef.current + const isProjectScopedRoute = pathname.startsWith('/project/') + const route = isProjectScopedRoute ? preview.getRoute?.(currentRef) : undefined + if (route !== undefined && currentRef !== undefined) { + setIsOpen(false) + router.push(route) + toast.success(`${preview.name} enabled`, { + description: "We've taken you to where you can try it out.", + }) + if (preview.bannerId) dismissBanner(preview.bannerId) + } else { + toast.success(`${preview.name} enabled`, { + description: "It's now active across the dashboard.", + }) + } + } + + // Grouped identically to the feature preview modal's category accordion — + // shared hook, so the two can never list a different set of previews. + const previewsByCategory = useVisibleFeaturePreviewsByCategory() + + const visiblePreviews = useMemo( + () => previewsByCategory.flatMap(({ previews }) => previews), + [previewsByCategory] + ) + + useRegisterPage( + FEATURE_PREVIEWS_PAGE_NAME, + { + type: PageType.Commands, + sections: previewsByCategory + .filter(({ previews }) => previews.length > 0) + .map(({ category, previews }) => ({ + id: `feature-previews-${category ?? 'others'}`, + name: category ?? 'Others', + commands: previews.flatMap((preview) => [ + { + id: `feature-preview-${preview.key}`, + name: preview.name, + value: preview.isForced + ? `${preview.name}, Feature preview, now the default, can't be turned off` + : `${preview.name}, Feature preview, Toggle ${preview.name}`, + action: () => toggleFeaturePreview(preview), + icon: () => (flagsRef.current[preview.key] ? : ), + badge: preview.isForced + ? () => ( + + Default + + ) + : preview.isNew + ? () => New + : undefined, + }, + { + id: `feature-preview-${preview.key}-details`, + name: `${preview.name}: View details`, + value: `${preview.name}, About, Learn more, Description, Feedback, Discussion`, + action: () => openFeaturePreviewDetails(preview.key), + icon: () => , + defaultHidden: true, + }, + ]), + })), + }, + { deps: [previewsByCategory], enabled: visiblePreviews.length > 0 } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.ACTIONS, + [ + { + id: 'feature-previews', + name: 'Feature previews...', + value: 'Feature previews, Try new features, Early access, Opt in, Opt out, Beta features', + action: () => setPage(FEATURE_PREVIEWS_PAGE_NAME), + icon: () => , + }, + ], + { enabled: visiblePreviews.length > 0 } + ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 0feabf99355..e96829ffa56 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -81,8 +81,7 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = isInitialized, onUpdateFlag: (key: string, value: boolean) => { safeLocalStorage.setItem(key, value ? 'true' : 'false') - const updatedFlags = { ...flags, [key]: value } - setFlags(updatedFlags) + setFlags((prevFlags) => ({ ...prevFlags, [key]: value })) }, } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index f28d68d1a8b..05aa24556cb 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -40,10 +40,9 @@ import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview' import { StorageVersioningPreview } from './StorageVersioningPreview' import { UnifiedLogsPreview } from './UnifiedLogsPreview' -import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews' +import { FeaturePreview, useVisibleFeaturePreviewsByCategory } 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' const FEATURE_PREVIEW_KEY_TO_CONTENT: { @@ -65,7 +64,7 @@ export const FeaturePreviewModal = () => { const router = useRouter() const { ref } = useParams() const { dismissBanner } = useBannerStack() - const featurePreviews = useFeaturePreviews() + const previewsByCategory = useVisibleFeaturePreviewsByCategory() const { showFeaturePreviewModal, selectedFeatureKey, @@ -76,9 +75,7 @@ export const FeaturePreviewModal = () => { const track = useTrack() const { flags, onUpdateFlag } = featurePreviewContext - const allFeaturePreviews = ( - IS_PLATFORM ? featurePreviews : featurePreviews.filter((x) => !x.isPlatformOnly) - ).filter((x) => x.enabled) + const allFeaturePreviews = previewsByCategory.flatMap(({ previews }) => previews) const selectedFeature = allFeaturePreviews.find((preview) => preview.key === selectedFeatureKey) ?? @@ -89,10 +86,6 @@ export const FeaturePreviewModal = () => { const selectedFeatureRoute = selectedFeature?.getRoute?.(ref) const hasRoute = selectedFeatureRoute !== undefined && ref !== undefined - const categories = ( - [...new Set(allFeaturePreviews.map((preview) => preview.category).filter(Boolean))] as string[] - ).concat(['others']) - const toggleFeature = () => { if (!selectedFeature) return @@ -141,30 +134,27 @@ export const FeaturePreviewModal = () => {
- - {categories.map((category) => { - const items = - category === 'others' - ? allFeaturePreviews.filter((x) => x.category === undefined) - : allFeaturePreviews.filter((x) => x.category === category) - return ( - - - {category} - - - {items.map((feature) => ( - - ))} - - - ) - })} + category ?? 'others')} + > + {previewsByCategory.map(({ category, previews }) => ( + + + {category ?? 'others'} + + + {previews.map((feature) => ( + + ))} + + + ))} {/* {allFeaturePreviews.map((feature) => ( diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index f73863054e2..3f79e31bfb9 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -2,6 +2,7 @@ import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { useMemo } from 'react' import { type BannerId } from '@/components/ui/BannerStack/BannerStackProvider' +import { IS_PLATFORM } from '@/lib/constants' export type FeaturePreview = { key: string @@ -160,3 +161,30 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isStorageVersioningEnabled, ]) } + +export type FeaturePreviewCategoryGroup = { + category: FeaturePreview['category'] + previews: FeaturePreview[] +} + +/** + * The visible feature previews (respecting platform-only/enabled gating), + * grouped by category in first-seen order, uncategorized previews last as a + * final `category: undefined` group. Shared by the feature preview modal and + * the Cmd+K "Feature previews" page so both list the exact same previews. + */ +export const useVisibleFeaturePreviewsByCategory = (): FeaturePreviewCategoryGroup[] => { + const featurePreviews = useFeaturePreviews() + + return useMemo(() => { + const previews = ( + IS_PLATFORM ? featurePreviews : featurePreviews.filter((preview) => !preview.isPlatformOnly) + ).filter((preview) => preview.enabled) + const categories = [...new Set(previews.map((preview) => preview.category).filter(Boolean))] + + return categories.concat(undefined).map((category) => ({ + category, + previews: previews.filter((preview) => preview.category === category), + })) + }, [featurePreviews]) +}