import { zodResolver } from '@hookform/resolvers/zod' import * as Sentry from '@sentry/nextjs' import { Book, ChevronRight, ExternalLink, Github, Loader2, Mail, Plus, X } from 'lucide-react' import Link from 'next/link' import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' 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 { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' import { Badge, Button, cn, Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_, Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, Separator, Switch, TextArea_Shadcn_, } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' import { LibrarySuggestions } from './LibrarySuggestions' import { PlanExpectationInfoBox } from './PlanExpectationInfoBox' import { CATEGORY_OPTIONS, IPV4_MIGRATION_STRINGS, SERVICE_OPTIONS, SEVERITY_OPTIONS, } from './Support.constants' import { formatMessage, uploadAttachments } from './SupportForm.utils' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] const CONTAINER_CLASSES = 'px-6' interface SupportFormV2Props { onProjectSelected: (value: string) => void onOrganizationSelected: (value: string) => void setSentCategory: (value: string) => void } // [Joshen] Just naming it as V2 for now for PR review purposes so its easier to view // This is a rewrite of the old SupportForm to use the new form components export const SupportFormV2 = ({ onProjectSelected: setSelectedProject, onOrganizationSelected: setSelectedOrganization, setSentCategory, }: SupportFormV2Props) => { const { profile } = useProfile() const { projectRef: ref, slug, category: urlCategory, subject: urlSubject, message: urlMessage, error, } = useParams() const uploadButtonRef = useRef(null) const [isSubmitting, setIsSubmitting] = useState(false) const [docsResults, setDocsResults] = useState([]) const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const FormSchema = z .object({ organizationSlug: z.string().min(1, 'Please select an organization'), projectRef: z.string().min(1, 'Please select a project'), category: z.string(), severity: z.string(), library: z.string(), subject: z.string().min(1, 'Please add a subject heading'), message: z.string().min(1, "Please add a message about the issue that you're facing"), affectedServices: z.string(), allowSupportAccess: z.boolean(), }) .refine( (data) => { return !(data.category === 'Problem' && data.library === '') }, { message: "Please select the library that you're facing issues with", path: ['library'], } ) const defaultValues = { organizationSlug: '', projectRef: 'no-project', category: CATEGORY_OPTIONS[0].value, severity: 'Low', library: '', subject: '', message: '', affectedServices: '', allowSupportAccess: true, } const form = useForm>({ mode: 'onBlur', reValidateMode: 'onBlur', resolver: zodResolver(FormSchema), defaultValues, }) const { organizationSlug, projectRef, category, severity, subject, library } = form.watch() const { handleDocsSearchDebounced, searchState, searchState: state } = useDocsSearch() const { data: organizations, isLoading: isLoadingOrganizations, isSuccess: isSuccessOrganizations, } = useOrganizationsQuery() const selectedOrganization = useMemo( () => organizations?.find((org) => org.slug === organizationSlug), [organizationSlug, organizations] ) const { data, isLoading: isLoadingProjects, isSuccess: isSuccessProjects } = useProjectsQuery() const allProjects = data?.projects ?? [] const { mutate: sendEvent } = useSendEventMutation() const { mutate: submitSupportTicket } = useSendSupportTicketMutation({ onSuccess: (res, variables) => { toast.success('Support request sent. Thank you!') setSentCategory(variables.category) sendEvent({ action: 'support_ticket_submitted', properties: { ticketCategory: variables.category, }, groups: { project: projectRef === 'no-project' ? undefined : projectRef, organization: variables.organizationSlug, }, }) setSelectedProject(variables.projectRef ?? 'no-project') }, onError: (error) => { toast.error(`Failed to submit support ticket: ${error.message}`) Sentry.captureMessage('Failed to submit Support Form: ' + error.message) setIsSubmitting(false) }, }) const respondToEmail = profile?.primary_email ?? 'your email' const subscriptionPlanId = selectedOrganization?.plan.id const projects = [ ...(allProjects ?? []).filter((project) => project.organization_slug === organizationSlug), { ref: 'no-project', name: 'No specific project' } as Partial, ] const hasResults = state.status === 'fullResults' || state.status === 'partialResults' || (state.status === 'loading' && state.staleResults.length > 0) 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) { 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 onSubmit: SubmitHandler> = async (values) => { 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, organizationSlug: values.organizationSlug === 'no-org' ? undefined : values.organizationSlug, library: values.category === 'Problem' && selectedLibrary !== undefined ? selectedLibrary.key : '', message: formatMessage(values.message, attachments, error), verified: true, tags: ['dashboard-support-form'], siteUrl: '', additionalRedirectUrls: '', affectedServices: values.affectedServices .split(',') .map((x) => x.trim().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 } catch (error) { // [Joshen] No error handler required as fetching these info are nice to haves, not necessary } } submitSupportTicket(payload) } useEffect(() => { if (subject !== urlSubject && subject.trim().length > 0) { handleDocsSearchDebounced(subject.trim()) } else { setDocsResults([]) } }, [subject, urlSubject]) useEffect(() => { if (subject.trim().length > 0 && searchState.status === 'fullResults') { setDocsResults(searchState.results) } else if (searchState.status === 'noResults' || searchState.status === 'error') { setDocsResults([]) } }, [searchState]) useEffect(() => { if (!uploadedFiles) return const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file)) setUploadedDataUrls(objectUrls) return () => { objectUrls.forEach((url: any) => URL.revokeObjectURL(url)) } }, [uploadedFiles]) useEffect(() => { // For prefilling form fields via URL, project ref will taking higher precedence than org slug if (isSuccessOrganizations && isSuccessProjects) { if (organizations.length === 0) { form.setValue('organizationSlug', 'no-org') } else if (ref) { const selectedProject = allProjects.find((p) => p.ref === ref) if (selectedProject !== undefined) { form.setValue('organizationSlug', selectedProject.organization_slug) form.setValue('projectRef', selectedProject.ref) } } else if (slug) { if (organizations.some((it) => it.slug === slug)) { form.setValue('organizationSlug', slug) } } else if (ref === undefined && slug === undefined) { const firstOrganization = organizations?.[0] if (firstOrganization !== undefined) { form.setValue('organizationSlug', firstOrganization.slug) } } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, slug, isSuccessOrganizations, isSuccessProjects]) useEffect(() => { if (urlCategory) { const validCategory = CATEGORY_OPTIONS.find((option) => { if (option.value.toLowerCase() === ((urlCategory as string) ?? '').toLowerCase()) return option }) if (validCategory !== undefined) form.setValue('category', validCategory.value) } }, [urlCategory]) useEffect(() => { if (urlSubject) form.setValue('subject', urlSubject) }, [urlSubject]) useEffect(() => { if (urlMessage) form.setValue('message', urlMessage) }, [urlMessage]) // Sync organization selection with parent state // Initialized as 'no-org' in parent if no org is selected useEffect(() => { setSelectedOrganization(organizationSlug) }, [organizationSlug, setSelectedOrganization]) // Sync project selection with parent state // Initialized as 'no-project' in parent if no project is selected useEffect(() => { setSelectedProject(projectRef) }, [projectRef, setSelectedProject]) return (

How can we help?

(
{organizationSlug === 'no-org' ? ( No specific organization ) : ( (organizations ?? []).find((o) => o.slug === field.value)?.name )} {subscriptionPlanId && ( {subscriptionPlanId} )}
{organizations?.map((org) => ( {org.name} ))} {isSuccessOrganizations && (organizations ?? []).length === 0 && ( No specific organization )}
)} />
( { if (val.length > 0) field.onChange(val) }} > {projects?.map((project) => ( {project.name} ))} )} /> {organizationSlug && subscriptionPlanId !== 'enterprise' && category !== 'Login_issues' && ( )}
( {CATEGORY_OPTIONS.find((o) => o.value === field.value)?.label} {CATEGORY_OPTIONS.map((option) => ( {option.label} {option.description} ))} )} /> ( {field.value} {SEVERITY_OPTIONS.map((option) => ( {option.label} {option.description} ))} )} /> {(severity === 'Urgent' || severity === 'High') && (

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.

)}
( )} /> {searchState.status === 'loading' && docsResults.length === 0 && (
Searching for relevant resources...
)} {docsResults.length > 0 && hasResults && ( <>
AI Suggested resources
{searchState.status === 'loading' && (
Updating results...
)}
    {docsResults.slice(0, 5).map((page, i) => { return (
  • {page.type === 'github-discussions' ? ( ) : ( )} {page.title}
  • ) })}
)} {form.getValues('subject').length > 0 && INCLUDE_DISCUSSIONS.includes(category) && (

Check our GitHub discussions for a quick answer

)}
{category === 'Problem' && ( ( {CLIENT_LIBRARIES.map((option) => ( {option.language} ))} )} /> )} {library.length > 0 && } {category !== 'Login_issues' && ( ( form.setValue('affectedServices', services.join(', '))} /> )} /> )} ( field.value.includes(str)) && ( ) } > {error !== undefined && ( )} )} />

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() }} >
)}
{['Problem', 'Database_unresponsive', 'Performance'].includes(category) && ( <> { return ( Allow support access to your project Recommended } description={
Human support and AI diagnostic access. More information

By enabling this, you grant permission for our support team to access your project temporarily and, if applicable, to use AI tools to assist in diagnosing and resolving issues. This access may involve analyzing database configurations, query performance, and other relevant data to expedite troubleshooting and enhance support accuracy.

We are committed to maintaining strict data privacy and security standards in all support activities.{' '} Privacy Policy

} >
) }} /> )}
We will contact you at {respondToEmail}
Please ensure emails from supabase.io are allowed
) }