Files
supabase/apps/studio/components/interfaces/Support/SupportFormV3.tsx
Joshen Lim fcfb0f0222 Refactor all usage of form.watch to either useWatch or subscribe (#48436)
## Context

Replaces all usage of `form.watch()` to use `useWatch` instead + follows
the "name what you watch" convention as specified in the react-hook-form
skills.

There's also a small refactor in `SmtpForm.tsx` which removes the
unnecessary use of a `useState` to track if SMTP is enabled or not

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Updated many Studio forms to watch specific fields more precisely,
improving live UI updates for previews, warnings, conditional sections,
and validation messages.
* Enhanced responsiveness across settings, authentication, billing,
storage, integrations, and support flows while keeping save/update
behavior the same.
* **Refined Experiences**
* Improved the analytics table creation flow with tighter, enum-based
column type validation and structured, type-specific column options.
* **Preserved Behavior**
* Maintained existing permission checks, submission flows, and
account-management workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 11:45:40 +08:00

344 lines
12 KiB
TypeScript

// End of third-party imports
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { useConstant, useFlag } from 'common'
import { CLIENT_LIBRARIES } from 'common/constants'
import { type Dispatch, type MouseEventHandler } from 'react'
import type { SubmitHandler, UseFormReturn } from 'react-hook-form'
import { useWatch } from 'react-hook-form'
import { Form, Separator } from 'ui'
import { v4 as uuidv4 } from 'uuid'
import {
AffectedServicesSelector,
CATEGORIES_WITHOUT_AFFECTED_SERVICES,
} from './AffectedServicesSelector'
import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload'
import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo'
import { ClientLibraryInfo } from './ClientLibraryInfo'
import {
DASHBOARD_LOG_CATEGORIES,
getSanitizedBreadcrumbs,
uploadDashboardLog,
} from './dashboard-logs'
import { DashboardLogsToggle } from './DashboardLogsToggle'
import { MessageField } from './MessageField'
import { OrganizationSelector } from './OrganizationSelector'
import { PlanExpectationInfoContent, ProjectAndPlanInfo } from './ProjectAndPlanInfo'
import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo'
import { SubmitButton } from './SubmitButton'
import { SupportAccessToggle } from './SupportAccessToggle'
import type { SupportFormValues } from './SupportForm.schema'
import type { SupportFormActions, SupportFormState } from './SupportForm.state'
import {
canAllowSupportAccess,
formatMessage,
formatStudioVersion,
getOrgSubscriptionPlan,
NO_ORG_MARKER,
NO_PROJECT_MARKER,
} from './SupportForm.utils'
import { SupportFormDirectEmailContent } from './SupportFormDirectEmailInfo'
import { getProjectAuthConfig } from '@/data/auth/auth-config-query'
import { useSendSupportTicketMutation } from '@/data/feedback/support-ticket-send'
import { type OrganizationPlanID } from '@/data/organizations/organization-query'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useGenerateAttachmentURLsMutation } from '@/data/support/generate-attachment-urls-mutation'
import { useDeploymentCommitQuery } from '@/data/utils/deployment-commit-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { detectBrowser } from '@/lib/helpers'
import { useProfile } from '@/lib/profile'
const useIsSimplifiedForm = (slug: string, subscriptionPlanId?: OrganizationPlanID) => {
const simplifiedSupportForm = useFlag('simplifiedSupportForm')
if (subscriptionPlanId === 'platform') {
return true
}
if (typeof simplifiedSupportForm === 'string') {
const slugs = (simplifiedSupportForm as string).split(',').map((x) => x.trim())
return slugs.includes(slug)
}
return false
}
interface SupportFormV3Props {
form: UseFormReturn<SupportFormValues>
initialError: string | null
state: SupportFormState
dispatch: Dispatch<SupportFormActions>
selectedProjectRef?: string | null
}
export const SupportFormV3 = ({
form,
initialError,
state,
dispatch,
selectedProjectRef,
}: SupportFormV3Props) => {
const { profile } = useProfile()
const respondToEmail = profile?.primary_email ?? 'your email'
const [organizationSlug, projectRef, category, severity, subject, library] = useWatch({
control: form.control,
name: ['organizationSlug', 'projectRef', 'category', 'severity', 'subject', 'library'],
})
const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug
const currentProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef
const { data: organizations } = useOrganizationsQuery()
const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug)
const simplifiedSupportForm = useIsSimplifiedForm(organizationSlug, subscriptionPlanId)
const showClientLibraries = useIsFeatureEnabled('support:show_client_libraries')
const attachmentUpload = useAttachmentUpload()
const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation()
const sanitizedLogSnapshot = useConstant(getSanitizedBreadcrumbs)
const { data: commit } = useDeploymentCommitQuery({
staleTime: 1000 * 60 * 10,
})
const { mutate: submitSupportTicket } = useSendSupportTicketMutation({
onSuccess: (data, variables) => {
dispatch({
type: 'SUCCESS',
sentProjectRef: variables.projectRef,
sentOrgSlug: variables.organizationSlug,
sentCategory: variables.category,
submittedRequest: {
organizationSlug: variables.organizationSlug,
projectRef: variables.projectRef,
category: variables.category,
severity: variables.severity,
subject: variables.subject,
message: variables.message,
affectedServices: variables.affectedServices ?? '',
library: variables.library,
allowSupportAccess: variables.allowSupportAccess,
dashboardLogs: variables.dashboardLogs,
// Front conversation created by this submission + the thread_ref used to
// create it, so the AI support chat can append to the same conversation.
threadRef: variables.threadRef,
frontConversationId: data?.conversationId,
},
})
},
onError: (error) => {
dispatch({
type: 'ERROR',
message: error.message,
code: error.code,
})
},
})
const onSubmit: SubmitHandler<SupportFormValues> = async (formValues) => {
if (
!simplifiedSupportForm &&
showClientLibraries &&
formValues.category === SupportCategories.PROBLEM &&
!formValues.library
) {
form.setError('library', {
type: 'manual',
message: "Please select the library that you're facing issues with",
})
return
}
dispatch({ type: 'SUBMIT' })
const { attachDashboardLogs: formAttachDashboardLogs, ...values } = formValues
const attachDashboardLogs =
formAttachDashboardLogs && DASHBOARD_LOG_CATEGORIES.includes(values.category)
const [attachments, dashboardLogUrl] = await Promise.all([
attachmentUpload.createAttachments(),
attachDashboardLogs
? uploadDashboardLog({
userId: profile?.gotrue_id,
sanitizedLogs: sanitizedLogSnapshot,
uploadDashboardLogFn,
})
: undefined,
])
const selectedLibrary = values.library
? CLIENT_LIBRARIES.find((library) => library.language === values.library)
: undefined
const payload = {
...values,
organizationSlug: values.organizationSlug ?? NO_ORG_MARKER,
projectRef: values.projectRef ?? NO_PROJECT_MARKER,
allowSupportAccess: canAllowSupportAccess(values.category, values.projectRef)
? values.allowSupportAccess
: false,
library:
values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined
? selectedLibrary.key
: '',
message: formatMessage({
message: values.message,
attachments,
error: initialError,
}),
verified: true,
tags: ['dashboard-support-form'],
siteUrl: '',
additionalRedirectUrls: '',
affectedServices: CATEGORIES_WITHOUT_AFFECTED_SERVICES.includes(values.category)
? ''
: values.affectedServices
.split(',')
.map((x) => x.trim().replace(/ /g, '_').toLowerCase())
.join(';'),
browserInformation: detectBrowser(),
dashboardLogs: dashboardLogUrl?.[0],
dashboardStudioVersion: commit ? formatStudioVersion(commit) : undefined,
// Stable Front thread_ref so the AI support chat (if the user engages it) can
// be appended to the same Front conversation this submission creates. Use the
// uuid package rather than crypto.randomUUID(), which is undefined in insecure
// contexts (non-localhost HTTP) and would throw, silently aborting the submit.
threadRef: uuidv4(),
}
if (values.projectRef !== NO_PROJECT_MARKER) {
try {
const authConfig = await getProjectAuthConfig({
projectRef: values.projectRef,
})
payload.siteUrl = authConfig.SITE_URL
payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST
} catch {
// Nice-to-have only
}
}
submitSupportTicket(payload)
}
const handleFormSubmit = form.handleSubmit(onSubmit)
const handleSubmitButtonClick: MouseEventHandler<HTMLButtonElement> = (event) => {
handleFormSubmit(event)
}
const showPlanExpectationInfo =
!!selectedOrgSlug &&
subscriptionPlanId !== 'enterprise' &&
subscriptionPlanId !== 'platform' &&
category !== 'Login_issues'
const showDirectEmailInfo = state.type !== 'success' && selectedProjectRef !== undefined
return (
<Form {...form}>
<form id="support-form" className="flex min-h-full flex-col">
<div className="flex flex-col gap-y-6">
<OrganizationSelector form={form} orgSlug={organizationSlug} />
<ProjectAndPlanInfo
form={form}
orgSlug={selectedOrgSlug}
projectRef={currentProjectRef}
subscriptionPlanId={subscriptionPlanId}
category={category}
/>
<CategoryAndSeverityInfo
form={form}
category={category}
severity={severity}
projectRef={projectRef}
/>
</div>
<div className="flex flex-col gap-y-6 py-6">
<SubjectAndSuggestionsInfo form={form} subject={subject} category={category} />
{!simplifiedSupportForm && (
<>
<ClientLibraryInfo form={form} library={library} category={category} />
<AffectedServicesSelector form={form} category={category} />
</>
)}
<MessageField form={form} originalError={initialError} />
<AttachmentUploadDisplay {...attachmentUpload} />
</div>
{(DASHBOARD_LOG_CATEGORIES.includes(category) ||
canAllowSupportAccess(category, projectRef) ||
showPlanExpectationInfo ||
showDirectEmailInfo) && (
<div className="flex flex-col gap-y-6">
<Separator />
{DASHBOARD_LOG_CATEGORIES.includes(category) && (
<DashboardLogsToggle form={form} sanitizedLog={sanitizedLogSnapshot} align="right" />
)}
{canAllowSupportAccess(category, projectRef) && (
<SupportAccessToggle form={form} align="right" />
)}
{(showPlanExpectationInfo || showDirectEmailInfo) && (
<SupportFormV3AdditionalInfoSection
orgSlug={selectedOrgSlug}
subscriptionPlanId={subscriptionPlanId}
projectRef={currentProjectRef}
showPlanExpectationInfo={showPlanExpectationInfo}
showDirectEmailInfo={showDirectEmailInfo}
/>
)}
</div>
)}
<div className="sticky bottom-0 z-10 -mx-5 mt-6 border-t bg-panel-footer-light px-5 py-4">
<SubmitButton
isSubmitting={state.type === 'submitting'}
userEmail={respondToEmail}
onClick={handleSubmitButtonClick}
descriptionClassName="pr-0"
/>
</div>
</form>
</Form>
)
}
interface SupportFormV3AdditionalInfoSectionProps {
orgSlug: string | null
subscriptionPlanId?: OrganizationPlanID
projectRef: string | null
showPlanExpectationInfo: boolean
showDirectEmailInfo: boolean
}
function SupportFormV3AdditionalInfoSection({
orgSlug,
subscriptionPlanId,
projectRef,
showPlanExpectationInfo,
showDirectEmailInfo,
}: SupportFormV3AdditionalInfoSectionProps) {
return (
<div className="flex flex-col gap-y-5">
{showPlanExpectationInfo && orgSlug && (
<div className="flex flex-col gap-y-2">
<h5 className="text-foreground">Support varies by plan</h5>
<PlanExpectationInfoContent orgSlug={orgSlug} planId={subscriptionPlanId} />
</div>
)}
{showDirectEmailInfo && (
<div className="flex flex-col gap-y-2">
<h5 className="text-foreground">Having trouble submitting the form?</h5>
<SupportFormDirectEmailContent projectRef={projectRef} />
</div>
)}
</div>
)
}