import { useParams } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' import { observer } from 'mobx-react-lite' import Link from 'next/link' import { useRouter } from 'next/router' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { Button, Checkbox, Form, IconAlertCircle, IconExternalLink, IconLoader, IconMail, IconPlus, IconX, Input, Listbox, } from 'ui' import Divider from 'components/ui/Divider' import InformationBox from 'components/ui/InformationBox' import MultiSelect from 'components/ui/MultiSelect' 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 { useProjectsQuery } from 'data/projects/projects-query' import { useFlag, useStore } from 'hooks' import useLatest from 'hooks/misc/useLatest' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' import { Project } from 'types' import DisabledStateForFreeTier from './DisabledStateForFreeTier' import { CATEGORY_OPTIONS, SERVICE_OPTIONS, SEVERITY_OPTIONS } from './Support.constants' import { formatMessage, uploadAttachments } from './SupportForm.utils' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] export interface SupportFormProps { setSentCategory: (value: string) => void } const SupportForm = ({ setSentCategory }: SupportFormProps) => { const { ui } = useStore() const { isReady } = useRouter() const { ref, subject, category, message } = useParams() const uploadButtonRef = useRef() const enableFreeSupport = useFlag('enableFreeSupport') const [isSubmitting, setIsSubmitting] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const [selectedServices, setSelectedServices] = 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) => { ui.setNotification({ category: 'success', message: 'Support request sent. Thank you!' }) setSentCategory(variables.category) }, onError: (error) => { ui.setNotification({ error, category: 'error', message: `Failed to submit support ticket: ${error.message}`, }) setIsSubmitting(false) }, }) const projectDefaults: Partial[] = [{ ref: 'no-project', name: 'No specific project' }] const projects = [...(allProjects ?? []), ...projectDefaults] const selectedProjectFromUrl = projects.find((project) => project.ref === ref) 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 = 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, }) useEffect(() => { if (!uploadedFiles) return const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file)) setUploadedDataUrls(objectUrls) return () => { objectUrls.forEach((url: any) => URL.revokeObjectURL(url)) } }, [uploadedFiles]) 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) => { 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) { ui.setNotification({ category: 'info', message: `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) } return (
{({ 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]) // 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 (

How can we help?

{CATEGORY_OPTIONS.map((option, i) => { return ( {option.label} {option.description} ) })}
{values.category !== 'Login_issues' && (
{isLoadingProjects && (

Which project is affected?

)} {isErrorProjects && (

Which project is affected?

Failed to retrieve projects

)} {isSuccessProjects && ( { setSelectedProjectRef(val) }} > {projects.map((option) => { const organization = organizations?.find( (x) => x.id === option.organization_id ) return ( {option.name} {organization?.name} ) })} )} {SEVERITY_OPTIONS.map((option: any) => { return ( {option.label} {option.description} ) })}
{values.projectRef !== 'no-project' && subscription && isSuccessProjects ? (

This project is on the{' '} {subscription.plan.name} plan

) : isLoadingSubscription && selectedProjectRef !== 'no-project' ? (

Checking project's plan

) : ( <> )}
)} {isSuccessProjects && values.projectRef === 'no-project' && values.category !== 'Login_issues' && (
{isLoadingOrganizations && (

Which organization is affected?

)} {isErrorOrganizations && (

Which organization is affected?

Failed to retrieve organizations

)} {isSuccessOrganizations && ( {organizations?.map((option) => { return ( {option.name} ) })} )}
)} {subscription?.plan.id === 'free' && values.category !== 'Login_issues' && (
} title="Expected response times are based on your project's plan" description={

Free plan support is available within the community and officially by the team on a best efforts basis, though we cannot guarantee a response time. For a guaranteed response time we recommend upgrading to the Pro plan. Enhanced SLAs for support are available on our Enterprise Plan.

} />
)} {!isDisabled ? ( <> {['Performance'].includes(values.category) && isFreeProject ? ( ) : ( <>
0 && INCLUDE_DISCUSSIONS.includes(values.category) ? (

Check our Github discussions for a quick answer

) : null } />
{values.category === 'Problem' && (
Please select a library {CLIENT_LIBRARIES.map((option, i) => { return ( {option.language} ) })}
)} {selectedLibrary !== undefined && (

Found an issue or a bug? Try searching our Github issues or submit a new one.

{selectedClientLibraries?.map((library) => { const libraryLanguage = values.library === 'Dart (Flutter)' ? library.name.split('-')[1] : values.library return (

{library.name}

For issues regarding the {libraryLanguage} client library

) })}

supabase

For any issues about our API

)} {values.category !== 'Login_issues' && (

Which services are affected?

)}
{['Problem', 'Database_unresponsive', 'Performance'].includes( values.category ) && (
)}

Attachments

Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that you're facing

{uploadedDataUrls.map((x: any, idx: number) => (
removeUploadedFile(idx)} >
))} {uploadedFiles.length < MAX_ATTACHMENTS && (
{ if (uploadButtonRef.current) (uploadButtonRef.current as any).click() }} >
)}

We will contact you at

{respondToEmail}

Please ensure you haven't blocked Hubspot in your emails

)} ) : ( <> )}
) }}
) } export default observer(SupportForm)