Files
supabase/apps/studio/components/interfaces/Support/SupportForm.tsx
Joshen Lim a72220efde Custom error boundary (#25946)
* Test custom error boundary

* Test

* Test

* Add error boundary to catch full page crashes

* Update apps/studio/components/ui/ErrorBoundaryState.tsx

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
2024-05-13 13:24:08 +08:00

823 lines
35 KiB
TypeScript

import { CLIENT_LIBRARIES } from 'common/constants'
import { AlertCircle, ExternalLink, HelpCircle, Loader2, Mail, Plus, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ChangeEvent, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { useParams } from 'common'
import InformationBox from 'components/ui/InformationBox'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { getProjectAuthConfig } from 'data/auth/auth-config-query'
import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import type { Project } from 'data/projects/project-detail-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useFlag } from 'hooks'
import useLatest from 'hooks/misc/useLatest'
import { detectBrowser } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Checkbox,
Form,
Input,
Listbox,
Separator,
} from 'ui'
import MultiSelect from 'ui-patterns/MultiSelect'
import DisabledStateForFreeTier from './DisabledStateForFreeTier'
import { CATEGORY_OPTIONS, SERVICE_OPTIONS, SEVERITY_OPTIONS } from './Support.constants'
import { formatMessage, uploadAttachments } from './SupportForm.utils'
const MAX_ATTACHMENTS = 5
const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive']
export interface SupportFormProps {
setSentCategory: (value: string) => void
}
const SupportForm = ({ setSentCategory }: SupportFormProps) => {
const { isReady } = useRouter()
const { ref, slug, subject, category, message } = useParams()
const uploadButtonRef = useRef()
const enableFreeSupport = useFlag('enableFreeSupport')
const [isSubmitting, setIsSubmitting] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])
const [selectedServices, setSelectedServices] = useState<string[]>([])
const [textAreaValue, setTextAreaValue] = useState('')
const {
data: organizations,
isLoading: isLoadingOrganizations,
isError: isErrorOrganizations,
isSuccess: isSuccessOrganizations,
} = useOrganizationsQuery()
// for use in useEffect
const organizationsRef = useLatest(organizations)
const {
data: allProjects,
isLoading: isLoadingProjects,
isError: isErrorProjects,
isSuccess: isSuccessProjects,
} = useProjectsQuery()
const { mutate: submitSupportTicket } = useSendSupportTicketMutation({
onSuccess: (res, variables) => {
toast.success('Support request sent. Thank you!')
setSentCategory(variables.category)
},
onError: (error) => {
toast.error(`Failed to submit support ticket: ${error.message}`)
setIsSubmitting(false)
},
})
const projectDefaults: Partial<Project>[] = [{ ref: 'no-project', name: 'No specific project' }]
const projects = [...(allProjects ?? []), ...projectDefaults]
const selectedProjectFromUrl = projects.find((project) => project.ref === ref)
const selectedOrganizationFromUrl = organizations?.find((org) => org.slug === slug)
const selectedCategoryFromUrl = CATEGORY_OPTIONS.find((option) => {
if (option.value.toLowerCase() === ((category as string) ?? '').toLowerCase()) return option
})
const [selectedProjectRef, setSelectedProjectRef] = useState(
selectedProjectFromUrl !== undefined
? selectedProjectFromUrl.ref
: projects.length > 0
? projects[0].ref
: 'no-project'
)
const selectedOrganizationSlug =
selectedOrganizationFromUrl !== undefined
? selectedOrganizationFromUrl.slug
: selectedProjectRef !== 'no-project'
? organizations?.find((org) => {
const project = projects.find((project) => project.ref === selectedProjectRef)
return org.id === project?.organization_id
})?.slug
: organizations?.[0]?.slug
const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({
orgSlug: selectedOrganizationSlug,
})
const { profile } = useProfile()
const respondToEmail = profile?.primary_email ?? 'your email'
const initialValues = {
category:
selectedCategoryFromUrl !== undefined
? selectedCategoryFromUrl.value
: CATEGORY_OPTIONS[0].value,
severity: 'Low',
projectRef: selectedProjectRef,
organizationSlug: selectedOrganizationSlug,
library: 'no-library',
subject: subject ?? '',
message: message || '',
allowSupportAccess: false,
}
const onFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
event.persist()
const items = event.target.files || (event as any).dataTransfer.items
const itemsCopied = Array.prototype.map.call(items, (item) => item) as File[]
const itemsToBeUploaded = itemsCopied.slice(0, MAX_ATTACHMENTS - uploadedFiles.length)
setUploadedFiles(uploadedFiles.concat(itemsToBeUploaded))
if (items.length + uploadedFiles.length > MAX_ATTACHMENTS) {
toast(`Only up to ${MAX_ATTACHMENTS} attachments are allowed`)
}
event.target.value = ''
}
const removeUploadedFile = (idx: number) => {
const updatedFiles = uploadedFiles?.slice()
updatedFiles.splice(idx, 1)
setUploadedFiles(updatedFiles)
const updatedDataUrls = uploadedDataUrls.slice()
uploadedDataUrls.splice(idx, 1)
setUploadedDataUrls(updatedDataUrls)
}
const onValidate = (values: any) => {
const errors: any = {}
if (!values.subject) errors.subject = 'Please add a subject heading'
if (!values.message) errors.message = "Please add a message about the issue that you're facing"
if (values.category === 'Problem' && values.library === 'no-library')
errors.library = "Please select the library that you're facing issues with"
return errors
}
const onSubmit = async (values: any) => {
setIsSubmitting(true)
const attachments =
uploadedFiles.length > 0 ? await uploadAttachments(values.projectRef, uploadedFiles) : []
const selectedLibrary = CLIENT_LIBRARIES.find((library) => library.language === values.library)
const payload = {
...values,
library:
values.category === 'Problem' && selectedLibrary !== undefined ? selectedLibrary.key : '',
message: formatMessage(values.message, attachments),
verified: true,
tags: ['dashboard-support-form'],
siteUrl: '',
additionalRedirectUrls: '',
affectedServices: selectedServices
.map((service) => service.replace(/ /g, '_').toLowerCase())
.join(';'),
browserInformation: detectBrowser(),
}
if (values.projectRef !== 'no-project') {
try {
const authConfig = await getProjectAuthConfig({ projectRef: values.projectRef })
payload.siteUrl = authConfig.SITE_URL
payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST
} finally {
}
}
submitSupportTicket(payload)
}
const handleTextMessageChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setTextAreaValue(event.target.value)
}
const ipv4MigrationStrings = [
'ipv4',
'ipv6',
'supavisor',
'pgbouncer',
'5432',
'ENETUNREACH',
'ECONNREFUSED',
'P1001',
'connect: no route to',
'network is unreac',
'could not translate host name',
'address family not supported by protocol',
]
const ipv4MigrationStringMatched = ipv4MigrationStrings.some((str) => textAreaValue.includes(str))
useEffect(() => {
if (!uploadedFiles) return
const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file))
setUploadedDataUrls(objectUrls)
return () => {
objectUrls.forEach((url: any) => URL.revokeObjectURL(url))
}
}, [uploadedFiles])
useEffect(() => {
if (isSuccessProjects && ref !== undefined) {
const selectedProjectFromUrl = projects.find((project) => project.ref === ref)
if (selectedProjectFromUrl !== undefined) setSelectedProjectRef(selectedProjectFromUrl.ref)
}
}, [isSuccessProjects])
return (
<Form id="support-form" initialValues={initialValues} validate={onValidate} onSubmit={onSubmit}>
{({ resetForm, values }: any) => {
const selectedCategory = CATEGORY_OPTIONS.find(
(category) => category.value === values.category
)
const selectedLibrary = CLIENT_LIBRARIES.find(
(library) => library.language === values.library
)
const selectedClientLibraries = selectedLibrary?.libraries.filter((library) =>
library.name.includes('supabase-')
)
const selectedProject = projects.find((project) => project.ref === values.projectRef)
const isFreeProject = (subscription?.plan.id ?? 'free') === 'free'
const isDisabled =
!enableFreeSupport &&
isFreeProject &&
['Performance', 'Problem'].includes(values.category)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (values.projectRef === 'no-project') {
const updatedValues = {
...values,
organizationSlug: organizationsRef.current?.[0]?.slug,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
} else if (selectedProject) {
const organization = organizationsRef.current?.find(
(org) => org.id === selectedProject.organization_id
)
if (organization) {
const updatedValues = { ...values, organizationSlug: organization.slug }
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}
}, [values.projectRef])
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (
isSuccessProjects &&
isSuccessOrganizations &&
allProjects.length > 0 &&
organizations.length > 0
) {
const updatedValues = {
...values,
projectRef: selectedProjectRef,
organizationSlug: selectedOrganizationSlug,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [
isSuccessProjects,
isSuccessOrganizations,
selectedProjectRef,
selectedOrganizationSlug,
])
// Populate fields when router is ready, required when navigating to
// support form on a refresh browser session
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isReady) {
const updatedValues = {
...initialValues,
projectRef: ref ?? initialValues.projectRef,
subject: subject ?? initialValues.subject,
category: selectedCategoryFromUrl?.value ?? initialValues.category,
message: message ?? initialValues.message,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [isReady])
return (
<div className="space-y-8 max-w-[620px] overflow-hidden">
<div className="px-6">
<h3 className="text-xl">How can we help?</h3>
</div>
<div className="px-6">
<Listbox
id="category"
layout="vertical"
label="What area are you having problems with?"
>
{CATEGORY_OPTIONS.map((option, i) => {
return (
<Listbox.Option
key={`option-${option.value}`}
label={option.label}
value={option.value}
className="min-w-[500px]"
>
<span>{option.label}</span>
<span className="block text-xs opacity-50">{option.description}</span>
</Listbox.Option>
)
})}
</Listbox>
</div>
{values.category !== 'Login_issues' && (
<div className="px-6">
<div className="grid sm:grid-cols-2 sm:grid-rows-1 gap-4 grid-cols-1 grid-rows-2">
{isLoadingProjects && (
<div className="space-y-2">
<p className="text-sm prose">Which project is affected?</p>
<ShimmeringLoader className="!py-[19px]" />
</div>
)}
{isErrorProjects && (
<div className="space-y-2">
<p className="text-sm prose">Which project is affected?</p>
<div className="border rounded-md px-4 py-2 flex items-center space-x-2">
<AlertCircle size={16} strokeWidth={2} className="text-foreground-light" />
<p className="text-sm prose">Failed to retrieve projects</p>
</div>
</div>
)}
{isSuccessProjects && (
<Listbox
id="projectRef"
layout="vertical"
label="Which project is affected?"
onChange={(val) => {
setSelectedProjectRef(val)
}}
>
{projects.map((option) => {
const organization = organizations?.find(
(x) => x.id === option.organization_id
)
return (
<Listbox.Option
key={`option-${option.ref}`}
label={option.name || ''}
value={option.ref}
className="!w-72"
>
<span>{option.name}</span>
<span className="block text-xs opacity-50">{organization?.name}</span>
</Listbox.Option>
)
})}
</Listbox>
)}
<Listbox id="severity" layout="vertical" label="Severity">
{SEVERITY_OPTIONS.map((option: any) => {
return (
<Listbox.Option
key={`option-${option.value}`}
label={option.label}
value={option.value}
className="!w-72"
>
<span>{option.label}</span>
<span className="block text-xs opacity-50">{option.description}</span>
</Listbox.Option>
)
})}
</Listbox>
</div>
{values.projectRef !== 'no-project' && subscription && isSuccessProjects ? (
<p className="text-sm text-foreground-light mt-2">
This project is on the{' '}
<span className="text-foreground-light">{subscription.plan.name} plan</span>
</p>
) : isLoadingSubscription && selectedProjectRef !== 'no-project' ? (
<div className="flex items-center space-x-2 mt-2">
<Loader2 size={14} className="animate-spin" />
<p className="text-sm text-foreground-light">Checking project's plan</p>
</div>
) : (
<></>
)}
{(values.severity === 'Urgent' || values.severity === 'High') && (
<p className="text-sm text-foreground-light mt-2">
We do our best to respond to everyone as quickly as possible; however,
prioritization will be based on production status. We ask that you reserve High
and Urgent severity for production-impacting issues only.
</p>
)}
</div>
)}
{isSuccessProjects &&
values.projectRef === 'no-project' &&
values.category !== 'Login_issues' && (
<div className="px-6">
{isLoadingOrganizations && (
<div className="space-y-2">
<p className="text-sm prose">Which organization is affected?</p>
<ShimmeringLoader className="!py-[19px]" />
</div>
)}
{isErrorOrganizations && (
<div className="space-y-2">
<p className="text-sm prose">Which organization is affected?</p>
<div className="border rounded-md px-4 py-2 flex items-center space-x-2">
<AlertCircle size={16} strokeWidth={2} className="text-foreground-light" />
<p className="text-sm prose">Failed to retrieve organizations</p>
</div>
</div>
)}
{isSuccessOrganizations && (
<Listbox
id="organizationSlug"
layout="vertical"
label="Which organization is affected?"
>
{organizations?.map((option) => {
return (
<Listbox.Option
key={`option-${option.slug}`}
label={option.name || ''}
value={option.slug}
>
<span>{option.name}</span>
</Listbox.Option>
)
})}
</Listbox>
)}
</div>
)}
{subscription?.plan.id !== 'enterprise' && values.category !== 'Login_issues' && (
<div className="px-6">
<InformationBox
icon={<AlertCircle size={18} strokeWidth={2} />}
defaultVisibility={true}
hideCollapse={true}
title="Expected response times are based on your project's plan"
description={
<div className="space-y-4 mb-1">
{subscription?.plan.id === 'free' && (
<p>
Free plan support is available within the community and officially by the
team on a best efforts basis. For a guaranteed response we recommend
upgrading to the Pro plan. Enhanced SLAs for support are available on our
Enterprise Plan.
</p>
)}
{subscription?.plan.id === 'pro' && (
<p>
Pro Plan includes email-based support. You can expect an answer within 1
business day in most situation for all severities. We recommend upgrading
to the Team plan for prioritized ticketing on all issues and prioritized
escalation to product engineering teams. Enhanced SLAs for support are
available on our Enterprise Plan.
</p>
)}
{subscription?.plan.id === 'team' && (
<p>
Team plan includes email-based support. You get prioritized ticketing on
all issues and prioritized escalation to product engineering teams. Low,
Normal, and High severity tickets will generally be handled within 1
business day, while Urgent issues, we respond within 1 day, 365 days a
year. Enhanced SLAs for support are available on our Enterprise Plan.
</p>
)}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-y-2 sm:gap-x-2">
<Button asChild>
<Link
href={`/org/${values.organizationSlug}/billing?panel=subscriptionPlan`}
>
Upgrade project
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://supabase.com/contact/enterprise"
target="_blank"
rel="noreferrer"
>
Enquire about Enterprise
</Link>
</Button>
</div>
</div>
}
/>
</div>
)}
<Separator />
{!isDisabled ? (
<>
{['Performance'].includes(values.category) && isFreeProject ? (
<DisabledStateForFreeTier
category={selectedCategory?.label ?? ''}
organizationSlug={selectedOrganizationSlug ?? ''}
/>
) : (
<>
<div className="px-6">
<Input
id="subject"
label="Subject"
placeholder="Summary of the problem you have"
descriptionText={
values.subject.length > 0 &&
INCLUDE_DISCUSSIONS.includes(values.category) ? (
<p className="flex items-center space-x-1">
<span>Check our </span>
<Link
key="gh-discussions"
href={`https://github.com/orgs/supabase/discussions?discussions_q=${values.subject}`}
target="_blank"
rel="noreferrer"
className="flex items-center space-x-2 text-foreground-light underline hover:text-foreground transition"
>
Github discussions
<ExternalLink size={14} strokeWidth={2} className="ml-1" />
</Link>
<span> for a quick answer</span>
</p>
) : null
}
/>
</div>
{values.category === 'Problem' && (
<div className="px-6">
<Listbox
id="library"
layout="vertical"
label="Which library are you having issues with?"
>
<Listbox.Option
disabled
label="Please select a library"
value="no-library"
className="min-w-[500px]"
>
<span>Please select a library</span>
</Listbox.Option>
{CLIENT_LIBRARIES.map((option, i) => {
return (
<Listbox.Option
key={`option-${option.key}`}
label={option.language}
value={option.language}
className="min-w-[500px]"
>
<span>{option.language}</span>
</Listbox.Option>
)
})}
</Listbox>
</div>
)}
{selectedLibrary !== undefined && (
<div className="px-6 space-y-4 !mt-4">
<div className="space-y-2">
<p className="text-sm text-foreground-light">
Found an issue or a bug? Try searching our Github issues or submit a new
one.
</p>
</div>
<div className="flex items-center space-x-4 overflow-x-auto">
{selectedClientLibraries?.map((library) => {
const libraryLanguage =
values.library === 'Dart (Flutter)'
? library.name.split('-')[1]
: values.library
return (
<div
key={library.name}
className="w-[230px] min-w-[230px] min-h-[128px] rounded border border-control bg-surface-100 space-y-3 px-4 py-3"
>
<div className="space-y-1">
<p className="text-sm">{library.name}</p>
<p className="text-sm text-foreground-light">
For issues regarding the {libraryLanguage} client library
</p>
</div>
<div>
<Button
asChild
type="default"
icon={<ExternalLink size={14} strokeWidth={1.5} />}
>
<Link href={library.url} target="_blank" rel="noreferrer">
View Github issues
</Link>
</Button>
</div>
</div>
)
})}
<div
className={[
'px-4 py-3 rounded border border-control bg-surface-100',
'w-[230px] min-w-[230px] min-h-[128px] flex flex-col justify-between space-y-3',
].join(' ')}
>
<div className="space-y-1">
<p className="text-sm">supabase</p>
<p className="text-sm text-foreground-light">
For any issues about our API
</p>
</div>
<div>
<Button
asChild
type="default"
icon={<ExternalLink size={14} strokeWidth={1.5} />}
>
<Link
href="https://github.com/supabase/supabase"
target="_blank"
rel="noreferrer"
>
View Github issues
</Link>
</Button>
</div>
</div>
</div>
</div>
)}
{values.category !== 'Login_issues' && (
<div className="px-6 space-y-2">
<p className="text-sm text-foreground-light">
Which services are affected?
</p>
<MultiSelect
options={SERVICE_OPTIONS}
value={selectedServices}
placeholder="No particular service"
searchPlaceholder="Search for a service"
onChange={setSelectedServices}
/>
</div>
)}
<div className="text-area-text-sm px-6 grid gap-4">
<Input.TextArea
id="message"
label="Message"
placeholder="Describe the issue you're facing, along with any relevant information. Please be as detailed and specific as possible."
limit={5000}
labelOptional="5000 character limit"
value={textAreaValue}
onChange={(e) => handleTextMessageChange(e)}
/>
{ipv4MigrationStringMatched && (
<Alert_Shadcn_ variant="default">
<HelpCircle strokeWidth={2} />
<AlertTitle_Shadcn_>Connection issues?</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="grid gap-3">
<p>
Having trouble connecting to your project? It could be related to our
migration from PGBouncer and IPv4.
</p>
<p>
Please review this GitHub discussion. It's up to date and covers many
frequently asked questions.
</p>
<p>
<Button
asChild
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="https://github.com/orgs/supabase/discussions/17817"
target="_blank"
rel="noreferrer"
>
PGBouncer and IPv4 Deprecation #17817
</Link>
</Button>
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</div>
{['Problem', 'Database_unresponsive', 'Performance'].includes(
values.category
) && (
<div className="px-6">
<Checkbox
name="allowSupportAccess"
label="Allow Supabase Support to access your project temporarily"
description="In some cases, we may require temporary access to your project to complete troubleshooting, or to answer questions related specifically to your project"
/>
</div>
)}
<div className="space-y-4 px-6">
<div className="space-y-1">
<p className="block text-sm text-foreground-light">Attachments</p>
<p className="block text-sm text-foreground-light">
Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the
issue that you're facing
</p>
</div>
<div>
<input
multiple
type="file"
// @ts-ignore
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={onFilesUpload}
/>
</div>
<div className="flex items-center space-x-2">
{uploadedDataUrls.map((x: any, idx: number) => (
<div
key={idx}
style={{ backgroundImage: `url("${x}")` }}
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
>
<div
className={[
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer',
].join(' ')}
onClick={() => removeUploadedFile(idx)}
>
<X size={12} strokeWidth={2} />
</div>
</div>
))}
{uploadedFiles.length < MAX_ATTACHMENTS && (
<div
className={[
'border border-stronger opacity-50 transition hover:opacity-100',
'group flex h-14 w-14 cursor-pointer items-center justify-center rounded',
].join(' ')}
onClick={() => {
if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
}}
>
<Plus strokeWidth={2} size={20} />
</div>
)}
</div>
</div>
<div className="px-6">
<div className="flex items-center space-x-1 justify-end block text-sm mt-0 mb-2">
<p className="text-foreground-light">We will contact you at</p>
<p className="text-foreground font-medium">{respondToEmail}</p>
</div>
<div className="flex items-center space-x-1 justify-end block text-sm mt-0 mb-2">
<p className="text-foreground-light">
Please ensure you haven't blocked Hubspot in your emails
</p>
</div>
<div className="flex justify-end">
<Button
htmlType="submit"
size="small"
icon={<Mail />}
disabled={isSubmitting}
loading={isSubmitting}
>
Send support request
</Button>
</div>
</div>
</>
)}
</>
) : (
<></>
)}
</div>
)
}}
</Form>
)
}
export default SupportForm