Files
supabase/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx
Gildas Garcia ef9490e929 chore: migrate project settings Modal to Dialog (#46265)
## Problem

Project settings still uses the deprecated `Modal` for:
- transferring a project
- upgrading Postgres in _Infrastructure_

## Solution

- use `Dialog` instead

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

* **Refactor**
* Migrated project upgrade confirmation and project transfer flows to a
unified dialog-based UI for improved consistency and accessibility.
Visual structure (headers, sections, footers) and action placement were
standardized; existing functionality, validation, and user workflows
remain unchanged.

<!-- 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/46265?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-22 14:15:34 +02:00

302 lines
11 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useFlag } from 'common'
import { Loader, Shield, Wrench } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
InfoIcon,
Loading,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DocsButton } from '@/components/ui/DocsButton'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { projectKeys } from '@/data/projects/keys'
import { useProjectTransferMutation } from '@/data/projects/project-transfer-mutation'
import { useProjectTransferPreviewQuery } from '@/data/projects/project-transfer-preview-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
export const TransferProjectButton = () => {
const { data: project } = useSelectedProjectQuery()
const projectRef = project?.ref
const projectOrgId = project?.organization_id
const [isOpen, setIsOpen] = useState(false)
const { data: allOrganizations } = useOrganizationsQuery({ enabled: isOpen })
const disableProjectTransfer = useFlag('disableProjectTransfer')
const organizations = (allOrganizations || []).filter((it) => it.id !== projectOrgId)
const [selectedOrg, setSelectedOrg] = useState<string>()
const {
mutate: transferProject,
error: transferError,
isPending: isTransferring,
} = useProjectTransferMutation({
onSuccess: () => {
toast.success(`Successfully transferred project ${project?.name}.`)
setIsOpen(false)
},
})
const {
data: transferPreviewData,
error: transferPreviewError,
isPending: transferPreviewIsLoading,
} = useProjectTransferPreviewQuery(
{ projectRef, targetOrganizationSlug: selectedOrg },
{ enabled: !isTransferring && isOpen }
)
const queryClient = useQueryClient()
useEffect(() => {
if (isOpen) {
// reset state
setSelectedOrg(undefined)
} else {
// Invalidate cache
queryClient.removeQueries({
queryKey: projectKeys.projectTransferPreview(projectRef, selectedOrg),
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
const { can: canTransferProject } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'organizations'
)
async function handleTransferProject() {
if (project === undefined) return
if (selectedOrg === undefined) return
transferProject({ projectRef, targetOrganizationSlug: selectedOrg })
}
return (
<Dialog onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
<DialogTrigger asChild>
<ButtonTooltip
type="default"
disabled={!canTransferProject || disableProjectTransfer}
tooltip={{
content: {
side: 'bottom',
text: !canTransferProject
? 'You need additional permissions to transfer this project'
: disableProjectTransfer
? 'Project transfers are temporarily disabled, please try again later.'
: undefined,
},
}}
>
Transfer project
</ButtonTooltip>
</DialogTrigger>
<DialogContent size="xlarge">
<DialogHeader>
<DialogTitle>{`Transfer project ${project?.name}`}</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="text-foreground-light">
<p className="text-sm">
To transfer projects, the owner must be a member of both the source and target
organizations. Consider the following before transferring your project:
</p>
<ul className="mt-4 space-y-5 text-sm">
<li className="flex gap-4">
<span className="shrink-0 mt-1">
<Loader />
</span>
<div>
<p className="font-bold">Possible downtime</p>
<p>
There might be a short downtime when transferring projects from a paid to a free
organization.
</p>
</div>
</li>
<li className="flex gap-4">
<span className="shrink-0 mt-1">
<Shield />
</span>
<div>
<p className="font-bold">Permissions</p>
<p>
Depending on your role in the target organization, your level of permissions may
change after transfer.
</p>
</div>
</li>
<li className="flex gap-4">
<span className="shrink-0 mt-1">
<Wrench size={24} className="shrink-0" />
</span>
<div>
<p className="font-bold">Features</p>
<p>
Moving your project to an organization with a smaller subscription plan may result
in the loss of certain features (i.e. image transformations).
</p>
</div>
</li>
</ul>
<DocsButton
abbrev={false}
className="mt-6"
href={`${DOCS_URL}/guides/platform/project-transfer`}
/>
</DialogSection>
<DialogSectionSeparator />
<DialogSection>
{organizations && (
<div className="space-y-2">
{organizations.length === 0 ? (
<div className="flex items-center gap-3 bg-surface-200 p-3 text-sm rounded-md border">
<InfoIcon /> You do not have any organizations you can transfer your project to.
</div>
) : (
<FormItemLayout
id="organization"
isReactForm={false}
layout="vertical"
label="Select Target Organization"
className="gap-[2px]"
size="tiny"
>
<Select onValueChange={(slug) => setSelectedOrg(slug)} value={selectedOrg}>
<SelectTrigger id="organization">
<SelectValue placeholder="Select Organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((x) => (
<SelectItem key={x.id} value={x.slug}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
</div>
)}
</DialogSection>
{selectedOrg !== undefined && (
<Loading active={selectedOrg !== undefined && transferPreviewIsLoading}>
<DialogSection>
<div className="space-y-2">
{transferPreviewData && transferPreviewData.errors.length > 0 && (
<Admonition type="danger" title="Project cannot be transferred">
<div className="space-y-1">
{transferPreviewData.errors.map((error) => (
<p key={error.key}>{error.message}</p>
))}
</div>
{transferPreviewData.members_exceeding_free_project_limit.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-foreground-light">
These members have reached their maximum limits for the number of active
Free plan projects within organizations where they are an administrator or
owner:
</p>
<ul className="pl-5 text-sm list-disc text-foreground-light">
{(transferPreviewData.members_exceeding_free_project_limit || []).map(
(member, idx: number) => (
<li key={`member-${idx}`}>
{member.name} (Limit: {member.limit} free projects)
</li>
)
)}
</ul>
<p className="text-sm text-foreground-light">
These members will need to either delete, pause, or upgrade one or more of
their projects before you can transfer this project.
</p>
</div>
)}
</Admonition>
)}
{transferPreviewData &&
(transferPreviewData.warnings.length > 0 ||
transferPreviewData.info.length > 0) && (
<Admonition type="caution" showIcon={false} className="mt-3">
<div className="flex flex-col gap-y-2">
{transferPreviewData.warnings.map((warning) => (
<div key={warning.key} className="flex items-center gap-2">
<WarningIcon className="shrink-0" />
<p className="mb-0.5">{warning.message}</p>
</div>
))}
{transferPreviewData.info.map((info) => (
<div key={info.key} className="flex items-start gap-2">
<InfoIcon className="shrink-0 mt-0.5" />
<p className="mb-0.5">{info.message}</p>
</div>
))}
</div>
</Admonition>
)}
{transferPreviewError && !transferError && (
<Admonition
type="danger"
title="Project cannot be transferred"
description={transferPreviewError.message}
/>
)}
{transferError && (
<Admonition
type="danger"
title="Project cannot be transferred"
description={transferError.message}
/>
)}
</div>
</DialogSection>
</Loading>
)}
<DialogFooter>
<Button type="default" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
onClick={() => handleTransferProject()}
disabled={
!transferPreviewData || !transferPreviewData.valid || isTransferring || !selectedOrg
}
>
Transfer Project
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}