mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 22:18:00 +08:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,7 +8,6 @@ const getInitialState = () => {
|
||||
showProjectApiDocs: false,
|
||||
showCreateBranchModal: false,
|
||||
showAiSettingsModal: false,
|
||||
showConnectDialog: false,
|
||||
ongoingQueriesPanelOpen: false,
|
||||
mobileMenuOpen: false,
|
||||
showSidebar: true,
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user