feat: adds link conversation support form (#40553)

* feat: adds link conversation support form

* style: fixes prettier styling issues

* fix: type checking errors

* feat: adds redirect to 404 for self hosted

* style: prettier formatting
This commit is contained in:
barcofourie authored and GitHub committed 2025-11-20 08:23:20 -07:00
1 parent a4b6c63928
commit f5d9cb1b60
5 files changed
+539

No files matched your search

@@ -0,0 +1,27 @@
import { z } from 'zod'
import { CATEGORY_OPTIONS, type ExtendedSupportCategories } from './Support.constants'
import { NO_ORG_MARKER } from './SupportForm.utils'
export const LinkSupportTicketFormSchema = z.object({
conversation_id: z.string().min(1, 'Conversation ID is required'),
organizationSlug: z
.string()
.min(1, 'Please select an organization')
.refine((val) => val !== NO_ORG_MARKER, {
message: 'Please select an organization',
}),
projectRef: z.string().optional(),
category: z.enum(
CATEGORY_OPTIONS.map((opt) => opt.value) as [
ExtendedSupportCategories,
...ExtendedSupportCategories[],
],
{
required_error: 'Please select a category',
}
),
allowSupportAccess: z.boolean(),
})
export type LinkSupportTicketFormValues = z.infer<typeof LinkSupportTicketFormSchema>
@@ -0,0 +1,362 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, ChevronsUpDown, ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import type { SubmitHandler } from 'react-hook-form'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { useLinkSupportTicketMutation } from 'data/feedback/link-support-ticket-mutation'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import {
Badge,
Button,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
CommandGroup_Shadcn_,
CommandItem_Shadcn_,
DialogSectionSeparator,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Switch,
cn,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { CATEGORY_OPTIONS, type ExtendedSupportCategories } from './Support.constants'
import {
LinkSupportTicketFormSchema,
type LinkSupportTicketFormValues,
} from './LinkSupportTicketForm.schema'
import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
interface LinkSupportTicketFormProps {
conversationId: string
}
export function LinkSupportTicketForm({ conversationId }: LinkSupportTicketFormProps) {
const router = useRouter()
const { data: organizations } = useOrganizationsQuery()
const form = useForm<LinkSupportTicketFormValues>({
resolver: zodResolver(LinkSupportTicketFormSchema),
defaultValues: {
conversation_id: conversationId,
organizationSlug: NO_ORG_MARKER,
projectRef: NO_PROJECT_MARKER,
category: '' as any,
allowSupportAccess: true,
},
mode: 'onSubmit',
reValidateMode: 'onBlur',
})
const { mutate: linkSupportTicket, isLoading } = useLinkSupportTicketMutation({
onSuccess: () => {
toast.success('Support ticket linked successfully!')
router.push('/')
},
onError: (error) => {
toast.error(`Failed to link support ticket: ${error.message}`)
},
})
const organizationSlug = form.watch('organizationSlug')
const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug
const onSubmit: SubmitHandler<LinkSupportTicketFormValues> = (values) => {
if (!organizations) {
toast.error('Organizations not loaded. Please try again.')
return
}
const selectedOrg = organizations.find((org) => org.slug === values.organizationSlug)
if (!selectedOrg) {
toast.error('Selected organization not found. Please try again.')
return
}
linkSupportTicket({
conversation_id: values.conversation_id,
org_id: selectedOrg.id,
project_ref:
values.projectRef && values.projectRef !== NO_PROJECT_MARKER
? values.projectRef
: undefined,
category: values.category,
allow_support_access: values.allowSupportAccess,
})
}
return (
<Form_Shadcn_ {...form}>
<form
id="link-support-ticket-form"
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-y-6"
>
<h3 className="px-6 text-xl">Link support ticket to account</h3>
<div className="px-6 flex flex-col gap-y-8">
<FormField_Shadcn_
control={form.control}
name="conversation_id"
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical" label="Conversation ID">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} disabled readOnly />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="organizationSlug"
render={({ field }) => {
const { ref: _ref, ...fieldWithoutRef } = field
return (
<FormItemLayout layout="vertical" label="Which organization is affected?">
<FormControl_Shadcn_>
<Select_Shadcn_
{...fieldWithoutRef}
disabled={!organizations}
defaultValue={field.value}
onValueChange={(value) => {
const previousOrgSlug = form.getValues('organizationSlug')
field.onChange(value)
if (previousOrgSlug !== value) {
form.resetField('projectRef', { defaultValue: NO_PROJECT_MARKER })
}
}}
>
<SelectTrigger_Shadcn_ className="w-full" aria-label="Select an organization">
<SelectValue_Shadcn_ placeholder="Select an organization">
{organizationSlug === NO_ORG_MARKER
? 'Select an organization'
: organizations?.find((o) => o.slug === field.value)?.name}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{organizations?.map((org) => (
<SelectItem_Shadcn_ key={org.slug} value={org.slug}>
{org.name}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)
}}
/>
<FormField_Shadcn_
control={form.control}
name="projectRef"
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical" label="Which project is affected?">
<FormControl_Shadcn_>
<OrganizationProjectSelector
key={selectedOrgSlug}
sameWidthAsTrigger
fetchOnMount
checkPosition="left"
slug={
!selectedOrgSlug || selectedOrgSlug === NO_ORG_MARKER
? undefined
: selectedOrgSlug
}
selectedRef={field.value}
onSelect={(project) => field.onChange(project.ref)}
renderTrigger={({ isLoading, project }) => {
return (
<Button
block
type="default"
role="combobox"
aria-label="Select a project"
size="small"
className="justify-between"
iconRight={
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
}
disabled={!selectedOrgSlug}
>
{!selectedOrgSlug ? (
'Select an organization first'
) : isLoading ? (
<ShimmeringLoader className="w-44 py-2" />
) : !field.value || field.value === NO_PROJECT_MARKER ? (
'No specific project'
) : (
project?.name ?? 'Unknown project'
)}
</Button>
)
}}
renderActions={(setOpen) => (
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="w-full gap-x-2"
onSelect={() => {
field.onChange(NO_PROJECT_MARKER)
setOpen(false)
}}
>
{field.value === NO_PROJECT_MARKER && <Check size={16} />}
<p className={field.value !== NO_PROJECT_MARKER ? 'ml-6' : ''}>
No specific project
</p>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="category"
render={({ field }) => {
const { ref: _ref, ...fieldWithoutRef } = field
return (
<FormItemLayout
hideMessage
layout="vertical"
label="What are you having issues with?"
>
<FormControl_Shadcn_>
<Select_Shadcn_
{...fieldWithoutRef}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ aria-label="Select an issue" className="w-full">
<SelectValue_Shadcn_ placeholder="Select an issue">
{field.value
? CATEGORY_OPTIONS.find((o) => o.value === field.value)?.label
: null}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{CATEGORY_OPTIONS.filter((option) => !option.hidden).map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
<span className="block text-xs text-foreground-lighter">
{option.description}
</span>
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)
}}
/>
</div>
<DialogSectionSeparator />
<FormField_Shadcn_
control={form.control}
name="allowSupportAccess"
render={({ field }) => {
return (
<FormItemLayout
hideMessage
name="allowSupportAccess"
className="px-6"
layout="flex"
label={
<div className="flex items-center gap-x-2">
<span className="text-foreground">Allow support access to your project</span>
<Badge className="bg-opacity-100">Recommended</Badge>
</div>
}
description={
<div className="flex flex-col">
<span className="text-foreground-light">
Human support and AI diagnostic access.
</span>
<Collapsible_Shadcn_ className="mt-2">
<CollapsibleTrigger_Shadcn_
className={
'group flex items-center gap-x-1 group-data-[state=open]:text-foreground hover:text-foreground transition'
}
>
<ChevronRight
size={14}
className="transition-all group-data-[state=open]:rotate-90 text-foreground-muted -ml-1"
/>
<span className="text-sm">More information</span>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="text-sm text-foreground-light mt-2 space-y-2">
<p>
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.
</p>
<p>
We are committed to maintaining strict data privacy and security standards
in all support activities.{' '}
<Link
href="https://supabase.com/privacy"
target="_blank"
rel="noreferrer"
className="text-foreground-light underline hover:text-foreground transition"
>
Privacy Policy
</Link>
</p>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</div>
}
>
<Switch
size="large"
id="allowSupportAccess"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormItemLayout>
)
}}
/>
<div className="px-6 pt-2">
<Button
type="primary"
htmlType="submit"
size="large"
block
disabled={isLoading}
loading={isLoading}
>
Link support ticket to account
</Button>
</div>
</form>
</Form_Shadcn_>
)
}
@@ -0,0 +1,44 @@
import { useRouter } from 'next/router'
import type { PropsWithChildren } from 'react'
import { Admonition } from 'ui-patterns'
import { LinkSupportTicketForm } from './LinkSupportTicketForm'
export function LinkSupportTicketPage() {
return (
<LinkSupportTicketPageWrapper>
<LinkSupportTicketPageContent />
</LinkSupportTicketPageWrapper>
)
}
function LinkSupportTicketPageContent() {
const router = useRouter()
const conversationId = router.query.conversationId as string | undefined
if (!conversationId) {
return (
<Admonition
type="warning"
title="Missing conversation ID"
description="Please provide a conversationId in the URL to link your support ticket."
/>
)
}
return (
<div className="min-w-full w-full space-y-12 rounded border bg-panel-body-light shadow-md py-8 border-default">
<LinkSupportTicketForm conversationId={conversationId} />
</div>
)
}
function LinkSupportTicketPageWrapper({ children }: PropsWithChildren) {
return (
<div className="relative overflow-y-auto overflow-x-hidden">
<div className="mx-auto my-16 max-w-2xl w-full px-4 lg:px-6">
<div className="flex flex-col gap-y-8">{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,73 @@
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ExtendedSupportCategories } from 'components/interfaces/Support/Support.constants'
import { handleError, patch } from 'data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from 'types'
export type LinkSupportTicketVariables = {
conversation_id: string
org_id: number
project_ref?: string
category: ExtendedSupportCategories
allow_support_access: boolean
}
export async function linkSupportTicket({
conversation_id,
org_id,
project_ref,
category,
allow_support_access,
}: LinkSupportTicketVariables) {
const { data, error } = await patch(
'/platform/feedback/conversations/{conversation_id}/custom-fields',
{
params: { path: { conversation_id } },
body: {
org_id,
project_ref,
category,
allow_support_access,
},
}
)
if (error) {
handleError(error, {
alwaysCapture: true,
sentryContext: {
tags: {
linkSupportTicket: true,
},
},
})
}
return data
}
type LinkSupportTicketData = Awaited<ReturnType<typeof linkSupportTicket>>
export const useLinkSupportTicketMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<LinkSupportTicketData, ResponseError, LinkSupportTicketVariables>,
'mutationFn'
> = {}) => {
return useMutation<LinkSupportTicketData, ResponseError, LinkSupportTicketVariables>({
mutationFn: (vars) => linkSupportTicket(vars),
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to link support ticket: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+33
View File
@@ -0,0 +1,33 @@
import { useRouter } from 'next/router'
import type { ReactElement } from 'react'
import { useEffect } from 'react'
import { LinkSupportTicketPage } from 'components/interfaces/Support/LinkSupportTicketPage'
import AppLayout from 'components/layouts/AppLayout/AppLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { withAuth } from 'hooks/misc/withAuth'
import type { NextPageWithLayout } from 'types'
import { IS_PLATFORM } from 'lib/constants'
const LinkSupportTicketPageRoute: NextPageWithLayout = () => {
const router = useRouter()
useEffect(() => {
if (!IS_PLATFORM) {
router.push('/404')
}
}, [router])
if (!IS_PLATFORM) {
return null
}
return <LinkSupportTicketPage />
}
LinkSupportTicketPageRoute.getLayout = (page: ReactElement) => (
<AppLayout>
<DefaultLayout>{page}</DefaultLayout>
</AppLayout>
)
export default withAuth(LinkSupportTicketPageRoute, { useHighestAAL: false })