diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.schema.ts b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.schema.ts new file mode 100644 index 00000000000..56b67ce6904 --- /dev/null +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.schema.ts @@ -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 diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx new file mode 100644 index 00000000000..65a36291d81 --- /dev/null +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx @@ -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({ + 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 = (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 ( + + + ) +} diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx new file mode 100644 index 00000000000..7b0785034bc --- /dev/null +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketPage.tsx @@ -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 ( + + + + ) +} + +function LinkSupportTicketPageContent() { + const router = useRouter() + const conversationId = router.query.conversationId as string | undefined + + if (!conversationId) { + return ( + + ) + } + + return ( +
+ +
+ ) +} + +function LinkSupportTicketPageWrapper({ children }: PropsWithChildren) { + return ( +
+
+
{children}
+
+
+ ) +} diff --git a/apps/studio/data/feedback/link-support-ticket-mutation.ts b/apps/studio/data/feedback/link-support-ticket-mutation.ts new file mode 100644 index 00000000000..f5d77a2974c --- /dev/null +++ b/apps/studio/data/feedback/link-support-ticket-mutation.ts @@ -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> + +export const useLinkSupportTicketMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + 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, + }) +} diff --git a/apps/studio/pages/support/link.tsx b/apps/studio/pages/support/link.tsx new file mode 100644 index 00000000000..107b0f9d824 --- /dev/null +++ b/apps/studio/pages/support/link.tsx @@ -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 +} + +LinkSupportTicketPageRoute.getLayout = (page: ReactElement) => ( + + {page} + +) + +export default withAuth(LinkSupportTicketPageRoute, { useHighestAAL: false })