mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
## Context Compute and Disk page: There's an odd scenario whereby down sizing the compute might involve some validation errors in the advanced settings, in which case because the advanced settings is in a collapsible and requires the user to scroll down, there's hence no visual indication of where the error is until the user opens the advanced settings to see the inline error. Am hence opting to open the advanced settings + scroll to it for this particular scenario, if submitting the form causes a validation issue on either IOPS, throughput or max disk size field inputs (the rest in this section do not have validation checks on that afaict and hence wouldn't be applicable to this scenario) ## To test Can be tested locally - Upsize compute to 8XL + change throughput to 750 - Once completed, try to down size to XL and hit "review changes" - It should be clear from a UX POV if there are any errors Also added an unrelated nit tidy up for ExitSurveyModal <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Advanced disk settings now auto-open and scroll into view (with a brief delay) when validation errors occur for throughput, IOPS, or size, improving error visibility. * **User Interface** * Improved advanced settings panel animation and separators for clearer visual boundaries. * Updated project deletion layout for the reason chooser and message input, providing a more consistent form appearance. [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45818) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
202 lines
7.3 KiB
TypeScript
202 lines
7.3 KiB
TypeScript
import { LOCAL_STORAGE_KEYS } from 'common'
|
|
import { useRouter } from 'next/router'
|
|
import { useEffect, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { TextArea_Shadcn_ as TextArea } from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
|
|
import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants'
|
|
import { LogicalBackupCliInstructions } from '@/components/layouts/ProjectLayout/LogicalBackupCliInstructions'
|
|
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
|
import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send'
|
|
import type { OrgProject } from '@/data/projects/org-projects-infinite-query'
|
|
import { useProjectDeleteMutation } from '@/data/projects/project-delete-mutation'
|
|
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
|
|
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import type { Organization } from '@/types'
|
|
|
|
export const DeleteProjectModal = ({
|
|
visible,
|
|
onClose,
|
|
project: projectProp,
|
|
organization: organizationProp,
|
|
}: {
|
|
visible: boolean
|
|
onClose: () => void
|
|
project?: OrgProject
|
|
organization?: Organization
|
|
}) => {
|
|
const router = useRouter()
|
|
const { data: projectFromQuery } = useSelectedProjectQuery()
|
|
const { data: organizationFromQuery } = useSelectedOrganizationQuery()
|
|
|
|
// Use props if provided, otherwise fall back to hooks
|
|
const project = projectProp || projectFromQuery
|
|
const organization = organizationProp || organizationFromQuery
|
|
|
|
const [lastVisitedOrganization] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
|
|
''
|
|
)
|
|
|
|
const projectRef = project?.ref
|
|
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
|
|
const projectPlan = subscription?.plan?.id ?? 'free'
|
|
const isFree = projectPlan === 'free'
|
|
|
|
const [message, setMessage] = useState<string>('')
|
|
const [selectedReason, setSelectedReason] = useState<string[]>([])
|
|
|
|
// Single select for cancellation reason
|
|
const onSelectCancellationReason = (reason: string) => {
|
|
setSelectedReason([reason])
|
|
}
|
|
|
|
// Helper to get label for selected reason
|
|
const getReasonLabel = (reason: string | undefined) => {
|
|
const found = CANCELLATION_REASONS.find((r) => r.value === reason)
|
|
return found?.label || 'What can we improve on?'
|
|
}
|
|
|
|
const textareaLabel = getReasonLabel(selectedReason[0])
|
|
|
|
const [shuffledReasons] = useState(() => [
|
|
...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
|
|
{ value: 'None of the above' },
|
|
])
|
|
|
|
const { mutate: deleteProject, isPending: isDeleting } = useProjectDeleteMutation({
|
|
onSuccess: async () => {
|
|
if (!isFree) {
|
|
try {
|
|
await sendExitSurvey({
|
|
orgSlug: organization?.slug,
|
|
projectRef,
|
|
message,
|
|
reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
|
|
exitAction: 'delete',
|
|
})
|
|
} catch (error) {
|
|
// [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user
|
|
}
|
|
}
|
|
|
|
toast.success(`Successfully deleted ${project?.name}`)
|
|
|
|
// Only redirect if still viewing the deleted project
|
|
if (router.asPath.startsWith(`/project/${projectRef}`)) {
|
|
if (lastVisitedOrganization) {
|
|
router.push(`/org/${lastVisitedOrganization}`)
|
|
} else {
|
|
router.push('/organizations')
|
|
}
|
|
}
|
|
},
|
|
})
|
|
const { mutateAsync: sendExitSurvey, isPending: isSending } = useSendDowngradeFeedbackMutation()
|
|
const isSubmitting = isDeleting || isSending
|
|
|
|
async function handleDeleteProject() {
|
|
if (project === undefined) return
|
|
if (!isFree && selectedReason.length === 0) {
|
|
return toast.error('Please select a reason for deleting your project')
|
|
}
|
|
|
|
deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug })
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
setSelectedReason([])
|
|
setMessage('')
|
|
}
|
|
}, [visible])
|
|
|
|
return (
|
|
<TextConfirmModal
|
|
visible={visible}
|
|
loading={isSubmitting}
|
|
size={isFree ? 'medium' : 'xlarge'}
|
|
title={`Confirm deletion of ${project?.name}`}
|
|
variant="destructive"
|
|
alert={{
|
|
title: isFree
|
|
? 'This action cannot be undone.'
|
|
: `This will permanently delete the ${project?.name}`,
|
|
description: !isFree ? `All project data will be lost, and cannot be undone` : '',
|
|
}}
|
|
text={
|
|
isFree
|
|
? `This will permanently delete the ${project?.name} project and all of its data.`
|
|
: undefined
|
|
}
|
|
confirmPlaceholder="Type the project name in here"
|
|
confirmString={project?.name || ''}
|
|
confirmLabel="I understand, delete this project"
|
|
onConfirm={handleDeleteProject}
|
|
onCancel={() => {
|
|
if (!isSubmitting) onClose()
|
|
}}
|
|
>
|
|
<div className="space-y-6">
|
|
<LogicalBackupCliInstructions enabled={visible} showResetPassword={false} />
|
|
|
|
{/*
|
|
[Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one
|
|
in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor
|
|
but leaving that for the future.
|
|
*/}
|
|
{!isFree && (
|
|
<div className="flex flex-col gap-y-6">
|
|
<FormItemLayout
|
|
isReactForm={false}
|
|
label="What made you decide to delete your project?"
|
|
>
|
|
<div className="flex flex-wrap gap-2" data-toggle="buttons">
|
|
{shuffledReasons.map((option) => {
|
|
const active = selectedReason[0] === option.value
|
|
return (
|
|
<label
|
|
key={option.value}
|
|
className={[
|
|
'flex cursor-pointer items-center space-x-2 rounded-md py-1',
|
|
'pl-2 pr-3 text-center text-sm shadow-xs transition-all duration-100',
|
|
`${
|
|
active
|
|
? ` bg-foreground text-background opacity-100 hover:bg-foreground/75`
|
|
: ` bg-border-strong text-foreground opacity-50 hover:opacity-75`
|
|
}`,
|
|
].join(' ')}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="options"
|
|
value={option.value}
|
|
className="hidden"
|
|
checked={active}
|
|
onChange={() => onSelectCancellationReason(option.value)}
|
|
/>
|
|
<div>{option.value}</div>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</FormItemLayout>
|
|
|
|
<FormItemLayout isReactForm={false} label={textareaLabel}>
|
|
<TextArea
|
|
name="message"
|
|
rows={3}
|
|
value={message}
|
|
onChange={(event) => setMessage(event.target.value)}
|
|
/>
|
|
</FormItemLayout>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TextConfirmModal>
|
|
)
|
|
}
|