From daf8b3c3fd76ac1406b5310d0bfca73db1918d4e Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Mon, 9 Jun 2025 11:04:10 +1000 Subject: [PATCH] Create branch without GitHub connection (#35983) * allow creating branching without git * update branching modals * add account connections * edit branch * copy * update copy * enable branch modal changes * add gitless branching flag * update account connections * update pull requests empty state * Clean up * refinements to gitless branching * nit * nit --------- Co-authored-by: Joshen Lim --- .../Preferences/AccountConnections.tsx | 82 +++ .../BranchManagement/BranchManagement.tsx | 8 +- .../BranchManagement/BranchPanels.tsx | 101 ++-- .../BranchManagement/CreateBranchModal.tsx | 350 ++++++++----- .../BranchManagement/EditBranchModal.tsx | 306 +++++++++++ .../BranchManagement/EmptyStates.tsx | 76 +-- .../VercelGithub/IntegrationConnection.tsx | 12 +- .../BranchingPITRNotice.tsx | 76 +-- .../BranchingPlanNotice.tsx | 6 +- .../BranchingPostgresVersionNotice.tsx | 8 +- .../EnableBranchingModal.tsx | 484 ++++++++++++------ .../layouts/ProjectLayout/ProjectLayout.tsx | 2 +- .../data/branches/branch-create-mutation.ts | 3 - apps/studio/pages/account/me.tsx | 5 + 14 files changed, 1091 insertions(+), 428 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx create mode 100644 apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx new file mode 100644 index 00000000000..3e586cf972d --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountConnections.tsx @@ -0,0 +1,82 @@ +import { Github } from 'lucide-react' +import Image from 'next/image' + +import Panel from 'components/ui/Panel' +import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query' +import { BASE_PATH } from 'lib/constants' +import { openInstallGitHubIntegrationWindow } from 'lib/github' +import { Badge, Button, cn } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +const AccountConnections = () => { + const { + data: gitHubAuthorization, + isLoading, + isSuccess, + isError, + error, + } = useGitHubAuthorizationQuery() + + const isConnected = gitHubAuthorization !== null + + const handleConnect = () => { + openInstallGitHubIntegrationWindow('authorize') + } + + return ( + +
Connections
+

+ Connect your Supabase account with other services +

+ + } + > + {isLoading && ( + + + + )} + {isError && ( + +

+ Failed to load GitHub connection status: {error?.message} +

+
+ )} + {isSuccess && ( + +
+ {`GitHub +
+

GitHub

+

+ Sync GitHub repos to Supabase projects for automatic branch creation and merging +

+
+
+
+ {isConnected ? ( + Connected + ) : ( + + )} +
+
+ )} +
+ ) +} + +export { AccountConnections } diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 3928bea2096..4c15961ef16 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -19,12 +19,13 @@ import { useGitHubConnectionsQuery } from 'data/integrations/github-connections- import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' import { useUrlState } from 'hooks/ui/useUrlState' import { Button } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' import { BranchLoader, BranchManagementSection, BranchRow } from './BranchPanels' -import CreateBranchModal from './CreateBranchModal' +import { CreateBranchModal } from './CreateBranchModal' import { BranchingEmptyState, PreviewBranchesEmptyState, @@ -39,6 +40,7 @@ const BranchManagement = () => { const { ref } = useParams() const project = useSelectedProject() const selectedOrg = useSelectedOrganization() + const gitlessBranching = useFlag('gitlessBranching') const hasBranchEnabled = project?.is_branch_enabled @@ -217,7 +219,7 @@ const BranchManagement = () => { /> )} - {isSuccessConnections && ( + {isSuccessConnections && !gitlessBranching && (
@@ -305,6 +307,8 @@ const BranchManagement = () => { 0} + githubConnection={githubConnection} + gitlessBranching={gitlessBranching} /> )} diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 84e378c3653..258abac83f3 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -5,8 +5,10 @@ import { Clock, ExternalLink, GitPullRequest, + Github, Infinity, MoreVertical, + Pencil, RefreshCw, Shield, Trash2, @@ -24,6 +26,7 @@ import { useBranchResetMutation } from 'data/branches/branch-reset-mutation' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import type { Branch } from 'data/branches/branches-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useFlag } from 'hooks/ui/useFlag' import { Badge, Button, @@ -37,6 +40,7 @@ import { } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import BranchStatusBadge from './BranchStatusBadge' +import { EditBranchModal } from './EditBranchModal' import WorkflowLogs from './WorkflowLogs' interface BranchManagementSectionProps { @@ -104,8 +108,10 @@ export const BranchRow = ({ }: BranchRowProps) => { const { ref: projectRef } = useParams() const isActive = projectRef === branch?.project_ref + const gitlessBranching = useFlag('gitlessBranching') const canDeleteBranches = useCheckPermissions(PermissionAction.DELETE, 'preview_branches') + const canUpdateBranches = useCheckPermissions(PermissionAction.UPDATE, 'preview_branches') const daysFromNow = dayjs().diff(dayjs(branch.updated_at), 'day') const formattedTimeFromNow = dayjs(branch.updated_at).fromNow() @@ -133,6 +139,7 @@ export const BranchRow = ({ const [showConfirmResetModal, setShowConfirmResetModal] = useState(false) const [showBranchModeSwitch, setShowBranchModeSwitch] = useState(false) + const [showEditBranchModal, setShowEditBranchModal] = useState(false) const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ onSuccess() { @@ -178,7 +185,7 @@ export const BranchRow = ({ text: branch.persistent ? `${branch.name} is a persistent branch and will remain active even after the underlying PR is closed` - : undefined, + : 'Switch to branch', }, }} > @@ -187,6 +194,8 @@ export const BranchRow = ({ + {branch.git_branch && } + {isActive && Current} ) : (
- {branch.pr_number === undefined ? ( + {branch.git_branch && branch.pr_number === undefined ? ( - ) : ( + ) : branch.pr_number !== undefined ? (
- )} + ) : null} @@ -285,32 +294,60 @@ export const BranchRow = ({ )} - - - setShowBranchModeSwitch(true)} - onClick={() => setShowBranchModeSwitch(true)} - disabled={!isBranchActiveHealthy} + {branch.git_branch && ( + + + setShowBranchModeSwitch(true)} + onClick={() => setShowBranchModeSwitch(true)} + disabled={!isBranchActiveHealthy} + > + {branch.persistent ? ( + <> + Switch to ephemeral + + ) : ( + <> + Switch to persistent + + )} + + + {!isBranchActiveHealthy && ( + + Branch is still initializing. Please wait for the branch to become healthy + before switching modes + + )} + + )} + + {gitlessBranching && ( + + - {branch.persistent ? ( - <> - Switch to ephemeral - - ) : ( - <> - Switch to persistent - - )} - - - {!isBranchActiveHealthy && ( - - Branch is still initializing. Please wait for the branch to become healthy - before switching modes - - )} - + setShowEditBranchModal(true)} + onClick={() => setShowEditBranchModal(true)} + > + + Edit Branch + + + {(!canUpdateBranches || !isBranchActiveHealthy) && ( + + {!canUpdateBranches + ? 'You need additional permissions to edit branches' + : 'Branch is still initializing. Please wait for the branch to become healthy before editing.'} + + )} + + )} @@ -373,6 +410,12 @@ export const BranchRow = ({
)}
+ + setShowEditBranchModal(false)} + />
) } diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index db71fa16422..fd981309aa0 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' -import { Check, ExternalLink, Loader2 } from 'lucide-react' +import { Check, DollarSign, Github, Loader2 } from 'lucide-react' +import Image from 'next/image' import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' @@ -15,32 +16,42 @@ import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-ch import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' +import { BASE_PATH } from 'lib/constants' +import { sidePanelsState } from 'state/side-panels' import { + Badge, Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, FormControl_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, FormMessage_Shadcn_, Form_Shadcn_, Input_Shadcn_, - Modal, + Label_Shadcn_ as Label, + cn, } from 'ui' -import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' interface CreateBranchModalProps { visible: boolean onClose: () => void } -const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { +export const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { const { ref } = useParams() const projectDetails = useSelectedProject() const selectedOrg = useSelectedOrganization() + const gitlessBranching = useFlag('gitlessBranching') - // [Joshen] There's something weird with RHF that I can't figure out atm - // but calling form.formState.isValid somehow removes the onBlur check, - // and makes the validation run onChange instead. This is a workaround - const [isValid, setIsValid] = useState(false) + const [isGitBranchValid, setIsGitBranchValid] = useState(false) const isBranch = projectDetails?.parent_project_ref !== undefined const projectRef = @@ -63,174 +74,233 @@ const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { }) const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ - onSuccess: () => { - toast.success('Successfully created new branch') + onSuccess: (data) => { + toast.success(`Successfully created preview branch "${data.name}"`) onClose() }, + onError: (error) => { + toast.error(`Failed to create branch: ${error.message}`) + }, }) - const githubConnection = connections?.find( - (connection) => connection.project.ref === projectDetails?.parentRef - ) + const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] + const isBranchingEnabled = gitlessBranching || !!githubConnection + const formId = 'create-branch-form' - const FormSchema = z.object({ - branchName: z.string().superRefine(async (val, ctx) => { - if ((branches ?? []).some((branch) => branch.git_branch === val)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'This branch already has a Preview Branch', - }) - return - } - - if (val.length > 0) { + 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() + .refine( + (val) => !githubConnection?.id || (val && val.length > 0), + 'Git branch name is required when Git is connected' + ), + }) + .superRefine(async (val, ctx) => { + if (val.gitBranchName && val.gitBranchName.length > 0 && githubConnection?.id) { try { - if (!githubConnection?.id) throw new Error('No GitHub connection found') - await checkGithubBranchValidity({ connectionId: githubConnection.id, - branchName: val, + branchName: val.gitBranchName, }) - setIsValid(true) + setIsGitBranchValid(true) } catch (error) { + setIsGitBranchValid(false) ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Unable to find branch from ${repoOwner}/${repoName}`, + message: `Unable to find branch "${val.gitBranchName}" in ${repoOwner}/${repoName}`, + path: ['gitBranchName'], }) - setIsValid(false) - return } + } else { + setIsGitBranchValid(!val.gitBranchName || val.gitBranchName.length === 0) } - }), - }) + }) + const form = useForm>({ mode: 'onBlur', - reValidateMode: 'onBlur', + reValidateMode: 'onChange', resolver: zodResolver(FormSchema), - defaultValues: { branchName: '' }, + defaultValues: { branchName: '', gitBranchName: '' }, }) - const canSubmit = form.getValues('branchName').length > 0 && !isChecking && isValid + const isFormValid = + form.formState.isValid && (!form.getValues('gitBranchName') || isGitBranchValid) + const canSubmit = isFormValid && !isCreating && !isChecking && isBranchingEnabled + const onSubmit = (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') - createBranch({ projectRef, branchName: data.branchName, gitBranch: data.branchName }) + createBranch({ + projectRef, + branchName: data.branchName, + ...(data.gitBranchName && isGitBranchValid ? { gitBranch: data.gitBranchName } : {}), + }) } useEffect(() => { if (form && visible) { - setIsValid(false) + setIsGitBranchValid(false) form.reset() } }, [form, visible]) + useEffect(() => { + setIsGitBranchValid( + !form.getValues('gitBranchName') || form.getValues('gitBranchName')?.length === 0 + ) + }, [githubConnection?.id, form.getValues('gitBranchName')]) + + const openLinkerPanel = () => { + onClose() + sidePanelsState.setGithubConnectionsOpen(true) + } + return ( - -
setIsValid(false)} - > - - - {isLoadingConnections && } - {isErrorConnections && ( - !open && onClose()}> + + + Create a new preview branch + + + + + + ( + + + + + + )} /> - )} - {isSuccessConnections && ( -
-

- Your project is currently connected to the repository: -

-
-

{githubConnection?.repository.name}

- - - -
-
- )} -
- - - -

- Choose a Git Branch to base your Preview Branch on. Any migration changes added to - this Git Branch will be run on this new Preview Branch. -

- ( - - - - - -
- {isChecking ? ( - - ) : isValid ? ( - - ) : null} -
- - -
+ {githubConnection && ( + ( + +
+ +
+ {`GitHub + + {repoOwner}/{repoName} + +
+
+
+ + + +
+ {isChecking && } + {field.value && !isChecking && isGitBranchValid && ( + + )} +
+
+

+ If linked, migrations from this Git branch will be automatically deployed. +

+ +
+ )} + /> )} - /> -
+ {isLoadingConnections && } + {isErrorConnections && ( + + )} + {isSuccessConnections && ( + <> + {!githubConnection && ( +
+
+
+ + {!gitlessBranching && ( + + Required + + )} +
+

+ {gitlessBranching + ? 'Optionally connect to a GitHub repository to manage migrations automatically for this branch.' + : 'Connect to a GitHub repository to enable branch creation. This allows you to manage migrations automatically for this branch.'} +

+
+ +
+ )} + + )} + - - - - - - - - - - - - -
-
-
+ +

+ + Each preview branch costs $0.32 per day +

+
+ + +
+
+ + + + ) } - -export default CreateBranchModal diff --git a/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx new file mode 100644 index 00000000000..1f058c0ee99 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/EditBranchModal.tsx @@ -0,0 +1,306 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { Check, Github, Loader2 } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' +import { Branch, useBranchesQuery } from 'data/branches/branches-query' +import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' +import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { BASE_PATH } from 'lib/constants' +import { sidePanelsState } from 'state/side-panels' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormMessage_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + Label_Shadcn_ as Label, + cn, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface EditBranchModalProps { + branch?: Branch + visible: boolean + onClose: () => void +} + +export const EditBranchModal = ({ branch, visible, onClose }: EditBranchModalProps) => { + const { ref } = useParams() + const projectDetails = useSelectedProject() + const selectedOrg = useSelectedOrganization() + + const [isGitBranchValid, setIsGitBranchValid] = useState(false) + + const isBranch = projectDetails?.parent_project_ref !== undefined + const projectRef = + projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined + + const { + data: connections, + error: connectionsError, + isLoading: isLoadingConnections, + isSuccess: isSuccessConnections, + isError: isErrorConnections, + } = useGitHubConnectionsQuery({ + organizationId: selectedOrg?.id, + }) + + const { data: branches } = useBranchesQuery({ projectRef }) + const { mutateAsync: checkGithubBranchValidity, isLoading: isChecking } = + useCheckGithubBranchValidity({ + onError: () => {}, + }) + + const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ + onSuccess: (data) => { + toast.success(`Successfully updated branch "${data.name}"`) + onClose() + }, + onError: (error) => { + toast.error(`Failed to update branch: ${error.message}`) + }, + }) + + const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) + const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] + + const formId = 'edit-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) => + // Allow the current branch name during edit + val === branch?.name || (branches ?? []).every((b) => b.name !== val), + 'A branch with this name already exists' + ), + gitBranchName: z.string().optional(), + }) + .superRefine(async (val, ctx) => { + if (val.gitBranchName && val.gitBranchName.length > 0 && githubConnection?.id) { + try { + await checkGithubBranchValidity({ + connectionId: githubConnection.id, + branchName: val.gitBranchName, + }) + setIsGitBranchValid(true) + } catch (error) { + setIsGitBranchValid(false) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unable to find branch "${val.gitBranchName}" in ${repoOwner}/${repoName}`, + path: ['gitBranchName'], + }) + } + } else { + // If git branch is empty or removed, it's valid + setIsGitBranchValid(!val.gitBranchName || val.gitBranchName.length === 0) + } + }) + + const form = useForm>({ + mode: 'onBlur', + reValidateMode: 'onChange', + resolver: zodResolver(FormSchema), + defaultValues: { branchName: '', gitBranchName: '' }, + }) + + const isFormValid = + form.formState.isValid && (!form.getValues('gitBranchName') || isGitBranchValid) + const canSubmit = isFormValid && !isUpdating && !isChecking + + const onSubmit = (data: z.infer) => { + if (!projectRef) return console.error('Project ref is required') + if (!branch?.id) return console.error('Branch ID is required') + + const payload: { + projectRef: string + id: string + branchName: string + gitBranch?: string + } = { + projectRef, + id: branch.id, + branchName: data.branchName, + } + + // Only add gitBranch to the payload if it is present and valid + // If gitBranchName is empty or invalid, gitBranch remains undefined in the payload + if (data.gitBranchName && isGitBranchValid) { + payload.gitBranch = data.gitBranchName + } + + updateBranch(payload) + } + + // Pre-fill form when the modal becomes visible and branch data is available + useEffect(() => { + if (visible && branch) { + setIsGitBranchValid(!!branch.git_branch) // Initial validity based on existing link + form.reset({ + branchName: branch.name ?? '', + gitBranchName: branch.git_branch ?? '', + }) + } + }, [branch, visible, form]) + + // Handle initial state and changes for git branch validity + useEffect(() => { + setIsGitBranchValid( + !form.getValues('gitBranchName') || form.getValues('gitBranchName')?.length === 0 + ) + // Trigger validation if a git branch name exists initially or is entered + if (form.getValues('gitBranchName')) { + form.trigger('gitBranchName') + } + }, [githubConnection?.id, form.getValues('gitBranchName'), form.trigger, visible, branch]) + + const openLinkerPanel = () => { + onClose() + sidePanelsState.setGithubConnectionsOpen(true) + } + + return ( + !open && onClose()}> + + + Edit branch "{branch?.name}" {/* Update title */} + + + +
+ + ( + + + + + + )} + /> + + {githubConnection && ( + ( + +
+ +
+ {`GitHub + + {repoOwner}/{repoName} + +
+
+
+ + + +
+ {isChecking && } + {field.value && !isChecking && isGitBranchValid && ( + + )} +
+
+

+ If linked, migrations from this Git branch will be automatically deployed. +

+ +
+ )} + /> + )} + {isLoadingConnections && } + {isErrorConnections && ( + + )} + {isSuccessConnections && !githubConnection && ( +
+
+ +

+ Optionally connect to a GitHub repository to manage migrations automatically + for this branch. +

+
+ +
+ )} +
+ + + + + +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx index 264af8aa091..329ebaeeb70 100644 --- a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx @@ -1,11 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink, GitBranch, GitPullRequest } from 'lucide-react' +import { ExternalLink, GitBranch, GitPullRequest, Github } from 'lucide-react' import Link from 'next/link' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAppStateSnapshot } from 'state/app-state' +import { sidePanelsState } from 'state/side-panels' import { Button } from 'ui' export const BranchingEmptyState = () => { @@ -55,10 +56,35 @@ export const BranchingEmptyState = () => { export const PullRequestsEmptyState = ({ url, hasBranches, + githubConnection, + gitlessBranching, }: { url: string hasBranches: boolean + githubConnection?: any + gitlessBranching?: boolean }) => { + // Show GitHub connection message if GitHub is not connected and gitless branching is enabled + if (!githubConnection && gitlessBranching) { + return ( +
+
+

Connect to GitHub for seamless branching

+

+ Sync GitHub repos to Supabase projects for automatic branch creation and merging +

+
+ +
+ ) + } + return (

No pull requests made yet for this repository

@@ -106,36 +132,24 @@ export const PreviewBranchesEmptyState = ({ }) => { return (
-

No database preview branches

-

Database preview branches will be shown here

-
-
-
- -
-

Create a preview branch

-

Start developing in preview

-
-
- -
-
-
-

Not sure what to do?

-

Browse our documentation

-
- -
+

Create your first preview branch

+

+ Preview branches are used to experiment with changes to your database schema in a safe, + non-destructive environment. +

+
+ +
) diff --git a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx index 93bcb2eed06..a89becda650 100644 --- a/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx @@ -154,18 +154,10 @@ const IntegrationConnectionItem = forwardRef

- This action cannot be undone. Are you sure you want to delete this {type} connection? + Deleting this GitHub connection will stop automatic creation and merging of preview + branches. Existing preview branches will remain unchanged.

diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx index a314423b39c..b7e0a95ea7a 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx @@ -1,13 +1,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Clock } from 'lucide-react' import Link from 'next/link' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAppStateSnapshot } from 'state/app-state' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Button } from 'ui' -const BranchingPITRNotice = () => { +export const BranchingPITRNotice = () => { const { ref } = useParams() const snap = useAppStateSnapshot() @@ -17,41 +18,44 @@ const BranchingPITRNotice = () => { ) return ( - - - We strongly encourage enabling Point in Time Recovery (PITR) - - - This is to ensure that you can always recover data if you make a "bad migration". For - example, if you accidentally delete a column or some of your production data. - - {!canUpdateSubscription ? ( - - Enable PITR add-on - - ) : ( - - )} - + + ) : ( + + )} +
+
) } - -export default BranchingPITRNotice diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx index 6d671823230..714b3e038ab 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx @@ -5,12 +5,12 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useAppStateSnapshot } from 'state/app-state' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -const BranchingPlanNotice = () => { +export const BranchingPlanNotice = () => { const snap = useAppStateSnapshot() const selectedOrg = useSelectedOrganization() return ( - + Database branching is only available on the Pro Plan and above @@ -32,5 +32,3 @@ const BranchingPlanNotice = () => { ) } - -export default BranchingPlanNotice diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx index cc70fbd08cf..f1b1ef709d1 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPostgresVersionNotice.tsx @@ -1,11 +1,11 @@ -import { useParams } from 'common' +import { AlertCircleIcon } from 'lucide-react' import Link from 'next/link' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import { AlertCircleIcon } from 'lucide-react' +import { useParams } from 'common' import { useAppStateSnapshot } from 'state/app-state' -const BranchingPostgresVersionNotice = () => { +export const BranchingPostgresVersionNotice = () => { const { ref } = useParams() const snap = useAppStateSnapshot() @@ -32,5 +32,3 @@ const BranchingPostgresVersionNotice = () => { ) } - -export default BranchingPostgresVersionNotice diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx index 251cf2c2cae..d0f59229663 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx @@ -1,11 +1,14 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useParams } from 'common' import { last } from 'lodash' +import { Check, DollarSign, ExternalLink, FileText, GitBranch, Github, Loader2 } from 'lucide-react' +import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' import SidePanelGitHubRepoLinker from 'components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' @@ -13,27 +16,49 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import { projectKeys } from 'data/projects/keys' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { DollarSign, FileText, GitBranch } from 'lucide-react' +import { useFlag } from 'hooks/ui/useFlag' +import { useRouter } from 'next/router' import { useAppStateSnapshot } from 'state/app-state' -import { Button, Form_Shadcn_, Modal } from 'ui' -import BranchingPITRNotice from './BranchingPITRNotice' -import BranchingPlanNotice from './BranchingPlanNotice' -import BranchingPostgresVersionNotice from './BranchingPostgresVersionNotice' -import GithubRepositorySelection from './GithubRepositorySelection' +import { sidePanelsState } from 'state/side-panels' +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + FormMessage_Shadcn_, + Input_Shadcn_, + Label_Shadcn_ as Label, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { BranchingPITRNotice } from './BranchingPITRNotice' +import { BranchingPlanNotice } from './BranchingPlanNotice' +import { BranchingPostgresVersionNotice } from './BranchingPostgresVersionNotice' -const EnableBranchingModal = () => { +export const EnableBranchingModal = () => { + const router = useRouter() const { ref } = useParams() const snap = useAppStateSnapshot() + const queryClient = useQueryClient() + const project = useSelectedProject() const selectedOrg = useSelectedOrganization() + const gitlessBranching = useFlag('gitlessBranching') - // [Joshen] There's something weird with RHF that I can't figure out atm - // but calling form.formState.isValid somehow removes the onBlur check, - // and makes the validation run onChange instead. This is a workaround - const [isValid, setIsValid] = useState(false) + const [isGitBranchValid, setIsGitBranchValid] = useState(false) const { data: connections, @@ -48,7 +73,6 @@ const EnableBranchingModal = () => { { enabled: snap.showEnableBranchingModal } ) - const project = useSelectedProject() const hasMinimumPgVersion = Number(last(project?.dbVersion?.split('-') ?? [])?.split('.')[0] ?? 0) >= 15 @@ -66,195 +90,321 @@ const EnableBranchingModal = () => { useCheckGithubBranchValidity({ onError: () => {} }) const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ - onSuccess: () => { - toast.success(`Successfully created new branch`) + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries(projectKeys.detail(ref)), + queryClient.invalidateQueries(projectKeys.list()), + ]) + toast.success(`Successfully enabled branching`) snap.setShowEnableBranchingModal(false) + router.push(`/project/${ref}/branches`) + }, + onError: (error) => { + toast.error(`Failed to enable branching: ${error.message}`) }, }) const formId = 'enable-branching-form' - const FormSchema = z.object({ - branchName: z - .string() - .refine((val) => val.length > 1, `Please enter a branch name from ${repoOwner}/${repoName}`) - .refine(async (val) => { - try { - if (val.length > 0) { - if (!githubConnection?.id) { - throw new Error('No GitHub connection found') - } + const FormSchema = z + .object({ + productionBranchName: z + .string() + .min(1, 'Production branch name cannot be empty') + .refine( + (val) => /^[a-zA-Z0-9\-_]+$/.test(val), + 'Branch name can only contain alphanumeric characters, hyphens, and underscores.' + ), + branchName: z.string().optional(), + }) + .superRefine(async (val, ctx) => { + if (githubConnection && (!val.branchName || val.branchName.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'GitHub branch is required when a repository is connected.', + path: ['branchName'], + }) + setIsGitBranchValid(false) + return + } - await checkGithubBranchValidity({ - connectionId: githubConnection.id, - branchName: val, - }) - setIsValid(true) - } - return true + if (githubConnection && val.branchName && val.branchName.length > 0) { + try { + await checkGithubBranchValidity({ + connectionId: githubConnection.id, + branchName: val.branchName, + }) + setIsGitBranchValid(true) } catch (error) { - setIsValid(false) - return false + setIsGitBranchValid(false) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Unable to find branch "${val.branchName}" in ${repoOwner}/${repoName}`, + path: ['branchName'], + }) } - }, `Unable to find branch from ${repoOwner}/${repoName}`), - }) + } else { + setIsGitBranchValid(true) + } + }) + const form = useForm>({ mode: 'onBlur', reValidateMode: 'onChange', resolver: zodResolver(FormSchema), - defaultValues: { branchName: '' }, + defaultValues: { productionBranchName: 'main', branchName: '' }, }) const isLoading = isLoadingConnections const isError = isErrorConnections const isSuccess = isSuccessConnections - const canSubmit = form.getValues('branchName').length > 0 && !isChecking && isValid + const isFormValid = form.formState.isValid + const canSubmit = + isFormValid && !isCreating && !isChecking && (gitlessBranching || !!githubConnection) + const onSubmit = (data: z.infer) => { if (!ref) return console.error('Project ref is required') - createBranch({ projectRef: ref, branchName: data.branchName, gitBranch: data.branchName }) + createBranch({ + projectRef: ref, + branchName: data.productionBranchName, + ...(data.branchName && isGitBranchValid ? { gitBranch: data.branchName } : {}), + }) + } + + const openLinkerPanel = () => { + snap.setShowEnableBranchingModal(false) + sidePanelsState.setGithubConnectionsOpen(true) } useEffect(() => { - if (form && snap.showEnableBranchingModal) { - setIsValid(false) - form.reset() + if (snap.showEnableBranchingModal) { + form.reset({ productionBranchName: 'main', branchName: '' }) + setIsGitBranchValid(false) } }, [form, snap.showEnableBranchingModal]) + useEffect(() => { + setIsGitBranchValid(!form.getValues('branchName') || form.getValues('branchName')?.length === 0) + }, [githubConnection?.id, form.getValues('branchName')]) + return ( <> - snap.setShowEnableBranchingModal(false)} - className="block" - size="medium" - hideClose + !open && snap.setShowEnableBranchingModal(false)} > - -
setIsValid(false)} - > - -
- -
-

Enable database branching

-

Manage environments in Supabase

+ + + + +
+
+ +
+ Enable database branching + Manage environments in Supabase +
+
+
-
- - + - {isLoading && ( - <> - - - - - - - )} - {isError && ( - <> - - - {isErrorConnections ? ( + {isLoading && ( +
+ + + + + +
+ )} + {isError && ( +
+ + - ) : null} - - - - )} - {isSuccess && ( - <> - {isFreePlan ? ( - - ) : !hasMinimumPgVersion ? ( - - ) : ( - <> - - {!hasPitrEnabled && } - - )} - -

- Please keep in mind the following: -

-
-
-
- -
-
-
-

- Preview branches are billed $0.32 per day -

-

- This cost will continue for as long as the branch has not been removed. -

-
-
-
-
-
- -
-
-
-

- Migrations are applied from your GitHub repository -

-

- Migration files in your ./supabase{' '} - directory will run on both Preview Branches and Production when pushing and - merging branches. -

-
-
-
- - - )} +
+ +
+ )} + {isSuccess && ( + <> + {isFreePlan ? ( + + + + ) : !hasMinimumPgVersion ? ( + + + + ) : ( + <> + + + ( + + + + + + )} + /> + {githubConnection ? ( + <> + ( + +
+ +
+ + + {repoOwner}/{repoName} + + + + +
+
+ + + +
+ {isChecking && } + {field.value && !isChecking && isGitBranchValid && ( + + )} +
+ +
+ )} + /> + + ) : ( +
+
+
+ + {!gitlessBranching && ( + + Required + + )} +
+

+ {gitlessBranching + ? 'Optionally connect to a GitHub repository to enable deploying previews on Pull Requests and manage migrations automatically.' + : 'Connect to a GitHub repository to enable database branching. This allows you to deploy previews on Pull Requests and manage migrations automatically.'} +

+
+ +
+ )} +
+ + )} + - - - - - - - + +

Please keep in mind the following:

+ + {githubConnection && ( +
+
+
+ +
+
+
+

+ Migrations are applied from your GitHub repository +

+

+ Migration files in your ./supabase{' '} + directory will run on both Preview Branches and Production when pushing + and merging branches. +

+
+
+ )} + +
+
+
+ +
+
+
+

+ Preview branches are billed $0.32 per day +

+

+ This cost will continue for as long as the branch has not been removed. +

+
+
+ {!hasPitrEnabled && } +
+ + + )} + + + + + + + + +
) } - -export default EnableBranchingModal diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 7f1b25d8ed5..8cc5e099791 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -23,7 +23,7 @@ import { useAppStateSnapshot } from 'state/app-state' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' -import EnableBranchingModal from '../AppLayout/EnableBranchingButton/EnableBranchingModal' +import { EnableBranchingModal } from '../AppLayout/EnableBranchingButton/EnableBranchingModal' import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' diff --git a/apps/studio/data/branches/branch-create-mutation.ts b/apps/studio/data/branches/branch-create-mutation.ts index 874f0bf102f..d32400c906a 100644 --- a/apps/studio/data/branches/branch-create-mutation.ts +++ b/apps/studio/data/branches/branch-create-mutation.ts @@ -2,7 +2,6 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' -import { projectKeys } from 'data/projects/keys' import type { ResponseError } from 'types' import { branchKeys } from './keys' @@ -51,8 +50,6 @@ export const useBranchCreateMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables await queryClient.invalidateQueries(branchKeys.list(projectRef)) - await queryClient.invalidateQueries(projectKeys.detail(projectRef)) - await queryClient.invalidateQueries(projectKeys.list()) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/pages/account/me.tsx b/apps/studio/pages/account/me.tsx index 5b09d1a7998..d842fba7788 100644 --- a/apps/studio/pages/account/me.tsx +++ b/apps/studio/pages/account/me.tsx @@ -1,6 +1,7 @@ import { AccountDeletion } from 'components/interfaces/Account/Preferences/AccountDeletion' import { AccountIdentities } from 'components/interfaces/Account/Preferences/AccountIdentities' import { AnalyticsSettings } from 'components/interfaces/Account/Preferences/AnalyticsSettings' +import { AccountConnections } from 'components/interfaces/Account/Preferences/AccountConnections' import { ProfileInformation } from 'components/interfaces/Account/Preferences/ProfileInformation' import { ThemeSettings } from 'components/interfaces/Account/Preferences/ThemeSettings' import AccountLayout from 'components/layouts/AccountLayout/AccountLayout' @@ -60,6 +61,10 @@ const ProfileCard = () => { )} +
+ +
+