mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
Reference in new issue
Block a user