chore(studio): ship connect section, remove getting started and experiment plumbing (#44329)

## Summary

The `connectSection` A/B experiment concluded as a true null (no effect
on activation or any downstream metric after 13 days at 50/50, ~153K
mature orgs). Saxon decided to ship the Connect section as the permanent
experience. This PR removes the Getting Started control variant, the old
Connect modal, all experiment flag gating, and related telemetry types.

## Changes

- Delete `GettingStarted/` directory (5 files: section component, types,
utils, progress hook)
- Delete old `Connect.tsx` dialog modal (replaced by ConnectSheet)
- Remove `connectSection` PostHog flag reads from `Home.tsx` and
`LayoutHeader.tsx`
- Remove `getSectionVisibility()` experiment logic and
`ConnectSectionVariant` type
- Remove `getting-started` from `DEFAULT_SECTION_ORDER`
- Always render `<ConnectSheet />` in header (no more conditional with
old `<Connect />` modal)
- Remove `variant` prop from `ConnectSection` component
- Remove 4 getting-started telemetry event interfaces from
`telemetry-constants.ts`
- Update `mergeSectionOrder` tests to reflect new section order

## Testing

Tested on Vercel preview:
- [x] Project homepage shows Connect section for new projects (< 10 days
old)
- [x] Connect section hidden for mature projects (> 10 days old)
- [x] Header Connect button opens ConnectSheet (not old modal)
- [x] Connect tiles open ConnectSheet with correct tab
- [x] Section drag-and-drop still works without getting-started in the
order
- [x] Existing users with `getting-started` in localStorage order don't
break (mergeSectionOrder strips it)

## Linear

- fixes GROWTH-730

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
Pamela Chia
2026-03-30 20:51:09 +08:00
committed by GitHub
parent 7bbd7060d3
commit edacf2413d
16 changed files with 46 additions and 1878 deletions

View File

@@ -1,492 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { ApiKeysTabContent } from 'components/interfaces/Connect/ApiKeysTabContent'
import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString'
import { McpTabContent } from 'components/interfaces/Connect/McpTabContent'
import Panel from 'components/ui/Panel'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { BASE_PATH } from 'lib/constants'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import {
Button,
cn,
Dialog,
DIALOG_PADDING_X,
DIALOG_PADDING_Y,
DialogContent,
DialogDescription,
DialogHeader,
DialogSectionSeparator,
DialogTitle,
Tabs_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
} from 'ui'
import { CONNECTION_TYPES, ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './Connect.constants'
import { getContentFilePath, inferConnectTabFromParentKey } from './Connect.utils'
import { ConnectDropdown } from './ConnectDropdown'
import { ConnectTabContent } from './ConnectTabContent'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
export const Connect = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const {
projectConnectionShowAppFrameworks: showAppFrameworks,
projectConnectionShowMobileFrameworks: showMobileFrameworks,
projectConnectionShowOrms: showOrms,
} = useIsFeatureEnabled([
'project_connection:show_app_frameworks',
'project_connection:show_mobile_frameworks',
'project_connection:show_orms',
])
const connectionTypes = CONNECTION_TYPES.filter(({ key }) => {
if (key === 'frameworks') {
return showAppFrameworks
}
if (key === 'mobiles') {
return showMobileFrameworks
}
if (key === 'orms') {
return showOrms
}
return true
})
// helper to get the connection type object
function getConnectionObjectForTab(tab: string | null) {
switch (tab) {
case 'frameworks':
return FRAMEWORKS
case 'mobiles':
return MOBILES
case 'orms':
return ORMS
default:
return FRAMEWORKS
}
}
const [open, setOpen] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const [tab, setTab] = useQueryState('connectTab', parseAsString.withDefault('direct'))
const [queryFramework, setQueryFramework] = useQueryState('framework', parseAsString)
const [queryUsing, setQueryUsing] = useQueryState('using', parseAsString)
const [queryWith, setQueryWith] = useQueryState('with', parseAsString)
const [, setQueryType] = useQueryState('type', parseAsString)
const [, setQuerySource] = useQueryState('source', parseAsString)
const [, setQueryMethod] = useQueryState('method', parseAsString)
const [connectionObject, setConnectionObject] = useState<ConnectionType[]>(FRAMEWORKS)
const [selectedParent, setSelectedParent] = useState(connectionObject[0].key) // aka nextjs
const [selectedChild, setSelectedChild] = useState(
connectionObject.find((item) => item.key === selectedParent)?.children[0]?.key ?? ''
)
const [selectedGrandchild, setSelectedGrandchild] = useState(
connectionObject
.find((item) => item.key === selectedParent)
?.children.find((child) => child.key === selectedChild)?.children[0]?.key || ''
)
const { data: resolvedEndpoint } = useProjectApiUrl({ projectRef })
const { can: canReadAPIKeys } = useAsyncCheckPermissions(
PermissionAction.READ,
'service_api_keys'
)
const handleParentChange = (value: string) => {
setSelectedParent(value)
setQueryFramework(value)
const parent = connectionObject.find((item) => item.key === value)
const firstChild = parent?.children?.[0]
if (firstChild) {
setSelectedChild(firstChild.key)
setQueryUsing(firstChild.key)
const firstGrandchild = firstChild.children?.[0]
if (firstGrandchild) {
setSelectedGrandchild(firstGrandchild.key)
setQueryWith(firstGrandchild.key)
} else {
setSelectedGrandchild('')
setQueryWith(null)
}
} else {
setSelectedChild('')
setQueryUsing(null)
setSelectedGrandchild('')
setQueryWith(null)
}
}
const handleChildChange = (value: string) => {
setSelectedChild(value)
setQueryUsing(value)
const parent = connectionObject.find((item) => item.key === selectedParent)
const child = parent?.children.find((child) => child.key === value)
const firstGrandchild = child?.children?.[0]
if (firstGrandchild) {
setSelectedGrandchild(firstGrandchild.key)
setQueryWith(firstGrandchild.key)
} else {
setSelectedGrandchild('')
setQueryWith(null)
}
}
const handleGrandchildChange = (value: string) => {
setSelectedGrandchild(value)
if (value) {
setQueryWith(value)
} else {
setQueryWith(null)
}
}
// reset the parent/child/grandchild when the connection type (tab) changes
function handleConnectionTypeChange(connections: ConnectionType[]) {
setSelectedParent(connections[0].key)
if (connections[0]?.children.length > 0) {
setSelectedChild(connections[0].children[0].key)
if (connections[0].children[0]?.children.length > 0) {
setSelectedGrandchild(connections[0].children[0].children[0].key)
} else {
setSelectedGrandchild('')
}
} else {
setSelectedChild('')
setSelectedGrandchild('')
}
}
function handleConnectionType(type: string) {
setTab(type)
if (type === 'frameworks') {
setConnectionObject(FRAMEWORKS)
handleConnectionTypeChange(FRAMEWORKS)
}
if (type === 'mobiles') {
setConnectionObject(MOBILES)
handleConnectionTypeChange(MOBILES)
}
if (type === 'orms') {
setConnectionObject(ORMS)
handleConnectionTypeChange(ORMS)
}
}
const getChildOptions = () => {
const parent = connectionObject.find((item) => item.key === selectedParent)
if (parent && parent.children.length > 0) {
return parent.children
}
return []
}
const getGrandchildrenOptions = () => {
const parent = connectionObject.find((item) => item.key === selectedParent)
const subCategory = parent?.children.find((child) => child.key === selectedChild)
if (subCategory && subCategory.children.length > 0) {
return subCategory.children
}
return []
}
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { anonKey, publishableKey } = canReadAPIKeys
? getKeys(apiKeys)
: { anonKey: null, publishableKey: null }
const projectKeys = useMemo(() => {
return {
apiUrl: resolvedEndpoint ?? null,
anonKey: anonKey?.api_key ?? null,
publishableKey: publishableKey?.api_key ?? null,
}
}, [resolvedEndpoint, anonKey?.api_key, publishableKey?.api_key])
const filePath = getContentFilePath({
connectionObject,
selectedParent,
selectedChild,
selectedGrandchild,
})
const resetQueryStates = () => {
setQueryFramework(null)
setQueryUsing(null)
setQueryWith(null)
setQueryType(null)
setQuerySource(null)
setQueryMethod(null)
}
const handleDialogChange = (dialogOpen: boolean) => {
if (!dialogOpen) {
setTab(null)
resetQueryStates()
}
setOpen(dialogOpen)
}
useEffect(() => {
if (!open) return
const noConnectTabInUrl = typeof router.query.connectTab === 'undefined'
const hasQuery = queryFramework || queryUsing || queryWith
const inferred = inferConnectTabFromParentKey(queryFramework)
if (noConnectTabInUrl && hasQuery && inferred) {
setTab(inferred)
if (inferred === 'frameworks') setConnectionObject(FRAMEWORKS)
if (inferred === 'mobiles') setConnectionObject(MOBILES)
if (inferred === 'orms') setConnectionObject(ORMS)
}
}, [open, router.query.connectTab, queryFramework, queryUsing, queryWith, setTab])
useEffect(() => {
if (!open) return
const newConnectionObject = getConnectionObjectForTab(tab)
setConnectionObject(newConnectionObject)
const parent =
newConnectionObject.find((item) => item.key === queryFramework) ?? newConnectionObject[0]
setSelectedParent(parent?.key ?? '')
if (queryFramework) {
if (parent?.key !== queryFramework) setQueryFramework(parent?.key ?? null)
}
const child =
parent?.children.find((child) => child.key === queryUsing) ?? parent?.children?.[0]
setSelectedChild(child?.key ?? '')
if (queryUsing) {
if (child?.key !== queryUsing) setQueryUsing(child?.key ?? null)
}
const grandchild =
child?.children.find((child) => child.key === queryWith) ?? child?.children?.[0]
setSelectedGrandchild(grandchild?.key ?? '')
if (queryWith) {
if (grandchild?.key !== queryWith) setQueryWith(grandchild?.key ?? null)
}
}, [
open,
tab,
queryFramework,
setQueryFramework,
queryUsing,
setQueryUsing,
queryWith,
setQueryWith,
])
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className={cn('sm:max-w-5xl p-0 rounded-lg')} centered={false}>
<DialogHeader className={cn('text-left', DIALOG_PADDING_X)}>
<DialogTitle>
Connect to your project
{connectionTypes.length === 1 ? ` via ${connectionTypes[0].label.toLowerCase()}` : null}
</DialogTitle>
<DialogDescription>
Get the connection strings and environment variables for your app.
</DialogDescription>
</DialogHeader>
<Tabs_Shadcn_
value={tab}
onValueChange={(value) => {
resetQueryStates()
handleConnectionType(value)
}}
>
{connectionTypes.length > 1 ? (
<TabsList_Shadcn_ className={cn('flex overflow-x-scroll gap-x-4', DIALOG_PADDING_X)}>
{connectionTypes.map((type) => (
<TabsTrigger_Shadcn_ key={type.key} value={type.key} className="px-0">
{type.label}
</TabsTrigger_Shadcn_>
))}
</TabsList_Shadcn_>
) : (
<DialogSectionSeparator />
)}
{connectionTypes.map((type) => {
const hasChildOptions =
(connectionObject.find((parent) => parent.key === selectedParent)?.children.length ||
0) > 0
const hasGrandChildOptions =
(connectionObject
.find((parent) => parent.key === selectedParent)
?.children.find((child) => child.key === selectedChild)?.children.length || 0) > 0
if (type.key === 'direct') {
return (
<TabsContent_Shadcn_
key="direct"
value="direct"
className={cn('!mt-0', 'p-0', 'flex flex-col gap-6')}
>
<div className={DIALOG_PADDING_Y}>
<DatabaseConnectionString />
</div>
</TabsContent_Shadcn_>
)
}
if (type.key === 'mcp') {
return (
<TabsContent_Shadcn_
key="mcp"
value="mcp"
className={cn(DIALOG_PADDING_X, DIALOG_PADDING_Y, '!mt-0')}
>
<McpTabContent projectKeys={projectKeys} />
</TabsContent_Shadcn_>
)
}
if (type.key === 'api-keys') {
return (
<TabsContent_Shadcn_
key="api-keys"
value="api-keys"
className={cn(DIALOG_PADDING_X, DIALOG_PADDING_Y, '!mt-0')}
>
<ApiKeysTabContent projectKeys={projectKeys} />
</TabsContent_Shadcn_>
)
}
const connectionTabMap: Record<
string,
'App Frameworks' | 'Mobile Frameworks' | 'ORMs'
> = {
frameworks: 'App Frameworks',
mobiles: 'Mobile Frameworks',
orms: 'ORMs',
}
const connectionTab = connectionTabMap[type.key] || 'App Frameworks'
const selectedFrameworkOrTool =
connectionObject.find((item) => item.key === selectedParent)?.label || ''
return (
<TabsContent_Shadcn_
key={`content-${type.key}`}
value={type.key}
className={cn(DIALOG_PADDING_X, DIALOG_PADDING_Y, '!mt-0')}
>
<div className="flex flex-col md:flex-row gap-2 justify-between">
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-3">
<ConnectDropdown
state={selectedParent}
updateState={handleParentChange}
label={
connectionObject === FRAMEWORKS || connectionObject === MOBILES
? 'Framework'
: 'Tool'
}
items={connectionObject}
/>
{selectedParent && hasChildOptions && (
<ConnectDropdown
state={selectedChild}
updateState={handleChildChange}
label="Using"
items={getChildOptions()}
/>
)}
{selectedChild && hasGrandChildOptions && (
<ConnectDropdown
state={selectedGrandchild}
updateState={handleGrandchildChange}
label="With"
items={getGrandchildrenOptions()}
/>
)}
</div>
{connectionObject.find((item) => item.key === selectedParent)?.guideLink && (
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<a
target="_blank"
rel="noreferrer"
href={
connectionObject.find((item) => item.key === selectedParent)?.guideLink ||
''
}
>
{connectionObject.find((item) => item.key === selectedParent)?.label} guide
</a>
</Button>
)}
</div>
<p className="text-xs text-foreground-lighter my-3">
Add the following files below to your application
</p>
<ConnectTabContent
projectKeys={projectKeys}
filePath={filePath}
connectionTab={connectionTab}
selectedFrameworkOrTool={selectedFrameworkOrTool}
className="rounded-b-none"
/>
{IS_PLATFORM && (
<Panel.Notice
className="border border-t-0 rounded-lg rounded-t-none"
badgeLabel="Changelog"
title="New publishable and secret API keys"
description={
<>
<p>
View your publishable and secret API keys from the project{' '}
<Link href={`/project/${projectRef}/settings/api-keys`}>
API settings page
</Link>
</p>
<p>
To learn more about the new API keys, read the{' '}
<a
href="https://supabase.com/docs/guides/api/api-keys"
target="_blank"
rel="noreferrer"
>
documentation
</a>
</p>
</>
}
href={`${BASE_PATH}/project/${projectRef}/settings/api-keys`}
buttonText="View API keys"
/>
)}
</TabsContent_Shadcn_>
)
})}
</Tabs_Shadcn_>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,9 +1,7 @@
import { Box, Cable, Database, KeyRound, Sparkles } from 'lucide-react'
import type { ReactNode } from 'react'
import type { ConnectMode } from '../ConnectSheet/Connect.types'
// Temporary: experiment variants for the connectSection A/B test. Remove after the experiment.
export type ConnectSectionVariant = 'connect' | 'getting-started'
import type { ConnectMode } from '../ConnectSheet/Connect.types'
export type ConnectAction = {
id: ConnectMode | 'api_keys'

View File

@@ -9,14 +9,10 @@ import { useEffect, useRef } from 'react'
import { Card, CardContent, cn } from 'ui'
import { useAvailableConnectModes } from '../ConnectSheet/useAvailableConnectModes'
import { CONNECT_ACTIONS, type ConnectSectionVariant } from './ConnectSection.config'
import { CONNECT_ACTIONS } from './ConnectSection.config'
import { useAppStateSnapshot } from '@/state/app-state'
interface ConnectSectionProps {
variant: ConnectSectionVariant
}
export const ConnectSection = ({ variant }: ConnectSectionProps) => {
export const ConnectSection = () => {
const router = useRouter()
const { data: selectedProject } = useSelectedProjectQuery()
const { setConnectSheetSource } = useAppStateSnapshot()
@@ -37,8 +33,8 @@ export const ConnectSection = ({ variant }: ConnectSectionProps) => {
if (!IS_PLATFORM) return
if (hasTrackedExposure.current) return
hasTrackedExposure.current = true
track('home_connect_section_exposed', { variant })
}, [variant, track])
track('home_connect_section_exposed')
}, [track])
const handleActionClick = (action: (typeof CONNECT_ACTIONS)[number]) => {
track('home_connect_action_clicked', { mode: action.id })

View File

@@ -1,300 +0,0 @@
import { Check, ChevronLeft, ChevronRight } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { BASE_PATH } from 'lib/constants'
import { Badge, Button, Card, CardContent, cn, IconDiscord } from 'ui'
import { GettingStartedAction, GettingStartedStep } from './GettingStarted.types'
// Determine action type for tracking
const getActionType = (action: GettingStartedAction): 'primary' | 'ai_assist' | 'external_link' => {
// Check if it's an AI assist action (has AiIconAnimation or "Do it for me"/"Generate" labels)
if (
action.label?.toLowerCase().includes('do it for me') ||
action.label?.toLowerCase().includes('generate') ||
action.label?.toLowerCase().includes('create policies for me')
) {
return 'ai_assist'
}
// Check if it's an external link (href that doesn't start with /project/)
if (action.href && !action.href.startsWith('/project/')) {
return 'external_link'
}
return 'primary'
}
export interface GettingStartedProps {
steps: GettingStartedStep[]
onStepClick: ({
stepIndex,
stepTitle,
actionType,
wasCompleted,
}: {
stepIndex: number
stepTitle: string
actionType: 'primary' | 'ai_assist' | 'external_link'
wasCompleted: boolean
}) => void
}
export function GettingStarted({ steps, onStepClick }: GettingStartedProps) {
const allStepsComplete = steps.length > 0 && steps.every((step) => step.status === 'complete')
const [activeStepKey, setActiveStepKey] = useState<string | null>(null)
useEffect(() => {
if (steps.length === 0) {
setActiveStepKey(null)
return
}
// Check if the current active step key is still valid in the new steps array
const isActiveStepValid = activeStepKey && steps.some((step) => step.key === activeStepKey)
// If no step is selected or the active step is no longer valid (e.g., after tab switch)
if (!isActiveStepValid) {
// If all steps are complete, don't select any step
if (allStepsComplete) {
setActiveStepKey(null)
} else {
// Select the first incomplete step, or the first step if all are complete
const firstIncompleteStep = steps.find((step) => step.status !== 'complete')
setActiveStepKey(firstIncompleteStep?.key ?? steps[0]?.key ?? null)
}
}
}, [steps, allStepsComplete, activeStepKey])
const activeStep = activeStepKey ? steps.find((step) => step.key === activeStepKey) : null
const activeStepIndex = activeStep ? steps.findIndex((step) => step.key === activeStep.key) : -1
const previousStep = activeStepIndex > 0 ? steps[activeStepIndex - 1] : null
const nextStep =
activeStepIndex > -1 && activeStepIndex < steps.length - 1 ? steps[activeStepIndex + 1] : null
const handleSelectPrevious = () => {
if (previousStep) {
setActiveStepKey(previousStep.key)
}
}
const handleSelectNext = () => {
if (nextStep) {
setActiveStepKey(nextStep.key)
}
}
const showCongratulations = allStepsComplete && !activeStep
return (
<Card className="overflow-hidden @container">
<div className="flex flex-col @2xl:flex-row">
<aside className="hidden border-r @2xl:block @2xl:w-[calc(1/3*100%-16px)]">
<ol>
{steps.map((step, index) => {
const isActive = activeStep ? step.key === activeStep.key : false
const isComplete = step.status === 'complete'
return (
<li key={step.key} className="border-b last:border-b-0 truncate">
<Button
type="text"
onClick={() => setActiveStepKey(step.key)}
className={cn(
'pl-1 block justify-start w-full rounded-none h-auto text-left text-foreground-light',
isActive && 'bg-muted text-foreground'
)}
>
<div className="flex items-center gap-3 text-sm w-full">
<span
className={cn(
'text-xs shrink-0 font-mono text-foreground-light w-7 h-7 bg border flex items-center justify-center rounded-md'
)}
>
{isComplete ? (
<Check size={16} strokeWidth={1.5} className="text-brand-link" />
) : (
index + 1
)}
</span>
<span
className={cn(
'flex-1 block truncate',
isActive ? 'text-foreground' : 'group-hover:text-foreground',
isComplete && 'line-through'
)}
>
{step.title}
</span>
<ChevronRight
size={16}
strokeWidth={1.5}
className="text-foreground-lighter"
/>
</div>
</Button>
</li>
)
})}
</ol>
</aside>
<CardContent className="flex flex-1 flex-col gap-0 p-0 overflow-y-auto border-b-0">
{showCongratulations ? (
<div className="relative w-full flex-1 min-h-[100px] shrink-0 overflow-hidden bg-200 flex flex-col justify-end">
<div className="p-10">
<div className="w-8 h-8 rounded-md bg-brand/15 flex items-center justify-center shrink-0 mb-4">
<Check size={16} strokeWidth={1.5} className="text-brand-link" />
</div>
<div className="flex flex-row items-center gap-4 mb-1">
<h3>All steps complete</h3>
</div>
<p className="text-foreground-light max-w-prose mb-4 text-balance">
Drop into our Discord community to share your progress and learn from fellow
developers.
</p>
<Button
asChild
type="default"
icon={<IconDiscord size={14} />}
className="text-foreground-light hover:text-foreground"
>
<Link href={'https://discord.supabase.com/'} target="_blank">
Join our Discord
</Link>
</Button>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between gap-2 border-b px-2 py-2 @2xl:hidden">
<span className="text-xs shrink-0 font-mono text-foreground-light w-7 h-7 bg border flex items-center justify-center rounded-md">
{activeStepIndex + 1}
</span>
<div className="flex items-center gap-2">
<Button
type="outline"
onClick={handleSelectPrevious}
disabled={!previousStep}
className="gap-2"
aria-label="Previous step"
>
<ChevronLeft size={16} strokeWidth={1.5} />
</Button>
<Button
type="outline"
onClick={handleSelectNext}
disabled={!nextStep}
className="gap-2"
aria-label="Next step"
>
<ChevronRight size={16} strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="relative w-full flex-1 min-h-[100px] shrink-0 overflow-hidden">
{activeStep?.image ? (
<Image
className="w-full select-none invert dark:invert-0"
src={activeStep.image}
fill
objectFit="cover"
objectPosition="top"
alt={activeStep.title}
/>
) : (
<div className="absolute top-0 left-0 right-0 overflow-hidden">
<img
src={`${BASE_PATH}/img/reports/bg-grafana-dark.svg`}
alt="Supabase Grafana"
className="w-full h-full object-cover object-right hidden dark:block user-select-none"
/>
<img
src={`${BASE_PATH}/img/reports/bg-grafana-light.svg`}
alt="Supabase Grafana"
className="w-full h-full object-cover object-right dark:hidden user-select-none"
/>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background-surface-100 to-transparent" />
</div>
<div className="p-10">
{activeStep && (
<>
<div className="flex flex-row items-center gap-4 mb-1">
<h3>{activeStep.title}</h3>
<Badge
variant={activeStep.status === 'complete' ? 'success' : 'default'}
className="capitalize hidden @2xl:inline-flex"
>
{activeStep.status}
</Badge>
</div>
<p className="text-foreground-light max-w-prose mb-4 text-balance">
{activeStep.description}
</p>
<div className="mt-auto flex flex-wrap gap-2 pt-2">
{activeStep.actions.map((action, i) => {
if (action.component) {
return <div key={`${activeStep.key}-action-${i}`}>{action.component}</div>
}
const actionType = getActionType(action)
if (action.href) {
return (
<Button
asChild
key={`${activeStep.key}-action-${i}`}
type={action.variant ?? 'default'}
icon={action.icon}
className="text-foreground-light hover:text-foreground"
>
<Link
href={action.href}
onClick={() => {
onStepClick({
stepIndex: activeStepIndex,
stepTitle: activeStep.title,
actionType,
wasCompleted: activeStep.status === 'complete',
})
}}
>
{action.label}
</Link>
</Button>
)
}
return (
<Button
key={`${activeStep.key}-action-${i}`}
type={action.variant ?? 'default'}
icon={action.icon}
onClick={() => {
action.onClick?.()
onStepClick({
stepIndex: activeStepIndex,
stepTitle: activeStep.title,
actionType,
wasCompleted: activeStep.status === 'complete',
})
}}
className="text-foreground-light hover:text-foreground"
>
{action.label}
</Button>
)
})}
</div>
</>
)}
</div>
</>
)}
</CardContent>
</div>
</Card>
)
}

View File

@@ -1,24 +0,0 @@
import { ComponentProps, ReactNode } from 'react'
import { Button } from 'ui'
export type GettingStartedAction = {
label: string
href?: string
variant?: ComponentProps<typeof Button>['type']
icon?: ReactNode
component?: ReactNode
onClick?: () => void
}
export type GettingStartedStep = {
key: string
status: 'complete' | 'incomplete'
icon?: ReactNode
title: string
description: string
image?: string
actions: GettingStartedAction[]
}
export type GettingStartedState = 'empty' | 'code' | 'no-code' | 'hidden'

View File

@@ -1,379 +0,0 @@
import { DOCS_URL } from 'lib/constants'
import { BarChart3, Code, Database, GitBranch, Shield, Table, Upload, UserPlus } from 'lucide-react'
import { AiIconAnimation } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import type { GettingStartedAction, GettingStartedStep } from './GettingStarted.types'
import type { GettingStartedStatuses } from './useGettingStartedProgress'
type BuildStepsBaseArgs = {
ref: string | undefined
openAiChat: (name: string, initialInput: string) => void
connectActions: GettingStartedAction[]
statuses: GettingStartedStatuses
}
type BuildCodeStepsArgs = BuildStepsBaseArgs
type BuildNoCodeStepsArgs = BuildStepsBaseArgs
export const getCodeWorkflowSteps = ({
ref,
openAiChat,
connectActions,
statuses,
}: BuildCodeStepsArgs): GettingStartedStep[] => {
const {
hasTables,
hasCliSetup,
hasSampleData,
hasRlsPolicies,
hasAppConnected,
hasFirstUser,
hasStorageObjects,
hasEdgeFunctions,
hasReports,
hasGitHubConnection,
} = statuses
return [
{
key: 'install-cli',
status: hasCliSetup ? 'complete' : 'incomplete',
title: 'Install the Supabase CLI',
icon: <Code strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'To get started, install the Supabase CLI—our command-line toolkit for managing projects locally, handling migrations, and seeding data—using the npm command below to add it to your workspace.',
actions: [
{
label: 'Install via npm',
component: (
<CodeBlock className="w-full text-xs p-3 !bg" language="bash">
npm install supabase --save-dev
</CodeBlock>
),
},
],
},
{
key: 'design-db',
status: hasTables ? 'complete' : 'incomplete',
title: 'Design your database schema',
icon: <Database strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Next, create a schema file that defines the structure of your database, either following our declarative schema guide or asking the AI assistant to generate one for you.',
actions: [
{
label: 'Create schema file',
href: `${DOCS_URL}/guides/local-development/declarative-database-schemas`,
variant: 'default',
},
{
label: 'Generate it',
variant: 'default',
icon: <AiIconAnimation size={14} />,
onClick: () =>
openAiChat(
'Design my database',
'Help me create a schema file for my database. We will be using Supabase declarative schemas which you can learn about by searching docs for declarative schema.'
),
},
],
},
{
key: 'add-data',
status: hasSampleData ? 'complete' : 'incomplete',
title: 'Seed your database with data',
icon: <Table strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Now, create a seed file to populate your database with initial data, using the docs for guidance or letting the AI assistant draft realistic inserts.',
actions: [
{
label: 'Create a seed file',
href: `${DOCS_URL}/guides/local-development/seeding-your-database`,
variant: 'default',
},
{
label: 'Generate data',
variant: 'default',
icon: <AiIconAnimation size={14} />,
onClick: () =>
openAiChat(
'Generate seed data',
'Generate SQL INSERT statements for realistic seed data that I can run via the Supabase CLI.'
),
},
],
},
{
key: 'add-rls-policies',
status: hasRlsPolicies ? 'complete' : 'incomplete',
title: 'Secure your data with RLS policies',
icon: <Shield strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Let's secure your data by enabling Row Level Security (per-row access rules that decide who can read or write specific records) and defining policies in a migration file, either configuring them manually or letting the AI assistant draft policies for your tables.",
actions: [
{
label: 'Create a migration file',
href: `/project/${ref}/auth/policies`,
variant: 'default',
},
{
label: 'Create policies for me',
variant: 'default',
icon: <AiIconAnimation size={14} />,
onClick: () =>
openAiChat(
'Generate RLS policies',
'Generate RLS policies for my existing tables in the public schema and guide me through the process of adding them as migration files to my codebase '
),
},
],
},
{
key: 'connect-app',
status: hasAppConnected ? 'complete' : 'incomplete',
title: 'Connect your application',
icon: <Code strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Your project is ready; open the Connect sheet to grab connection details and setup guidance.',
actions: connectActions,
},
{
key: 'signup-first-user',
status: hasFirstUser ? 'complete' : 'incomplete',
title: 'Sign up your first user',
icon: <UserPlus strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Test your authentication setup by creating the first user account, following the docs if you need a step-by-step walkthrough.',
actions: [
{
label: 'Read docs',
href: `${DOCS_URL}/guides/auth`,
variant: 'default',
},
],
},
{
key: 'upload-file',
status: hasStorageObjects ? 'complete' : 'incomplete',
title: 'Upload a file',
icon: <Upload strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Integrate file storage by creating a bucket via SQL and uploading a file using our client libraries.',
actions: [
{
label: 'Create a bucket via SQL',
href: `${DOCS_URL}/guides/storage/buckets/creating-buckets?queryGroups=language&language=sql`,
variant: 'default',
},
{
label: 'Upload a file',
href: `${DOCS_URL}/guides/storage/uploads/standard-uploads`,
variant: 'default',
},
],
},
{
key: 'create-edge-function',
status: hasEdgeFunctions ? 'complete' : 'incomplete',
title: 'Deploy an Edge Function',
icon: <Code strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Add server-side logic by creating and deploying your first Edge Function—a lightweight TypeScript or JavaScript function that runs close to your users—then revisit the list to monitor and iterate on it.',
actions: [
{
label: 'Create and deploy via CLI',
href: `${DOCS_URL}/guides/functions/quickstart`,
variant: 'default',
},
{ label: 'View functions', href: `/project/${ref}/functions`, variant: 'default' },
],
},
{
key: 'monitor-progress',
status: hasReports ? 'complete' : 'incomplete',
title: "Monitor your project's usage",
icon: <BarChart3 strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Track your project's activity by creating custom reports for API, database, and auth events right from the reports dashboard.",
actions: [{ label: 'Reports', href: `/project/${ref}/reports`, variant: 'default' }],
},
{
key: 'connect-github',
status: hasGitHubConnection ? 'complete' : 'incomplete',
title: 'Connect to GitHub',
icon: <GitBranch strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Link this project to a GitHub repository to keep production in sync and spin up preview branches from pull requests.',
actions: [
{
label: 'Connect to GitHub',
href: `/project/${ref}/settings/integrations`,
variant: 'default',
},
],
},
]
}
export const getNoCodeWorkflowSteps = ({
ref,
openAiChat,
connectActions,
statuses,
}: BuildNoCodeStepsArgs): GettingStartedStep[] => {
const {
hasTables,
hasSampleData,
hasRlsPolicies,
hasAppConnected,
hasFirstUser,
hasStorageObjects,
hasEdgeFunctions,
hasReports,
hasGitHubConnection,
} = statuses
return [
{
key: 'design-db',
status: hasTables ? 'complete' : 'incomplete',
title: 'Create your first table',
icon: <Database strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"To kick off your new project, let's start by creating your very first database table using either the table editor or the AI assistant to shape the structure for you.",
actions: [
{ label: 'Create a table', href: `/project/${ref}/editor`, variant: 'default' },
{
label: 'Do it for me',
variant: 'default',
icon: <AiIconAnimation size={14} />,
onClick: () =>
openAiChat(
'Design my database',
'I want to design my database schema. Please propose tables, relationships, and SQL to create them for my app. Ask clarifying questions if needed.'
),
},
],
},
{
key: 'add-data',
status: hasSampleData ? 'complete' : 'incomplete',
title: 'Add sample data',
icon: <Table strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Next, let's add some sample data that you can play with once you connect your app, either by inserting rows yourself or letting the AI assistant craft realistic examples.",
actions: [
{ label: 'Add data', href: `/project/${ref}/editor`, variant: 'default' },
{
label: 'Do it for me',
variant: 'default',
icon: <AiIconAnimation size={14} />,
onClick: () =>
openAiChat(
'Generate sample data',
'Generate SQL INSERT statements to add realistic sample data to my existing tables. Use safe defaults and avoid overwriting data.'
),
},
],
},
{
key: 'add-rls-policies',
status: hasRlsPolicies ? 'complete' : 'incomplete',
title: 'Secure your data with Row Level Security',
icon: <Shield strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Now that you have some data, let's secure it by enabling Row Level Security (row-specific access rules that control who can view or modify records) and creating policies yourself or with help from the AI assistant.",
actions: [
{
label: 'Create a policy',
href: `/project/${ref}/auth/policies`,
variant: 'default',
},
{
label: 'Do it for me',
variant: 'default',
icon: <AiIconAnimation size={14} />,
onClick: () =>
openAiChat(
'Generate RLS policies',
'Generate RLS policies for my existing tables in the public schema. '
),
},
],
},
{
key: 'connect-app',
status: hasAppConnected ? 'complete' : 'incomplete',
title: 'Connect your application',
icon: <Code strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Your project is ready; open the Connect sheet to grab connection details and setup guidance.',
actions: connectActions,
},
{
key: 'signup-first-user',
status: hasFirstUser ? 'complete' : 'incomplete',
title: 'Sign up your first user',
icon: <UserPlus strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Test your authentication by signing up your first user, referencing the docs if you need sample flows or troubleshooting tips.',
actions: [
{
label: 'Read docs',
href: `${DOCS_URL}/guides/auth`,
variant: 'default',
},
],
},
{
key: 'upload-file',
status: hasStorageObjects ? 'complete' : 'incomplete',
title: 'Upload a file',
icon: <Upload strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Let's add file storage to your app by creating a bucket and uploading your first file from the buckets dashboard.",
actions: [{ label: 'Buckets', href: `/project/${ref}/storage/files`, variant: 'default' }],
},
{
key: 'create-edge-function',
status: hasEdgeFunctions ? 'complete' : 'incomplete',
title: 'Add server-side logic',
icon: <Code strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Extend your app's functionality by creating an Edge Function—a lightweight serverless function that executes close to your users—for server-side logic directly from the functions page.",
actions: [
{
label: 'Create a function',
href: `/project/${ref}/functions/new`,
variant: 'default',
},
],
},
{
key: 'monitor-progress',
status: hasReports ? 'complete' : 'incomplete',
title: "Monitor your project's health",
icon: <BarChart3 strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
"Keep an eye on your project's performance and usage by setting up custom reports from the reports dashboard.",
actions: [{ label: 'Create a report', href: `/project/${ref}/reports`, variant: 'default' }],
},
{
key: 'connect-github',
status: hasGitHubConnection ? 'complete' : 'incomplete',
title: 'Connect to GitHub',
icon: <GitBranch strokeWidth={1} className="text-foreground-muted" size={16} />,
description:
'Connect your project to GitHub to automatically create preview branches and sync production changes.',
actions: [
{
label: 'Connect to GitHub',
href: `/project/${ref}/settings/integrations`,
variant: 'default',
},
],
},
]
}

View File

@@ -1,243 +0,0 @@
import { IS_PLATFORM, useParams } from 'common'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { BASE_PATH } from 'lib/constants'
import { useTrack } from 'lib/telemetry/track'
import { Code, Table2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { Button, Card, CardContent, ToggleGroup, ToggleGroupItem } from 'ui'
import { GettingStarted } from './GettingStarted'
import {
GettingStartedAction,
GettingStartedState,
GettingStartedStep,
} from './GettingStarted.types'
import { getCodeWorkflowSteps, getNoCodeWorkflowSteps } from './GettingStarted.utils'
import { useGettingStartedProgress } from './useGettingStartedProgress'
import { ConnectButton } from '@/components/interfaces/ConnectButton/ConnectButton'
interface GettingStartedSectionProps {
value: GettingStartedState
onChange: (v: GettingStartedState) => void
}
export function GettingStartedSection({ value, onChange }: GettingStartedSectionProps) {
const { ref } = useParams()
const track = useTrack()
const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const workflow: 'no-code' | 'code' | null = value === 'code' || value === 'no-code' ? value : null
const [previousWorkflow, setPreviousWorkflow] = useState<'no-code' | 'code' | null>(null)
const statuses = useGettingStartedProgress()
const openAiChat = useCallback(
(name: string, initialInput: string) => {
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({ name, initialInput })
},
[aiSnap, openSidebar]
)
const connectActions: GettingStartedAction[] = useMemo(() => {
return [
{
label: 'Connect',
component: <ConnectButton buttonType="primary" />,
},
]
}, [])
const codeSteps: GettingStartedStep[] = useMemo(
() =>
getCodeWorkflowSteps({
ref,
openAiChat,
connectActions,
statuses,
}),
[connectActions, openAiChat, ref, statuses]
)
const noCodeSteps: GettingStartedStep[] = useMemo(
() =>
getNoCodeWorkflowSteps({
ref,
openAiChat,
connectActions,
statuses,
}),
[connectActions, openAiChat, ref, statuses]
)
const steps = workflow === 'code' ? codeSteps : workflow === 'no-code' ? noCodeSteps : []
const hasTrackedExposure = useRef(false)
useEffect(() => {
if (!IS_PLATFORM) return
if (hasTrackedExposure.current) return
hasTrackedExposure.current = true
track('home_getting_started_section_exposed', {
workflow: workflow === 'no-code' ? 'no_code' : workflow === 'code' ? 'code' : null,
})
}, [workflow, track])
return (
<section className="w-full">
<div className="flex justify-between items-center mb-6">
<h3 className="heading-section">Getting started</h3>
<div className="flex items-center gap-2">
<ToggleGroup
type="single"
value={workflow ?? undefined}
onValueChange={(v) => {
if (v) {
const newWorkflow = v as 'no-code' | 'code'
setPreviousWorkflow(workflow)
onChange(newWorkflow)
track('home_getting_started_workflow_clicked', {
workflow: newWorkflow === 'no-code' ? 'no_code' : 'code',
is_switch: previousWorkflow !== null,
})
}
}}
>
<ToggleGroupItem
value="no-code"
aria-label="No-code workflow"
size="sm"
className="text-xs gap-2 h-auto"
>
<Table2 size={16} strokeWidth={1.5} />
No-code
</ToggleGroupItem>
<ToggleGroupItem
value="code"
size="sm"
aria-label="Code workflow"
className="text-xs gap-2 h-auto"
>
<Code size={16} strokeWidth={1.5} />
Code
</ToggleGroupItem>
</ToggleGroup>
<Button
size="tiny"
type="outline"
onClick={() => {
onChange('hidden')
if (workflow) {
const completedSteps = (workflow === 'code' ? codeSteps : noCodeSteps).filter(
(step) => step.status === 'complete'
).length
const totalSteps = (workflow === 'code' ? codeSteps : noCodeSteps).length
track('home_getting_started_closed', {
workflow: workflow === 'no-code' ? 'no_code' : 'code',
steps_completed: completedSteps,
total_steps: totalSteps,
})
}
}}
>
Dismiss
</Button>
</div>
</div>
{steps.length === 0 ? (
<Card className="bg-background/25 border-dashed relative">
<div className="absolute -inset-16 z-0 opacity-50">
<img
src={`${BASE_PATH}/img/reports/bg-grafana-dark.svg`}
alt="Supabase Grafana"
className="w-full h-full object-cover object-right hidden dark:block"
/>
<img
src={`${BASE_PATH}/img/reports/bg-grafana-light.svg`}
alt="Supabase Grafana"
className="w-full h-full object-cover object-right dark:hidden"
/>
<div className="absolute inset-0 bg-gradient-to-r from-background-alternative to-transparent" />
</div>
<CardContent className="relative z-10 p-8 md:p-12 grid lg:grid-cols-2 gap-16 items-center">
<div>
<h2 className="heading-subSection mb-0 heading-meta text-foreground-light mb-4">
Choose a preferred workflow
</h2>
<p className="text-foreground">
With Supabase, you have the flexibility to adopt a workflow that works for you. You
can do everything via the dashboard, or manage your entire project within your own
codebase.
</p>
</div>
<div className="flex items-stretch gap-4">
<Button
size="medium"
type="outline"
onClick={() => {
setPreviousWorkflow(workflow)
onChange('no-code')
track('home_getting_started_workflow_clicked', {
workflow: 'no_code',
is_switch: previousWorkflow !== null,
})
}}
className="block gap-2 h-auto p-4 md:p-8 max-w-80 text-left justify-start bg-background "
>
<Table2 size={20} strokeWidth={1.5} className="text-brand" />
<div className="mt-4">
<div>No-code</div>
<div className="text-foreground-light w-full whitespace-normal">
Ideal for prototyping or getting your project up and running
</div>
</div>
</Button>
<Button
size="medium"
type="outline"
onClick={() => {
setPreviousWorkflow(workflow)
onChange('code')
track('home_getting_started_workflow_clicked', {
workflow: 'code',
is_switch: previousWorkflow !== null,
})
}}
className="bg-background block gap-2 h-auto p-4 md:p-8 max-w-80 text-left justify-start"
>
<Code size={20} strokeWidth={1.5} className="text-brand" />
<div className="mt-4">
<div>Code</div>
<div className="text-foreground-light whitespace-normal">
Ideal for teams that want full control of their project
</div>
</div>
</Button>
</div>
</CardContent>
</Card>
) : (
<GettingStarted
steps={steps}
onStepClick={({ stepIndex, stepTitle, actionType, wasCompleted }) => {
if (workflow) {
track('home_getting_started_step_clicked', {
workflow: workflow === 'no-code' ? 'no_code' : 'code',
step_number: stepIndex + 1,
step_title: stepTitle,
action_type: actionType,
was_completed: wasCompleted,
})
}
}}
/>
)}
</section>
)
}

View File

@@ -1,144 +0,0 @@
import { useMemo } from 'react'
import { useProjectLogStatsQuery } from 'data/analytics/project-log-stats-query'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { useContentInfiniteQuery } from 'data/content/content-infinite-query'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useMigrationsQuery } from 'data/database/migrations-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
type GettingStartedStatuses = {
hasTables: boolean
hasCliSetup: boolean
hasSampleData: boolean
hasRlsPolicies: boolean
hasAppConnected: boolean
hasFirstUser: boolean
hasStorageObjects: boolean
hasEdgeFunctions: boolean
hasReports: boolean
hasGitHubConnection: boolean
}
export const useGettingStartedProgress = (): GettingStartedStatuses => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const projectRef = project?.ref
const connectionString = project?.connectionString
const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const { data: tablesData } = useTablesQuery(
{ projectRef, connectionString, schema: 'public' },
{ enabled: !!projectRef && isProjectActive }
)
const { data: storageTablesData } = useTablesQuery(
{ projectRef, connectionString, schema: 'storage' },
{ enabled: !!projectRef && isProjectActive }
)
const { data: policiesData } = useDatabasePoliciesQuery(
{ projectRef, connectionString, schema: 'public' },
{ enabled: !!projectRef && !!connectionString && isProjectActive }
)
const { data: authConfig } = useAuthConfigQuery(
{ projectRef },
{ enabled: !!projectRef && isProjectActive }
)
const { data: usersCountData } = useUsersCountQuery(
{ projectRef, connectionString },
{ enabled: !!projectRef && !!connectionString && isProjectActive }
)
const { data: edgeFunctionsData } = useEdgeFunctionsQuery(
{ projectRef },
{ enabled: !!projectRef && isProjectActive }
)
const { data: reportsData } = useContentInfiniteQuery(
{ projectRef, type: 'report', limit: 1 },
{ enabled: !!projectRef && isProjectActive }
)
const { data: migrationsData } = useMigrationsQuery(
{ projectRef, connectionString },
{ enabled: !!projectRef && !!connectionString && isProjectActive }
)
const { data: usageStatsData } = useProjectLogStatsQuery(
{ projectRef, interval: '1day' },
{ enabled: !!projectRef && isProjectActive }
)
const { data: githubConnections } = useGitHubConnectionsQuery(
{ organizationId: organization?.id },
{ enabled: !!projectRef && !!organization?.id }
)
const statuses = useMemo<GettingStartedStatuses>(() => {
const hasTables = (tablesData?.length ?? 0) > 0
const hasCliSetup = (migrationsData?.length ?? 0) > 0
const hasSampleData = (tablesData ?? []).some(
(table) => Number(table?.live_rows_estimate ?? 0) > 0
)
const hasRlsPolicies = (policiesData?.length ?? 0) > 0
const allowSignupsEnabled = authConfig ? !authConfig.DISABLE_SIGNUP : false
const emailProviderEnabled = !!authConfig?.EXTERNAL_EMAIL_ENABLED
const hasFirstUser = !!usersCountData && !usersCountData.is_estimate && usersCountData.count > 0
const hasStorageObjects = (storageTablesData ?? []).some(
(table) => table.name === 'objects' && Number(table?.live_rows_estimate ?? 0) > 0
)
const hasEdgeFunctions = (edgeFunctionsData?.length ?? 0) > 0
const hasReports = (reportsData?.pages?.[0]?.content?.length ?? 0) > 0
const hasGitHubConnection =
githubConnections?.some((connection) => connection.project.ref === projectRef) ?? false
const hasAppConnected =
usageStatsData?.result?.some((row) => {
const totals = [
row.total_auth_requests,
row.total_storage_requests,
row.total_rest_requests,
row.total_realtime_requests,
]
return totals.some((value) => (value ?? 0) > 0)
}) ?? false
return {
hasTables,
hasCliSetup,
hasSampleData,
hasRlsPolicies,
hasAppConnected,
hasFirstUser,
hasStorageObjects,
hasEdgeFunctions,
hasReports,
hasGitHubConnection,
}
}, [
authConfig,
edgeFunctionsData,
githubConnections,
migrationsData,
policiesData,
reportsData,
storageTablesData,
tablesData,
usageStatsData,
usersCountData,
projectRef,
])
return statuses
}
export type { GettingStartedStatuses }

View File

@@ -8,7 +8,6 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import dayjs from 'dayjs'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { usePHFlag } from 'hooks/ui/useFlag'
import { PROJECT_STATUS } from 'lib/constants'
import { useTrack } from 'lib/telemetry/track'
import { useEffect, useRef } from 'react'
@@ -17,11 +16,8 @@ import { cn } from 'ui'
import { AdvisorSection } from './AdvisorSection'
import { ConnectSection } from './ConnectSection'
import type { ConnectSectionVariant } from './ConnectSection.config'
import { CustomReportSection } from './CustomReportSection'
import { type GettingStartedState } from './GettingStarted/GettingStarted.types'
import { GettingStartedSection } from './GettingStarted/GettingStartedSection'
import { DEFAULT_SECTION_ORDER, getSectionVisibility, mergeSectionOrder } from './Home.utils'
import { DEFAULT_SECTION_ORDER, mergeSectionOrder } from './Home.utils'
import { ProjectUsageSection as ProjectUsageSectionV2 } from './ProjectUsageSection'
export const ProjectHome = () => {
@@ -31,7 +27,6 @@ export const ProjectHome = () => {
const track = useTrack()
const showHomepageUsageV2 = useFlag('newHomepageUsageV2')
const connectSectionVariant = usePHFlag<ConnectSectionVariant | false>('connectSection')
const isMatureProject = dayjs(project?.inserted_at).isBefore(dayjs().subtract(10, 'day'))
@@ -44,11 +39,6 @@ export const ProjectHome = () => {
DEFAULT_SECTION_ORDER
)
const [gettingStartedState, setGettingStartedState] = useLocalStorage<GettingStartedState>(
`home-getting-started-${project?.ref || 'default'}`,
'empty'
)
const UsageSection = showHomepageUsageV2 ? ProjectUsageSectionV2 : ProjectUsageSectionV1
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
@@ -82,11 +72,11 @@ export const ProjectHome = () => {
setSectionOrder(mergeSectionOrder)
}, [setSectionOrder])
const { showConnectSection, showGettingStarted } = getSectionVisibility({
connectSectionVariant,
isMatureProject,
hasProject: !!project,
gettingStartedState,
const showConnectSection = !isMatureProject && !!project
const renderOrder = mergeSectionOrder(sectionOrder).filter((id) => {
if (id === 'connect') return showConnectSection
return true
})
return (
@@ -103,15 +93,8 @@ export const ProjectHome = () => {
<ScaffoldContainer size="large">
<ScaffoldSection isFullWidth className="gap-12 pb-32">
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={sectionOrder.filter((id) => {
if (id === 'connect') return showConnectSection
if (id === 'getting-started') return showGettingStarted
return true
})}
strategy={verticalListSortingStrategy}
>
{sectionOrder.map((id) => {
<SortableContext items={renderOrder} strategy={verticalListSortingStrategy}>
{renderOrder.map((id) => {
if (IS_PLATFORM && id === 'usage') {
return (
<div key={id} className={cn(isComingUp && 'opacity-60 pointer-events-none')}>
@@ -124,17 +107,7 @@ export const ProjectHome = () => {
if (id === 'connect' && showConnectSection) {
return (
<SortableSection key={id} id={id}>
<ConnectSection variant={connectSectionVariant as ConnectSectionVariant} />
</SortableSection>
)
}
if (id === 'getting-started' && showGettingStarted) {
return (
<SortableSection key={id} id={id}>
<GettingStartedSection
value={gettingStartedState}
onChange={setGettingStartedState}
/>
<ConnectSection />
</SortableSection>
)
}

View File

@@ -1,17 +1,16 @@
import { describe, expect, it } from 'vitest'
import { mergeSectionOrder, getSectionVisibility } from './Home.utils'
import { mergeSectionOrder } from './Home.utils'
describe('mergeSectionOrder', () => {
it('returns stored order unchanged when it matches defaults', () => {
const stored = ['connect', 'getting-started', 'usage', 'advisor', 'custom-report']
const stored = ['connect', 'usage', 'advisor', 'custom-report']
expect(mergeSectionOrder(stored)).toBe(stored)
})
it('inserts missing sections at their default-relative position', () => {
expect(mergeSectionOrder(['usage', 'advisor', 'custom-report'])).toEqual([
'connect',
'getting-started',
'usage',
'advisor',
'custom-report',
@@ -19,10 +18,9 @@ describe('mergeSectionOrder', () => {
})
it('preserves user reordering while inserting missing sections', () => {
expect(mergeSectionOrder(['getting-started', 'advisor', 'usage', 'custom-report'])).toEqual([
'connect',
'getting-started',
expect(mergeSectionOrder(['advisor', 'usage', 'custom-report'])).toEqual([
'advisor',
'connect',
'usage',
'custom-report',
])
@@ -31,74 +29,15 @@ describe('mergeSectionOrder', () => {
it('strips unknown sections from stored order', () => {
expect(mergeSectionOrder(['usage', 'deleted-section', 'advisor', 'custom-report'])).toEqual([
'connect',
'getting-started',
'usage',
'advisor',
'custom-report',
])
})
})
describe('getSectionVisibility', () => {
const base = {
connectSectionVariant: 'connect' as const,
isMatureProject: false,
hasProject: true,
gettingStartedState: 'empty' as const,
}
it('shows connect section for connect variant on new project', () => {
expect(getSectionVisibility(base)).toEqual({
showConnectSection: true,
showGettingStarted: false,
})
})
it('shows getting started for non-connect variant', () => {
expect(getSectionVisibility({ ...base, connectSectionVariant: 'getting-started' })).toEqual({
showConnectSection: false,
showGettingStarted: true,
})
})
it('shows neither when flag is unresolved', () => {
expect(getSectionVisibility({ ...base, connectSectionVariant: undefined })).toEqual({
showConnectSection: false,
showGettingStarted: false,
})
})
it('shows neither for mature projects', () => {
expect(getSectionVisibility({ ...base, isMatureProject: true })).toEqual({
showConnectSection: false,
showGettingStarted: false,
})
})
it('hides getting started when user dismissed it', () => {
it('strips legacy getting-started from stored order', () => {
expect(
getSectionVisibility({
...base,
connectSectionVariant: 'getting-started',
gettingStartedState: 'hidden',
})
).toEqual({
showConnectSection: false,
showGettingStarted: false,
})
})
it('shows getting started when flag resolved to false (control group)', () => {
expect(getSectionVisibility({ ...base, connectSectionVariant: false })).toEqual({
showConnectSection: false,
showGettingStarted: true,
})
})
it('shows neither when project is missing', () => {
expect(getSectionVisibility({ ...base, hasProject: false })).toEqual({
showConnectSection: false,
showGettingStarted: false,
})
mergeSectionOrder(['connect', 'getting-started', 'usage', 'advisor', 'custom-report'])
).toEqual(['connect', 'usage', 'advisor', 'custom-report'])
})
})

View File

@@ -1,13 +1,4 @@
import type { GettingStartedState } from './GettingStarted/GettingStarted.types'
import type { ConnectSectionVariant } from './ConnectSection.config'
export const DEFAULT_SECTION_ORDER = [
'connect',
'getting-started',
'usage',
'advisor',
'custom-report',
]
export const DEFAULT_SECTION_ORDER = ['connect', 'usage', 'advisor', 'custom-report']
/**
* Reconciles a stored section order with the canonical list.
@@ -33,23 +24,3 @@ export function mergeSectionOrder(stored: string[]): string[] {
}
return merged
}
// Temporary: getSectionVisibility and related types support the connectSection
// experiment (connect vs getting-started). Remove after the experiment concludes.
interface SectionVisibilityInput {
connectSectionVariant: ConnectSectionVariant | false | undefined
isMatureProject: boolean
hasProject: boolean
gettingStartedState: GettingStartedState
}
export function getSectionVisibility(input: SectionVisibilityInput) {
const { connectSectionVariant, isMatureProject, hasProject, gettingStartedState } = input
const canShowEither = connectSectionVariant !== undefined && !isMatureProject && hasProject
return {
showConnectSection: canShowEither && connectSectionVariant === 'connect',
showGettingStarted:
canShowEither && connectSectionVariant !== 'connect' && gettingStartedState !== 'hidden',
}
}

View File

@@ -3,7 +3,6 @@ import {
useIsBranching2Enabled,
useIsFloatingMobileToolbarEnabled,
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { Connect } from 'components/interfaces/Connect/Connect'
import { ConnectButton } from 'components/interfaces/ConnectButton/ConnectButton'
import { ConnectSheet } from 'components/interfaces/ConnectSheet/ConnectSheet'
import { LocalDropdown } from 'components/interfaces/LocalDropdown'
@@ -23,7 +22,6 @@ import { AnimatePresence, motion } from 'framer-motion'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { usePHFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM } from 'lib/constants'
import { ChevronLeft } from 'lucide-react'
import Link from 'next/link'
@@ -37,7 +35,6 @@ import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown'
import { HomeIcon } from './HomeIcon'
import { LocalVersionPopover } from './LocalVersionPopover'
import { MergeRequestButton } from './MergeRequestButton'
import type { ConnectSectionVariant } from '@/components/interfaces/ProjectHome/ConnectSection.config'
const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps<HTMLSpanElement>) => (
<span className={cn('text-border-stronger pr-2', className)} {...props}>
@@ -78,9 +75,6 @@ export const LayoutHeader = ({
const gitlessBranching = useIsBranching2Enabled()
const showFloatingMobileToolbar = useIsFloatingMobileToolbarEnabled()
const connectSectionVariant = usePHFlag<ConnectSectionVariant | false>('connectSection')
const isConnectSheetEnabled = connectSectionVariant === 'connect'
const [commandMenuEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, true)
const isAccountPage = router.pathname.startsWith('/account')
@@ -292,7 +286,7 @@ export const LayoutHeader = ({
</div>
</header>
{isConnectSheetEnabled ? <ConnectSheet /> : <Connect />}
<ConnectSheet />
</>
)
}

View File

@@ -8,7 +8,7 @@ export const useProjectApiUrl = (
{ projectRef }: { projectRef?: string },
{ enabled = true }: { enabled?: boolean } = {}
) => {
const { data } = useProjectAddonsQuery({ projectRef })
const { data } = useProjectAddonsQuery({ projectRef }, { enabled })
const hasCustomDomainsAddon = !!data?.selected_addons.find((x) => x.type === 'custom_domain')
const {

View File

@@ -8,7 +8,6 @@ const getInitialState = () => {
showProjectApiDocs: false,
showCreateBranchModal: false,
showAiSettingsModal: false,
showConnectDialog: false,
ongoingQueriesPanelOpen: false,
mobileMenuOpen: false,
showSidebar: true,

View File

@@ -1,51 +1,41 @@
import { expect } from '@playwright/test'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
test.describe('Connect', async () => {
test('Connect dialog opens when showConnect=true query param is present', async ({
page,
ref,
}) => {
test('ConnectSheet opens when showConnect=true query param is present', async ({ page, ref }) => {
// Navigate to project page with showConnect=true query param
await page.goto(toUrl(`/project/${ref}?showConnect=true`))
// Wait for the page to load
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 30000 })
// Check that either the Connect dialog or ConnectSheet is visible
// The Connect component renders a Dialog with title "Connect to your project"
// The ConnectSheet component renders a Sheet with title "Connect to your project"
await expect(
page.getByRole('heading', { name: 'Connect to your project' })
).toBeVisible({ timeout: 30000 })
// Check that the ConnectSheet is visible
await expect(page.getByRole('heading', { name: 'Connect to your project' })).toBeVisible({
timeout: 30000,
})
})
test('Connect dialog closes when dismissed', async ({ page, ref }) => {
test('ConnectSheet closes when dismissed', async ({ page, ref }) => {
// Navigate to project page with showConnect=true query param
await page.goto(toUrl(`/project/${ref}?showConnect=true`))
// Wait for the page to load
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 30000 })
// Wait for the ConnectSheet to be visible
await expect(page.getByRole('heading', { name: 'Connect to your project' })).toBeVisible({
timeout: 30000,
})
// Wait for the Connect dialog/sheet to be visible
await expect(
page.getByRole('heading', { name: 'Connect to your project' })
).toBeVisible({ timeout: 30000 })
// Close the dialog by pressing Escape
// Close the sheet by pressing Escape
await page.keyboard.press('Escape')
// Verify the dialog is no longer visible
await expect(
page.getByRole('heading', { name: 'Connect to your project' })
).not.toBeVisible({ timeout: 10000 })
// Verify the sheet is no longer visible
await expect(page.getByRole('heading', { name: 'Connect to your project' })).not.toBeVisible({
timeout: 10000,
})
// Verify the query param is removed from the URL
await expect(page).not.toHaveURL(/showConnect=true/)
})
test('Connect button in header opens the Connect dialog', async ({ page, ref }) => {
test('Connect button in header opens the ConnectSheet', async ({ page, ref }) => {
// Navigate to project page without the query param
await page.goto(toUrl(`/project/${ref}`))
@@ -55,10 +45,10 @@ test.describe('Connect', async () => {
// Click the Connect button in the header
await page.getByRole('button', { name: 'Connect' }).click()
// Verify the Connect dialog/sheet opens
await expect(
page.getByRole('heading', { name: 'Connect to your project' })
).toBeVisible({ timeout: 30000 })
// Verify the ConnectSheet opens
await expect(page.getByRole('heading', { name: 'Connect to your project' })).toBeVisible({
timeout: 30000,
})
// Verify the URL has the showConnect query param
await expect(page).toHaveURL(/showConnect=true/)

View File

@@ -1778,62 +1778,6 @@ export interface DpaPdfOpenedEvent {
}
}
/**
* User selected a workflow in the Getting Started section of HomeV2.
*
* @group Events
* @source studio
* @page /project/{ref}
*/
export interface HomeGettingStartedWorkflowClickedEvent {
action: 'home_getting_started_workflow_clicked'
properties: {
/**
* The workflow selected by the user
*/
workflow: 'code' | 'no_code'
/**
* Whether this is switching from another workflow
*/
is_switch: boolean
}
groups: TelemetryGroups
}
/**
* User clicked on a step in the Getting Started section of HomeV2.
*
* @group Events
* @source studio
* @page /project/{ref}
*/
export interface HomeGettingStartedStepClickedEvent {
action: 'home_getting_started_step_clicked'
properties: {
/**
* The workflow type (code or no-code)
*/
workflow: 'code' | 'no_code'
/**
* The step number (1-based index)
*/
step_number: number
/**
* The title of the step
*/
step_title: string
/**
* The action type of the button clicked
*/
action_type: 'primary' | 'ai_assist' | 'external_link'
/**
* Whether the step was already completed
*/
was_completed: boolean
}
groups: TelemetryGroups
}
/**
* User clicked on an activity stat in HomeV2.
*
@@ -1944,50 +1888,6 @@ export interface HomeCustomReportBlockRemovedEvent {
groups: TelemetryGroups
}
/**
* User dismissed the Getting Started section in HomeV2.
*
* @group Events
* @source studio
* @page /project/{ref}
*/
export interface HomeGettingStartedClosedEvent {
action: 'home_getting_started_closed'
properties: {
/**
* The current workflow when dismissed
*/
workflow: 'code' | 'no_code'
/**
* Number of steps completed when dismissed
*/
steps_completed: number
/**
* Total number of steps in the workflow
*/
total_steps: number
}
groups: TelemetryGroups
}
/**
* Getting Started section was shown to the user in HomeV2.
*
* @group Events
* @source studio
* @page /project/{ref}
*/
export interface HomeGettingStartedSectionExposedEvent {
action: 'home_getting_started_section_exposed'
properties: {
/**
* The current workflow shown (null if choosing workflow)
*/
workflow: 'code' | 'no_code' | null
}
groups: TelemetryGroups
}
/**
* User was exposed to the HomeV2 experiment (shown the new home page).
*
@@ -2007,7 +1907,7 @@ export interface HomeNewExperimentExposedEvent {
}
/**
* Connect section was shown to the user as part of the connectSection experiment.
* Connect section was shown to the user on the project homepage.
*
* @group Events
* @source studio
@@ -2015,12 +1915,6 @@ export interface HomeNewExperimentExposedEvent {
*/
export interface HomeConnectSectionExposedEvent {
action: 'home_connect_section_exposed'
properties: {
/**
* The experiment variant shown to the user
*/
variant: 'connect' | 'getting-started'
}
groups: TelemetryGroups
}
@@ -3174,10 +3068,6 @@ export type TelemetryEvent =
| BranchSelectorCreateClickedEvent
| BranchSelectorManageClickedEvent
| DpaPdfOpenedEvent
| HomeGettingStartedWorkflowClickedEvent
| HomeGettingStartedStepClickedEvent
| HomeGettingStartedClosedEvent
| HomeGettingStartedSectionExposedEvent
| HomeNewExperimentExposedEvent
| HomeConnectSectionExposedEvent
| HomeConnectActionClickedEvent