mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
cb21b89899
## 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/<ref>/...`). 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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
|
import { ExternalLink, Eye, EyeOff, FlaskConical } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { ReactNode } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger,
|
|
Badge,
|
|
Button,
|
|
cn,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogSection,
|
|
DialogSectionSeparator,
|
|
DialogTitle,
|
|
ScrollArea,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
|
|
import { AdvisorRulesPreview } from './AdvisorRulesPreview'
|
|
import { CLSPreview } from './CLSPreview'
|
|
import { ExplorerPreview } from './ExplorerPreview'
|
|
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
|
|
import { IntegrationsLayoutPreview } from './IntegrationsLayoutPreview'
|
|
import { JitDbAccessPreview } from './JitDbAccessPreview'
|
|
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
|
|
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
|
|
import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview'
|
|
import { StorageVersioningPreview } from './StorageVersioningPreview'
|
|
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
|
|
import { FeaturePreview, useVisibleFeaturePreviewsByCategory } from './useFeaturePreviews'
|
|
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
|
|
const FEATURE_PREVIEW_KEY_TO_CONTENT: {
|
|
[key: string]: ReactNode
|
|
} = {
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF]: <PgDeltaDiffPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES]: <AdvisorRulesPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: <CLSPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: <UnifiedLogsPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: <JitDbAccessPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: <SqlEditorManualSavePreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: <IntegrationsLayoutPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_EXPLORER]: <ExplorerPreview />,
|
|
[LOCAL_STORAGE_KEYS.UI_PREVIEW_STORAGE_VERSIONING]: <StorageVersioningPreview />,
|
|
}
|
|
|
|
export const FeaturePreviewModal = () => {
|
|
const router = useRouter()
|
|
const { ref } = useParams()
|
|
const { dismissBanner } = useBannerStack()
|
|
const previewsByCategory = useVisibleFeaturePreviewsByCategory()
|
|
const {
|
|
showFeaturePreviewModal,
|
|
selectedFeatureKey,
|
|
selectFeaturePreview,
|
|
toggleFeaturePreviewModal,
|
|
} = useFeaturePreviewModal()
|
|
const featurePreviewContext = useFeaturePreviewContext()
|
|
const track = useTrack()
|
|
|
|
const { flags, onUpdateFlag } = featurePreviewContext
|
|
const allFeaturePreviews = previewsByCategory.flatMap(({ previews }) => previews)
|
|
|
|
const selectedFeature =
|
|
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
|
|
|
|
const toggleFeature = () => {
|
|
if (!selectedFeature) return
|
|
|
|
const isEnabling = !isSelectedFeatureEnabled
|
|
|
|
onUpdateFlag(selectedFeature.key, isEnabling)
|
|
track(isEnabling ? 'feature_preview_enabled' : 'feature_preview_disabled', {
|
|
feature: selectedFeature.key,
|
|
})
|
|
|
|
if (!isEnabling) {
|
|
toast(`${selectedFeature.name} disabled`)
|
|
return
|
|
}
|
|
|
|
if (hasRoute) {
|
|
// Navigating away drops the `featurePreviewModal` query param, which is
|
|
// what closes the modal. Don't also close it via
|
|
// toggleFeaturePreviewModal — its queued nuqs URL update races the push
|
|
// and can navigate back to the current page, swallowing the redirect.
|
|
router.push(selectedFeatureRoute)
|
|
toast.success(`${selectedFeature.name} enabled`, {
|
|
description: "We've taken you to where you can try it out.",
|
|
})
|
|
if (selectedFeature.bannerId) dismissBanner(selectedFeature.bannerId)
|
|
} else {
|
|
toggleFeaturePreviewModal(false)
|
|
toast.success(`${selectedFeature.name} enabled`, {
|
|
description: "It's now active across the dashboard.",
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={showFeaturePreviewModal} onOpenChange={toggleFeaturePreviewModal}>
|
|
<DialogContent size="xlarge" className="flex flex-col max-w-4xl! h-[90dvh] md:h-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Dashboard feature previews</DialogTitle>
|
|
<DialogDescription>Get early access to new features and give feedback</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogSectionSeparator />
|
|
|
|
<DialogSection className="p-0! flex-1 min-h-0 h-full">
|
|
{allFeaturePreviews.length > 0 ? (
|
|
<div className="max-h-full flex-1 min-h-0 h-full flex flex-col gap-y-1 md:gap-y-4 md:flex-row">
|
|
<div>
|
|
<ScrollArea className="hidden md:block h-[550px] w-[280px] border-r">
|
|
<Accordion
|
|
type="multiple"
|
|
defaultValue={previewsByCategory.map(({ category }) => category ?? 'others')}
|
|
>
|
|
{previewsByCategory.map(({ category, previews }) => (
|
|
<AccordionItem key={category ?? 'others'} value={category ?? 'others'}>
|
|
<AccordionTrigger className="text-xs font-mono uppercase tracking-tight px-4 text-foreground-lighter py-2 bg-tertiary dark:bg-transparent">
|
|
{category ?? 'others'}
|
|
</AccordionTrigger>
|
|
<AccordionContent className="[&>div]:pb-0">
|
|
{previews.map((feature) => (
|
|
<FeaturePreviewItem
|
|
key={feature.key}
|
|
feature={feature}
|
|
selectedFeature={selectedFeature}
|
|
selectFeaturePreview={selectFeaturePreview}
|
|
/>
|
|
))}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
|
|
{/* {allFeaturePreviews.map((feature) => (
|
|
<FeaturePreviewItem
|
|
key={feature.key}
|
|
feature={feature}
|
|
selectedFeature={selectedFeature}
|
|
selectFeaturePreview={selectFeaturePreview}
|
|
/>
|
|
))} */}
|
|
</ScrollArea>
|
|
</div>
|
|
<div className="block md:hidden px-4 pt-4">
|
|
<Select
|
|
value={selectedFeature.key}
|
|
onValueChange={selectFeaturePreview}
|
|
defaultValue={selectedFeature.name}
|
|
>
|
|
<SelectTrigger id="feature-preview-select">
|
|
<div className="flex items-center gap-x-2">
|
|
{(flags[selectedFeature.key] ?? false) ? (
|
|
<Eye size={14} strokeWidth={2} className="text-brand" />
|
|
) : (
|
|
<EyeOff size={14} strokeWidth={1.5} className="text-foreground-light" />
|
|
)}
|
|
<p>{selectedFeature.name}</p>
|
|
{selectedFeature.isNew && <Badge variant="success">New</Badge>}
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent className="p-0! [&>div]:w-full! [&>div]:p-0! [&>div]:flex! [&>div]:flex-col! w-full flex">
|
|
{allFeaturePreviews.map((feature) => (
|
|
<SelectItem
|
|
key={feature.key}
|
|
value={feature.key}
|
|
className="p-0 [&>span:nth-child(2)]:w-full [&>span:nth-child(1)]:left-3"
|
|
>
|
|
<FeaturePreviewItem
|
|
feature={feature}
|
|
selectedFeature={selectedFeature}
|
|
selectFeaturePreview={selectFeaturePreview}
|
|
className="pl-10 py-3 bg-transparent"
|
|
/>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="w-full h-auto min-h-0 max-h-auto md:max-h-[550px] p-4 pb-0 flex flex-col">
|
|
<div className="flex items-center justify-between border-b gap-2 pb-3">
|
|
<p>{selectedFeature?.name}</p>
|
|
<div className="flex items-center gap-x-2">
|
|
{selectedFeature?.discussionsUrl !== undefined && (
|
|
<Button asChild icon={<ExternalLink strokeWidth={1.5} />}>
|
|
<Link
|
|
href={selectedFeature.discussionsUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Give feedback
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
{isSelectedFeatureEnabled && (
|
|
<ButtonTooltip
|
|
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
|
|
</ButtonTooltip>
|
|
)}
|
|
{!isSelectedFeatureEnabled && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button onClick={() => toggleFeature()}>Enable feature</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-64 text-center">
|
|
{hasRoute
|
|
? 'Enables the feature and takes you to where you can try it out'
|
|
: 'Enables this preview across the dashboard'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-scroll pt-3 pb-4">
|
|
{FEATURE_PREVIEW_KEY_TO_CONTENT[selectedFeature?.key ?? '']}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-[550px] flex flex-col items-center justify-center">
|
|
<FlaskConical size={30} strokeWidth={1.5} className="text-foreground-light" />
|
|
<div className="mt-1 mb-3 flex flex-col items-center gap-y-0.5">
|
|
<p className="text-sm">No feature previews available</p>
|
|
<p className="text-sm text-foreground-light">
|
|
Have an idea for the dashboard? Let us know via GitHub Discussions!
|
|
</p>
|
|
</div>
|
|
<Button asChild icon={<ExternalLink strokeWidth={1.5} />}>
|
|
<Link
|
|
href="https://github.com/orgs/supabase/discussions/categories/feature-requests"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
GitHub Discussions
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogSection>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
const FeaturePreviewItem = ({
|
|
feature,
|
|
selectedFeature,
|
|
selectFeaturePreview,
|
|
className,
|
|
}: {
|
|
feature: FeaturePreview
|
|
selectedFeature: FeaturePreview
|
|
selectFeaturePreview: (key: string) => void
|
|
className?: string
|
|
}) => {
|
|
const featurePreviewContext = useFeaturePreviewContext()
|
|
const { flags } = featurePreviewContext
|
|
const isEnabled = flags[feature.key] ?? false
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
tabIndex={0}
|
|
key={feature.key}
|
|
onClick={() => selectFeaturePreview(feature.key)}
|
|
className={cn(
|
|
'w-full! flex-1 flex items-center justify-between p-4 cursor-pointer bg transition',
|
|
selectedFeature?.key === feature.key
|
|
? 'bg-muted dark:bg-accent text-foreground'
|
|
: 'bg-card text-foreground-light',
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-x-3">
|
|
{isEnabled ? (
|
|
<Eye size={14} strokeWidth={2} className="text-brand" />
|
|
) : (
|
|
<EyeOff size={14} strokeWidth={1.5} className="text-foreground-light" />
|
|
)}
|
|
<p className="text-sm truncate" title={feature.name}>
|
|
{feature.name}
|
|
</p>
|
|
</div>
|
|
{feature.isNew && <Badge variant="success">New</Badge>}
|
|
</button>
|
|
)
|
|
}
|