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]) +}