Files
supabase/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx
Pamela Chia 47c084e51d refactor(studio): migrate telemetry to useTrack (#46140)
## Summary

I migrated every `useSendEventMutation` call site in `apps/studio` to
`useTrack`, deleted the legacy hook, and added a lint guardrail so it
can't return. `useTrack` is the type-safe replacement: it auto-injects
`groups: { project, organization }` from the selected project/org and
types `action` + `properties` against `TelemetryEvent`. Existing call
sites built groups manually and were not type-checked at the action
level. The migration covers 81 files (60 trivial swaps, 9 org-only, 3
pre-auth, 5 bespoke, 4 test mocks).

## Changes

- Migrated trivial call sites across `pages/project/[ref]`,
`components/interfaces/*` (Reports, Storage, Realtime/Inspector,
SQLEditor, Functions, EdgeFunctions, Integrations, ProjectAPIDocs,
Branching/BranchManagement, TableGridEditor, Connect, Docs, Auth,
Support, Home, ProjectHome, App), `components/layouts/*`, and
`components/ui/*`.
- Migrated org-only sites (`Organization/Documents/*`,
`Organization/BillingSettings/Subscription/*`,
`Organization/SecuritySettings.tsx`,
`Account/Preferences/DashboardSettingsToggles.tsx`) by dropping the
manual `groups: { organization: ... }` and letting `useTrack`
auto-inject. Verified `useSelectedProjectQuery` is disabled on org
routes (gates on URL `[ref]`).
- Migrated pre-auth sites (`SignInForm.tsx`, `sign-in-mfa.tsx`,
`profile.tsx`) where neither project nor org is resolved.
- Bespoke handling:
- `execute-sql-mutation.ts` and `table-row-create-mutation.ts`: pass `{
project: projectRef }` via `groupOverrides` since the mutation can
target a non-selected project ref.
- `useStudioCommandMenuTelemetry.ts`: kept a direct `sendTelemetryEvent`
call because studio groups must override pre-built event groups
(opposite of `useTrack`'s override direction).
- `AIAssistantOption.tsx`: passes sentinel-aware `groupOverrides` so
`NO_PROJECT_MARKER`/`NO_ORG_MARKER` continue to suppress group emission.
- `SidePanelEditor.utils.tsx`: utility functions `createTable` and
`updateTable` now take a `track: Track` parameter (threaded from
`SidePanelEditor.tsx`); dropped the `organizationSlug` arg since groups
are no longer assembled manually.
- Branch-event attribution: preserved `parentProjectRef` overrides on
`branch_updated`, `branch_merge_completed`, `branch_merge_failed`,
`branch_merge_submitted`, `branch_delete_button_clicked`,
`branch_review_with_assistant_clicked`, and
`branch_*_merge_request_button_clicked`. Original code grouped these
under the parent (production) project, not the branch ref;
auto-injection would have shifted them onto the branch.
- Switched 4 test mocks from `@/data/telemetry/send-event-mutation` to
`@/lib/telemetry/track`. Removed obsolete tests around manual groups and
`try/catch` on telemetry rejection.
- Deleted `apps/studio/data/telemetry/send-event-mutation.ts`. The
deleted module is its own guardrail: any reintroduction of the import
fails at TypeScript module resolution before lint runs.

## Testing

Tested on preview deploy:

- [x] SQL editor `CREATE TABLE` fires `table_created` with method
`sql_editor` and `groups.project` set to the mutation's `projectRef`.
- [x] Table editor creates a table from the side panel; `table_created`
fires from `SidePanelEditor.utils` via threaded `track`.
- [x] Help button (`/project/[ref]/...`) fires `help_button_clicked`
with auto-injected project + org groups.
- [x] Sign-in form fires `sign_in` with empty groups (pre-auth,
expected).
- [x] Org documents page (`/org/[slug]/documents`) fires
`document_view_button_clicked` with org group only, no stale project
ref.
- [x] Command menu (`Cmd+K`) inside a project still fires
`command_menu_opened` with studio's project/org overriding any
event-supplied groups.
- [x] Support form "Ask the Assistant" without selected org fires
`ai_assistant_in_support_form_clicked` with no project/org groups
(sentinels suppress).
- [x] On a branch, "Update branch" / "Merge branch" / "Close merge
request" events fire with `groups.project` set to the parent project
ref, not the branch ref.

Local checks:
- [x] 22/22 tests pass across the 4 updated test files
(`SidePanelEditor.utils.createTable`, `EdgeFunctionRenderer`,
`LayoutSidebar`, `PlanUpdateSidePanel`).
- [x] `rg useSendEventMutation apps/studio` returns 0 hits.

## Linear
- fixes GROWTH-860


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Standardized telemetry across the Studio to a unified tracking system;
events now send simplified payloads with less contextual/grouping data.
* No user-facing flows changed; UI behavior, permissions, and
interactions remain the same.
* **Tests**
* Updated telemetry mocks and tests to align with the new tracking
approach.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46140?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-27 15:19:54 +08:00

635 lines
26 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useDebounce } from '@uidotdev/usehooks'
import { useFlag, useParams } from 'common'
import { Check, DatabaseZap, DollarSign, Github, GitMerge, Loader2 } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Badge,
Button,
cn,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
Form,
FormControl,
FormField,
Input,
Label,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import {
estimateComputeSize,
estimateDiskCost,
estimateRestoreTime,
} from './BranchManagement.utils'
import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
import { BranchingPITRNotice } from '@/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice'
import AlertError from '@/components/ui/AlertError'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { DiskAttributesData, useDiskAttributesQuery } from '@/data/config/disk-attributes-query'
import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query'
import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
import { projectKeys } from '@/data/projects/keys'
import { DesiredInstanceSize, instanceSizeSpecs } from '@/data/projects/new-project.constants'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
import { useAppStateSnapshot } from '@/state/app-state'
export const CreateBranchModal = () => {
const { ref } = useParams()
const router = useRouter()
const queryClient = useQueryClient()
const { data: projectDetails } = useSelectedProjectQuery()
const { data: selectedOrg } = useSelectedOrganizationQuery()
const { showCreateBranchModal, setShowCreateBranchModal } = useAppStateSnapshot()
const allowDataBranching = useFlag('allowDataBranching')
const [isGitBranchValid, setIsGitBranchValid] = useState(false)
const { can: canCreateBranch } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'preview_branches'
)
const { hasAccess: hasAccessToBranching, isLoading: isLoadingEntitlement } =
useCheckEntitlements('branching_limit')
const promptPlanUpgrade = IS_PLATFORM && !hasAccessToBranching
const isBranch = projectDetails?.parent_project_ref !== undefined
const projectRef =
projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined
const formId = 'create-branch-form'
const FormSchema = z.object({
branchName: z
.string()
.min(1, 'Branch name cannot be empty')
.refine(
(val) => /^[a-zA-Z0-9\-_]+$/.test(val),
'Branch name can only contain alphanumeric characters, hyphens, and underscores.'
)
.refine(
(val) => (branches ?? []).every((branch) => branch.name !== val),
'A branch with this name already exists'
),
gitBranchName: z.string().optional(),
withData: z.boolean().default(false).optional(),
})
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onSubmit',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues: { branchName: '', gitBranchName: '', withData: false },
})
const { withData, gitBranchName } = form.watch()
const debouncedGitBranchName = useDebounce(gitBranchName, 500)
const {
data: connections,
error: connectionsError,
isPending: isLoadingConnections,
isSuccess: isSuccessConnections,
isError: isErrorConnections,
} = useGitHubConnectionsQuery(
{ organizationId: selectedOrg?.id },
{ enabled: showCreateBranchModal }
)
const { data: branches } = useBranchesQuery({ projectRef })
const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery(
{ projectRef },
{ enabled: showCreateBranchModal }
)
const computeAddon = addons?.selected_addons.find((addon) => addon.type === 'compute_instance')
const computeSize = computeAddon
? (computeAddon.variant.identifier.split('ci_')[1] as DesiredInstanceSize)
: undefined
const hasPitrEnabled = (addons?.selected_addons ?? []).some((addon) => addon.type === 'pitr')
const {
data: disk,
isPending: isLoadingDiskAttr,
isError: isErrorDiskAttr,
} = useDiskAttributesQuery({ projectRef }, { enabled: showCreateBranchModal && withData })
const projectDiskAttributes = disk?.attributes ?? {
type: 'gp3',
size_gb: 0,
iops: 0,
throughput_mbps: 0,
}
// Branch disk is oversized to include backup files, it should be scaled back eventually.
const branchDiskAttributes = {
...projectDiskAttributes,
// [Joshen] JFYI for Qiao - this multiplier may eventually be dropped
size_gb: Math.round(projectDiskAttributes.size_gb * 1.5),
} as DiskAttributesData['attributes']
const branchComputeSize = estimateComputeSize(projectDiskAttributes.size_gb, computeSize)
const estimatedDiskCost = estimateDiskCost(branchDiskAttributes)
const track = useTrack()
const { mutate: checkGithubBranchValidity, isPending: isCheckingGHBranchValidity } =
useCheckGithubBranchValidity({
onError: () => {},
})
const { mutate: createBranch, isPending: isCreatingBranch } = useBranchCreateMutation({
onSuccess: async (data) => {
toast.success(`Successfully created preview branch "${data.name}"`)
if (projectRef) {
await queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectRef) })
}
track('branch_create_button_clicked', {
branchType: data.persistent ? 'persistent' : 'preview',
gitlessBranching: !data.git_branch,
})
setShowCreateBranchModal(false)
router.push(`/project/${data.project_ref}`)
},
onError: (error) => {
toast.error(`Failed to create branch: ${error.message}`)
},
})
// Fetch production/default branch to inspect git_branch linkage
const githubConnection = connections?.find((connection) => connection.project.ref === projectRef)
const prodBranch = branches?.find((branch) => branch.is_default)
const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? []
const isFormValid = form.formState.isValid && (!gitBranchName || isGitBranchValid)
const isDisabled =
!isFormValid ||
!canCreateBranch ||
!isSuccessAddons ||
(!!gitBranchName && !isSuccessConnections) ||
isLoadingEntitlement ||
!hasAccessToBranching ||
isCreatingBranch ||
isCheckingGHBranchValidity
const tooltipText = promptPlanUpgrade ? 'Upgrade to unlock branching' : undefined
const validateGitBranchName = useCallback(
(branchName: string) => {
if (!githubConnection) {
return console.error(
'[CreateBranchModal > validateGitBranchName] GitHub Connection is missing'
)
}
const repositoryId = githubConnection.repository.id
checkGithubBranchValidity(
{ repositoryId, branchName },
{
onSuccess: () => {
if (form.getValues('gitBranchName') !== branchName) return
// Check if another branch is already linked to this git branch
const existingBranch = (branches ?? []).find((b) => b.git_branch === branchName)
if (existingBranch) {
setIsGitBranchValid(false)
form.setError('gitBranchName', {
message: `Branch "${existingBranch.name}" is already linked to git branch "${branchName}"`,
})
return
}
setIsGitBranchValid(true)
form.clearErrors('gitBranchName')
},
onError: (error) => {
if (form.getValues('gitBranchName') !== branchName) return
setIsGitBranchValid(false)
form.setError('gitBranchName', {
message:
error?.message ??
`Unable to find branch "${branchName}" in ${repoOwner}/${repoName}`,
})
},
}
)
},
[githubConnection, form, checkGithubBranchValidity, repoOwner, repoName, branches]
)
const onSubmit = (data: z.infer<typeof FormSchema>) => {
if (!projectRef) return console.error('Project ref is required')
createBranch({
projectRef,
branchName: data.branchName,
is_default: false,
...(data.withData ? { desired_instance_size: computeSize } : {}),
...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}),
...(allowDataBranching ? { withData: data.withData } : {}),
})
}
const handleGitHubClick = () => {
setShowCreateBranchModal(false)
router.push(`/project/${projectRef}/settings/integrations`)
}
useEffect(() => {
if (showCreateBranchModal) form.reset()
}, [form, showCreateBranchModal])
useEffect(() => {
form.clearErrors('gitBranchName')
if (githubConnection && debouncedGitBranchName) validateGitBranchName(debouncedGitBranchName)
}, [debouncedGitBranchName, validateGitBranchName, form, githubConnection])
return (
<Dialog open={showCreateBranchModal} onOpenChange={setShowCreateBranchModal}>
<DialogContent
size="large"
hideClose
onOpenAutoFocus={(e) => {
if (promptPlanUpgrade) e.preventDefault()
}}
aria-describedby={undefined}
>
<DialogHeader padding="small">
<DialogTitle>Create a new preview branch</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
{promptPlanUpgrade && (
<UpgradeToPro
fullWidth
layout="vertical"
source="create-branch"
featureProposition="enable branching"
primaryText="Upgrade to unlock branching"
secondaryText="Create and test schema changes, functions, and more in a separate, temporary instance without affecting production."
className="pb-5"
/>
)}
<DialogSection
padding="medium"
className={cn('space-y-4', promptPlanUpgrade && 'opacity-25 pointer-events-none')}
>
<FormField
control={form.control}
name="branchName"
render={({ field }) => (
<FormItemLayout label="Preview Branch Name">
<FormControl>
<Input
{...field}
placeholder="e.g. staging, dev-feature-x"
autoComplete="off"
/>
</FormControl>
</FormItemLayout>
)}
/>
{isLoadingConnections && (
<div className="flex flex-col gap-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{isErrorConnections && (
<AlertError
error={connectionsError}
subject="Failed to retrieve GitHub connection information"
/>
)}
{isSuccessConnections &&
(githubConnection ? (
<FormField
control={form.control}
name="gitBranchName"
render={({ field }) => (
<FormItemLayout
label={
<div className="flex items-center justify-between w-full gap-4">
<span className="flex-1">Sync with Git branch</span>
<div className="flex items-center gap-2 text-sm">
<Image
className={cn('dark:invert')}
src={`${BASE_PATH}/img/icons/github-icon.svg`}
width={16}
height={16}
alt={`GitHub icon`}
/>
<Link
href={`https://github.com/${repoOwner}/${repoName}`}
target="_blank"
rel="noreferrer"
className="text-foreground hover:underline"
>
{repoOwner}/{repoName}
</Link>
</div>
</div>
}
labelOptional="Optional"
description="Automatically deploy changes on every commit"
>
<div className="relative w-full">
<FormControl>
<Input
{...field}
placeholder="e.g. main, feat/some-feature"
autoComplete="off"
onChange={(e) => {
field.onChange(e)
setIsGitBranchValid(false)
}}
/>
</FormControl>
<div className="absolute top-2.5 right-3 flex items-center gap-2">
{field.value ? (
isCheckingGHBranchValidity ? (
<Loader2 size={14} className="animate-spin" />
) : isGitBranchValid ? (
<Check size={14} className="text-brand" strokeWidth={2} />
) : null
) : null}
</div>
</div>
</FormItemLayout>
)}
/>
) : (
<div className="flex items-center gap-2 justify-between">
<div className="flex flex-col gap-1">
<Label>Sync with a GitHub branch</Label>
<p className="text-sm text-foreground-lighter">
Keep this preview branch in sync with a chosen GitHub branch
</p>
</div>
<Button type="default" icon={<Github />} onClick={handleGitHubClick}>
Configure
</Button>
</div>
))}
{allowDataBranching && (
<FormField
control={form.control}
name="withData"
render={({ field }) => (
<FormItemLayout
label={
<>
<Label className="mr-2">Include data</Label>
{!hasPitrEnabled && <Badge variant="warning">Requires PITR</Badge>}
</>
}
layout="flex-row-reverse"
className="[&>div>label]:mb-1"
description="Clone production data into this branch"
>
<FormControl>
<Switch
disabled={!hasPitrEnabled}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItemLayout>
)}
/>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection
padding="medium"
className={cn(
'flex flex-col gap-4',
promptPlanUpgrade && 'opacity-25 pointer-events-none'
)}
>
{withData && (
<div className="flex flex-row gap-4">
<div>
<figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
<DatabaseZap className="text-info" size={20} strokeWidth={2} />
</figure>
</div>
<div className="flex flex-col gap-y-1">
{isLoadingDiskAttr ? (
<>
<ShimmeringLoader className="w-32 h-5 py-0" />
<ShimmeringLoader className="w-72 h-8 py-0" />
</>
) : (
<>
{isErrorDiskAttr ? (
<>
<p className="text-sm text-foreground">
Branch disk size will incur additional cost per month
</p>
<p className="text-sm text-foreground-light">
The additional cost and time taken to create a data branch is relative
to the size of your database. We are unable to provide an estimate as
we were unable to retrieve your project's disk configuration
</p>
</>
) : (
<>
<p className="text-sm text-foreground">
Branch disk size is billed at ${estimatedDiskCost.total.toFixed(2)}{' '}
per month
</p>
<p className="text-sm text-foreground-light">
Creating a data branch will take about{' '}
<span className="text-foreground">
{estimateRestoreTime(branchDiskAttributes).toFixed()} minutes
</span>{' '}
and costs{' '}
<span className="text-foreground">
${estimatedDiskCost.total.toFixed(2)}
</span>{' '}
per month based on your current target database volume size of{' '}
{branchDiskAttributes.size_gb} GB and your{' '}
<Tooltip>
<TooltipTrigger>
<span className={InlineLinkClassName}>
project's disk configuration
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="flex items-center gap-x-2">
<p className="w-24">Disk type:</p>
<p className="w-16">
{branchDiskAttributes.type.toUpperCase()}
</p>
</div>
<div className="flex items-center gap-x-2">
<p className="w-24">Target disk size:</p>
<p className="w-16">{branchDiskAttributes.size_gb} GB</p>
<p>(${estimatedDiskCost.size.toFixed(2)})</p>
</div>
<div className="flex items-center gap-x-2">
<p className="w-24">IOPs:</p>
<p className="w-16">{branchDiskAttributes.iops} IOPS</p>
<p>(${estimatedDiskCost.iops.toFixed(2)})</p>
</div>
{'throughput_mbps' in branchDiskAttributes && (
<div className="flex items-center gap-x-2">
<p className="w-24">Throughput:</p>
<p className="w-16">
{branchDiskAttributes.throughput_mbps} MB/s
</p>
<p>(${estimatedDiskCost.throughput.toFixed(2)})</p>
</div>
)}
<p className="mt-2">
More info in{' '}
<InlineLink
onClick={() => setShowCreateBranchModal(false)}
className="pointer-events-auto"
href={`/project/${ref}/settings/compute-and-disk`}
>
Compute and Disk
</InlineLink>
</p>
</TooltipContent>
</Tooltip>
.
</p>
</>
)}
</>
)}
</div>
</div>
)}
{githubConnection && (
<div className="flex flex-row gap-4">
<div>
<figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
<GitMerge className="text-info" size={20} strokeWidth={2} />
</figure>
</div>
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground">
{prodBranch?.git_branch
? 'Merging to production enabled'
: 'Merging to production disabled'}
</p>
<p className="text-sm text-foreground-light">
{prodBranch?.git_branch ? (
<>
When this branch is merged to{' '}
<span className="text-foreground">{prodBranch.git_branch}</span>,
migrations will be deployed to production. Otherwise, migrations only run
on preview branches.
</>
) : (
<>
Merging this branch to production will not deploy migrations. To enable
production deployment, enable "Deploy to production" in project
integration settings.
</>
)}
</p>
</div>
</div>
)}
<div className="flex flex-row gap-4">
<div>
<figure className="w-10 h-10 rounded-md bg-info-200 border border-info-400 flex items-center justify-center">
<DollarSign className="text-info" size={20} strokeWidth={2} />
</figure>
</div>
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground">
Branch compute is billed at $
{withData ? branchComputeSize.priceHourly : instanceSizeSpecs.micro.priceHourly}{' '}
per hour
</p>
<p className="text-sm text-foreground-light">
{withData ? (
<>
<code className="text-code-inline">{branchComputeSize.label}</code> compute
size is automatically selected to match your production branch. You may
downgrade after creation or pause the branch when not in use to save cost.
</>
) : (
<>This cost will continue for as long as the branch has not been removed.</>
)}
</p>
</div>
</div>
{!hasPitrEnabled && <BranchingPITRNotice />}
<TaxDisclaimer />
</DialogSection>
<DialogFooter className="justify-end gap-2" padding="medium">
<Button
type="default"
disabled={isCreatingBranch}
onClick={() => setShowCreateBranchModal(false)}
>
Cancel
</Button>
<ButtonTooltip
form={formId}
disabled={isDisabled}
loading={isCreatingBranch}
type={promptPlanUpgrade ? 'default' : 'primary'}
htmlType="submit"
tooltip={{
content: {
side: 'bottom',
text: tooltipText,
},
}}
>
Create branch
</ButtonTooltip>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}