DeleteProjectModal.tsx201 lines · main
| 1 | import { LOCAL_STORAGE_KEYS } from 'common' |
| 2 | import { useRouter } from 'next/router' |
| 3 | import { useEffect, useState } from 'react' |
| 4 | import { toast } from 'sonner' |
| 5 | import { TextArea } from 'ui' |
| 6 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 7 | |
| 8 | import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants' |
| 9 | import { LogicalBackupCliInstructions } from '@/components/layouts/ProjectLayout/LogicalBackupCliInstructions' |
| 10 | import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' |
| 11 | import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send' |
| 12 | import type { OrgProject } from '@/data/projects/org-projects-infinite-query' |
| 13 | import { useProjectDeleteMutation } from '@/data/projects/project-delete-mutation' |
| 14 | import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' |
| 15 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 16 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 17 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 18 | import type { Organization } from '@/types' |
| 19 | |
| 20 | export const DeleteProjectModal = ({ |
| 21 | visible, |
| 22 | onClose, |
| 23 | project: projectProp, |
| 24 | organization: organizationProp, |
| 25 | }: { |
| 26 | visible: boolean |
| 27 | onClose: () => void |
| 28 | project?: OrgProject |
| 29 | organization?: Organization |
| 30 | }) => { |
| 31 | const router = useRouter() |
| 32 | const { data: projectFromQuery } = useSelectedProjectQuery() |
| 33 | const { data: organizationFromQuery } = useSelectedOrganizationQuery() |
| 34 | |
| 35 | // Use props if provided, otherwise fall back to hooks |
| 36 | const project = projectProp || projectFromQuery |
| 37 | const organization = organizationProp || organizationFromQuery |
| 38 | |
| 39 | const [lastVisitedOrganization] = useLocalStorageQuery( |
| 40 | LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, |
| 41 | '' |
| 42 | ) |
| 43 | |
| 44 | const projectRef = project?.ref |
| 45 | const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) |
| 46 | const projectPlan = subscription?.plan?.id ?? 'free' |
| 47 | const isFree = projectPlan === 'free' |
| 48 | |
| 49 | const [message, setMessage] = useState<string>('') |
| 50 | const [selectedReason, setSelectedReason] = useState<string[]>([]) |
| 51 | |
| 52 | // Single select for cancellation reason |
| 53 | const onSelectCancellationReason = (reason: string) => { |
| 54 | setSelectedReason([reason]) |
| 55 | } |
| 56 | |
| 57 | // Helper to get label for selected reason |
| 58 | const getReasonLabel = (reason: string | undefined) => { |
| 59 | const found = CANCELLATION_REASONS.find((r) => r.value === reason) |
| 60 | return found?.label || 'What can we improve on?' |
| 61 | } |
| 62 | |
| 63 | const textareaLabel = getReasonLabel(selectedReason[0]) |
| 64 | |
| 65 | const [shuffledReasons] = useState(() => [ |
| 66 | ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5), |
| 67 | { value: 'None of the above' }, |
| 68 | ]) |
| 69 | |
| 70 | const { mutate: deleteProject, isPending: isDeleting } = useProjectDeleteMutation({ |
| 71 | onSuccess: async () => { |
| 72 | if (!isFree) { |
| 73 | try { |
| 74 | await sendExitSurvey({ |
| 75 | orgSlug: organization?.slug, |
| 76 | projectRef, |
| 77 | message, |
| 78 | reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), |
| 79 | exitAction: 'delete', |
| 80 | }) |
| 81 | } catch (error) { |
| 82 | // [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | toast.success(`Successfully deleted ${project?.name}`) |
| 87 | |
| 88 | // Only redirect if still viewing the deleted project |
| 89 | if (router.asPath.startsWith(`/project/${projectRef}`)) { |
| 90 | if (lastVisitedOrganization) { |
| 91 | router.push(`/org/${lastVisitedOrganization}`) |
| 92 | } else { |
| 93 | router.push('/organizations') |
| 94 | } |
| 95 | } |
| 96 | }, |
| 97 | }) |
| 98 | const { mutateAsync: sendExitSurvey, isPending: isSending } = useSendDowngradeFeedbackMutation() |
| 99 | const isSubmitting = isDeleting || isSending |
| 100 | |
| 101 | async function handleDeleteProject() { |
| 102 | if (project === undefined) return |
| 103 | if (!isFree && selectedReason.length === 0) { |
| 104 | return toast.error('Please select a reason for deleting your project') |
| 105 | } |
| 106 | |
| 107 | deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug }) |
| 108 | } |
| 109 | |
| 110 | useEffect(() => { |
| 111 | if (visible) { |
| 112 | setSelectedReason([]) |
| 113 | setMessage('') |
| 114 | } |
| 115 | }, [visible]) |
| 116 | |
| 117 | return ( |
| 118 | <TextConfirmModal |
| 119 | visible={visible} |
| 120 | loading={isSubmitting} |
| 121 | size={isFree ? 'medium' : 'xlarge'} |
| 122 | title={`Confirm deletion of ${project?.name}`} |
| 123 | variant="destructive" |
| 124 | alert={{ |
| 125 | title: isFree |
| 126 | ? 'This action cannot be undone.' |
| 127 | : `This will permanently delete the ${project?.name}`, |
| 128 | description: !isFree ? `All project data will be lost, and cannot be undone` : '', |
| 129 | }} |
| 130 | text={ |
| 131 | isFree |
| 132 | ? `This will permanently delete the ${project?.name} project and all of its data.` |
| 133 | : undefined |
| 134 | } |
| 135 | confirmPlaceholder="Type the project name in here" |
| 136 | confirmString={project?.name || ''} |
| 137 | confirmLabel="I understand, delete this project" |
| 138 | onConfirm={handleDeleteProject} |
| 139 | onCancel={() => { |
| 140 | if (!isSubmitting) onClose() |
| 141 | }} |
| 142 | > |
| 143 | <div className="space-y-6"> |
| 144 | <LogicalBackupCliInstructions enabled={visible} showResetPassword={false} /> |
| 145 | |
| 146 | {/* |
| 147 | [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one |
| 148 | in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor |
| 149 | but leaving that for the future. |
| 150 | */} |
| 151 | {!isFree && ( |
| 152 | <div className="flex flex-col gap-y-6"> |
| 153 | <FormItemLayout |
| 154 | isReactForm={false} |
| 155 | label="What made you decide to delete your project?" |
| 156 | > |
| 157 | <div className="flex flex-wrap gap-2" data-toggle="buttons"> |
| 158 | {shuffledReasons.map((option) => { |
| 159 | const active = selectedReason[0] === option.value |
| 160 | return ( |
| 161 | <label |
| 162 | key={option.value} |
| 163 | className={[ |
| 164 | 'flex cursor-pointer items-center space-x-2 rounded-md py-1', |
| 165 | 'pl-2 pr-3 text-center text-sm shadow-xs transition-all duration-100', |
| 166 | `${ |
| 167 | active |
| 168 | ? ` bg-foreground text-background opacity-100 hover:bg-foreground/75` |
| 169 | : ` bg-border-strong text-foreground opacity-50 hover:opacity-75` |
| 170 | }`, |
| 171 | ].join(' ')} |
| 172 | > |
| 173 | <input |
| 174 | type="radio" |
| 175 | name="options" |
| 176 | value={option.value} |
| 177 | className="hidden" |
| 178 | checked={active} |
| 179 | onChange={() => onSelectCancellationReason(option.value)} |
| 180 | /> |
| 181 | <div>{option.value}</div> |
| 182 | </label> |
| 183 | ) |
| 184 | })} |
| 185 | </div> |
| 186 | </FormItemLayout> |
| 187 | |
| 188 | <FormItemLayout isReactForm={false} label={textareaLabel}> |
| 189 | <TextArea |
| 190 | name="message" |
| 191 | rows={3} |
| 192 | value={message} |
| 193 | onChange={(event) => setMessage(event.target.value)} |
| 194 | /> |
| 195 | </FormItemLayout> |
| 196 | </div> |
| 197 | )} |
| 198 | </div> |
| 199 | </TextConfirmModal> |
| 200 | ) |
| 201 | } |