Refactor SupportForm to use react hook form + new form components (#28836)

* Dont hide org field when project is selected

* Refactor SupportForm to use new form components and fix UX oddities identified

* Add scroll area to docs search results

* Address feedback

* Small padding nudge

* Add check

---------

Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
Joshen LimandTerry Sutton authored and GitHub committed 2024-09-04 16:35:54 +08:00
1 parent 553af18172
commit 454fda86bf
13 files changed
+1164 -1162

No files matched your search

@@ -1,5 +1,6 @@
import { AlertCircle, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Button, IconAlertCircle, IconExternalLink } from 'ui'
import { Button } from 'ui'
import InformationBox from 'components/ui/InformationBox'
@@ -16,19 +17,19 @@ const DisabledStateForFreeTier = ({
<div className="px-6">
<InformationBox
hideCollapse
defaultVisibility={true}
icon={<IconAlertCircle className="text-foreground" size="large" strokeWidth={1.5} />}
defaultVisibility
icon={<AlertCircle className="text-foreground" size={18} strokeWidth={1.5} />}
title={`Support for ${category} is only available on the Pro Plan`}
description={
<div className="space-y-4 mb-1">
<p>Upgrade your project to the Pro Plan for support in this area</p>
<p>Upgrade your organization to the Pro Plan for support in this area</p>
<div className="flex items-center space-x-2">
<Button asChild>
<Link href={`/org/${organizationSlug}/billing?panel=subscriptionPlan`}>
Upgrade project
Upgrade plan
</Link>
</Button>
<Button asChild type="default" icon={<IconExternalLink size={14} />}>
<Button asChild type="default" icon={<ExternalLink />}>
<Link href="https://supabase.com/pricing" target="_blank" rel="noreferrer">
About the Pro Plan
</Link>
@@ -0,0 +1,96 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { type DocsSearchResult as Page, type DocsSearchResultSection as PageSection } from 'common'
import { cn } from 'ui'
import { TextHighlighterBase as TextHighlighter } from 'ui-patterns/CommandMenu'
import { formatSectionUrl, generateLink, getPageIcon } from './SupportForm.utils'
const cardBaseClasses =
'bg-200 rounded-lg hover:bg-surface-200 p-3 transition-colors hover:border-overlay hover:shadow-sm flex items-center justify-between'
interface DocsLinkGroup {
page: Page
}
export const DocsLinkGroup = ({ page }: DocsLinkGroup) => {
const link = generateLink(page.type, page.path)
return (
<ul key={page.id} className="grid gap-2">
<li key={`${page.path}-group`} className="px-2">
<Link
target="_blank"
rel="noreferrer"
href={link}
className={cn(cardBaseClasses, 'flex items-center justify-between pr-5')}
>
<div className="grow flex gap-3 items-center">
<div>{getPageIcon(page)}</div>
<div className="flex flex-col gap-0 pr-6">
<span className="text-sm">
<TextHighlighter text={page.title} query="test" />
</span>
{(page.description || page.subtitle) && (
<div className="text-xs text">
<TextHighlighter text={page.description || page.subtitle || ''} query="test" />
</div>
)}
</div>
</div>
<ChevronRight size={18} />
</Link>
{page.sections.length > 0 && (
<ul className="border-l border-default ml-3 pt-3 grid gap-2">
{page.sections.map((section: PageSection, i) => (
<DocsLinkSection
key={`${page.path}__${section.heading}-item-${i}`}
page={page}
section={section}
/>
))}
</ul>
)}
</li>
</ul>
)
}
interface DocsLinkSection {
page: Page
section: PageSection
}
const DocsLinkSection = ({ page, section }: DocsLinkSection) => {
const sectionLink = formatSectionUrl(page, section)
return (
<ul key={`${section.heading}-group`} className="grid gap-2">
<li key={`${section.heading}-item`} className="p-2 mb-2">
<Link target="_blank" href={sectionLink} className={cn(cardBaseClasses)}>
<div className="grow flex gap-3 items-center">
<div>{getPageIcon(page)}</div>
<div className="grid gap-1.5">
{page.type !== 'github-discussions' && (
<span>
<TextHighlighter
className="not-italic text-xs rounded-full px-3 py-1 bg-surface-300 "
text={section.heading}
query="test"
/>
</span>
)}
{section.heading && (
<div className="text text-xs ">
<TextHighlighter text={section.heading} query="test" />
</div>
)}
</div>
</div>
<ChevronRight size={18} />
</Link>
</li>
</ul>
)
}
@@ -0,0 +1,34 @@
import { ExternalLink, HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
export const IPV4SuggestionAlert = () => {
return (
<Alert_Shadcn_ variant="default">
<HelpCircle strokeWidth={2} />
<AlertTitle_Shadcn_>Connection issues?</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="grid gap-3">
<p>
Having trouble connecting to your project? It could be related to our migration from
PGBouncer and IPv4.
</p>
<p>
Please review this GitHub discussion. It's up to date and covers many frequently asked
questions.
</p>
<p>
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
target="_blank"
rel="noreferrer"
href="https://github.com/orgs/supabase/discussions/17817"
>
PGBouncer and IPv4 Deprecation #17817
</Link>
</Button>
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
@@ -0,0 +1,68 @@
import { CLIENT_LIBRARIES } from 'common'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
interface LibrarySuggestionsProps {
library: string
}
export const LibrarySuggestions = ({ library }: LibrarySuggestionsProps) => {
const selectedLibrary = CLIENT_LIBRARIES.find((lib) => lib.language === library)
const selectedClientLibraries = selectedLibrary?.libraries.filter((library) =>
library.name.includes('supabase-')
)
return (
<div className="px-6 flex flex-col gap-y-4">
<div className="space-y-2">
<p className="text-sm text-foreground-light">
Found an issue or a bug? Try searching our Github issues or submit a new one.
</p>
</div>
<div className="flex items-center space-x-4 overflow-x-auto">
{selectedClientLibraries?.map((lib) => {
const libraryLanguage = library === 'Dart (Flutter)' ? lib.name.split('-')[1] : library
return (
<div
key={lib.name}
className="w-[230px] min-w-[230px] min-h-[128px] rounded border border-control bg-surface-100 space-y-3 px-4 py-3"
>
<div className="space-y-1">
<p className="text-sm">{lib.name}</p>
<p className="text-sm text-foreground-light">
For issues regarding the {libraryLanguage} client library
</p>
</div>
<div>
<Button asChild type="default" icon={<ExternalLink size={14} strokeWidth={1.5} />}>
<Link href={lib.url} target="_blank" rel="noreferrer">
View Github issues
</Link>
</Button>
</div>
</div>
)
})}
<div
className={[
'px-4 py-3 rounded border border-control bg-surface-100',
'w-[230px] min-w-[230px] min-h-[128px] flex flex-col justify-between space-y-3',
].join(' ')}
>
<div className="space-y-1">
<p className="text-sm">supabase</p>
<p className="text-sm text-foreground-light">For any issues about our API</p>
</div>
<div>
<Button asChild type="default" icon={<ExternalLink size={14} strokeWidth={1.5} />}>
<Link href="https://github.com/supabase/supabase" target="_blank" rel="noreferrer">
View Github issues
</Link>
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,76 @@
import InformationBox from 'components/ui/InformationBox'
import { AlertCircle, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Button } from 'ui'
interface PlanExpectationInfoBoxProps {
orgSlug: string
projectRef: string
planId?: string
}
export const PlanExpectationInfoBox = ({
orgSlug,
projectRef,
planId,
}: PlanExpectationInfoBoxProps) => {
return (
<InformationBox
icon={<AlertCircle size={18} strokeWidth={2} />}
defaultVisibility={true}
hideCollapse={true}
title={
projectRef === 'no-project'
? 'Please note that no project has been selected'
: "Expected response times are based on your organization's plan"
}
{...(projectRef !== 'no-project' && {
description: (
<div className="flex flex-col gap-y-4 mb-1">
{planId === 'free' && (
<p>
Free Plan support is available within the community and officially by the team on a
best efforts basis. For a guaranteed response we recommend upgrading to the Pro
Plan. Enhanced SLAs for support are available on our Enterprise Plan.
</p>
)}
{planId === 'pro' && (
<p>
Pro Plan includes email-based support. You can expect an answer within 1 business
day in most situations for all severities. We recommend upgrading to the Team Plan
for prioritized ticketing on all issues and prioritized escalation to product
engineering teams. Enhanced SLAs for support are available on our Enterprise Plan.
</p>
)}
{planId === 'team' && (
<p>
Team Plan includes email-based support. You get prioritized ticketing on all issues
and prioritized escalation to product engineering teams. Low, Normal, and High
severity tickets will generally be handled within 1 business day, while Urgent
issues, we respond within 1 day, 365 days a year. Enhanced SLAs for support are
available on our Enterprise Plan.
</p>
)}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-y-2 sm:gap-x-2">
<Button asChild>
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan`}>Upgrade project</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://supabase.com/contact/enterprise"
target="_blank"
rel="noreferrer"
>
Enquire about Enterprise
</Link>
</Button>
</div>
</div>
),
})}
/>
)
}
@@ -1,9 +1,10 @@
import { Check, ExternalLink, Mail, Search } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { Button, IconCheck, IconExternalLink, IconMail, IconSearch, Input, Separator } from 'ui'
import { CATEGORY_OPTIONS } from './Support.constants'
import { useProfile } from 'lib/profile'
import { Button, Input, Separator } from 'ui'
import { CATEGORY_OPTIONS } from './Support.constants'
interface SuccessProps {
sentCategory?: string
@@ -30,12 +31,12 @@ const Success = ({
return (
<div className="mt-10 w-[620px] flex flex-col items-center space-y-4">
<div className="relative">
<IconMail strokeWidth={1.5} size={60} className="text-brand" />
<Mail strokeWidth={1.5} size={60} className="text-brand" />
<div className="h-6 w-6 rounded-full bg-brand absolute bottom-1 -right-1.5 flex items-center justify-center">
<IconCheck strokeWidth={4} size={18} />
<Check strokeWidth={4} size={18} />
</div>
</div>
<div className="flex items-center flex-col space-y-2 text-center">
<div className="flex items-center flex-col space-y-2 text-center p-4">
<h3 className="text-xl">Support request successfully sent!</h3>
<p className="text-sm text-foreground-light">
We will reach out to you at <span className="text-foreground">{respondToEmail}</span>.
@@ -64,14 +65,14 @@ const Success = ({
<Input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
icon={<IconSearch size={16} strokeWidth={1.5} />}
icon={<Search size={16} strokeWidth={1.5} />}
actions={[
<Button
key="search"
asChild
className="mr-1"
type="default"
icon={<IconExternalLink size={16} strokeWidth={1.5} />}
icon={<ExternalLink />}
>
<Link
href={`https://github.com/supabase/supabase/discussions?discussions_q=${searchValue}`}
@@ -124,3 +124,18 @@ export const SERVICE_OPTIONS = [
disabled: false,
},
]
export const IPV4_MIGRATION_STRINGS = [
'ipv4',
'ipv6',
'supavisor',
'pgbouncer',
'5432',
'ENETUNREACH',
'ECONNREFUSED',
'P1001',
'connect: no route to',
'network is unreac',
'could not translate host name',
'address family not supported by protocol',
]
File diff suppressed because it is too large. Load diff
@@ -1,57 +0,0 @@
import { compact } from 'lodash'
import { createClient } from '@supabase/supabase-js'
import { uuidv4 } from 'lib/helpers'
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
export const uploadAttachments = async (ref: string, files: File[]) => {
const supportSupabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
// @ts-ignore
multiTab: false,
detectSessionInUrl: false,
localStorage: {
getItem: (key: string) => undefined,
setItem: (key: string, value: string) => {},
removeItem: (key: string) => {},
},
},
})
const filesToUpload = Array.from(files)
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
const suffix = file.type.split('/')[1]
const prefix = `${ref}/${uuidv4()}.${suffix}`
const options = { cacheControl: '3600' }
const { data, error } = await supportSupabaseClient.storage
.from('support-attachments')
.upload(prefix, file, options)
if (error) console.error('Failed to upload:', file.name, error)
return data
})
)
const keys = compact(uploadedFiles).map((file) => file.path)
const { data, error } = await supportSupabaseClient.storage
.from('support-attachments')
.createSignedUrls(keys, 10 * 365 * 24 * 60 * 60)
if (error) {
console.error('Failed to retrieve URLs for attachments', error)
}
return data ? data.map((file) => file.signedUrl) : []
}
export const formatMessage = (message: string, attachments: string[]) => {
if (attachments.length > 0) {
const attachmentsImg = attachments.map((url) => `\n${url}`)
return `${message}\n${attachmentsImg.join('')}`
} else {
return message
}
}
@@ -0,0 +1,120 @@
import { createClient } from '@supabase/supabase-js'
import { compact } from 'lodash'
import { Book, Github, Hash, MessageSquare } from 'lucide-react'
import {
DocsSearchResultType as PageType,
type DocsSearchResult as Page,
type DocsSearchResultSection as PageSection,
} from 'common'
import { uuidv4 } from 'lib/helpers'
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
export const uploadAttachments = async (ref: string, files: File[]) => {
const supportSupabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
// @ts-ignore
multiTab: false,
detectSessionInUrl: false,
localStorage: {
getItem: (key: string) => undefined,
setItem: (key: string, value: string) => {},
removeItem: (key: string) => {},
},
},
})
const filesToUpload = Array.from(files)
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
const suffix = file.type.split('/')[1]
const prefix = `${ref}/${uuidv4()}.${suffix}`
const options = { cacheControl: '3600' }
const { data, error } = await supportSupabaseClient.storage
.from('support-attachments')
.upload(prefix, file, options)
if (error) console.error('Failed to upload:', file.name, error)
return data
})
)
const keys = compact(uploadedFiles).map((file) => file.path)
if (keys.length === 0) return []
const { data, error } = await supportSupabaseClient.storage
.from('support-attachments')
.createSignedUrls(keys, 10 * 365 * 24 * 60 * 60)
if (error) {
console.error('Failed to retrieve URLs for attachments', error)
}
return data ? data.map((file) => file.signedUrl) : []
}
export const formatMessage = (message: string, attachments: string[]) => {
if (attachments.length > 0) {
const attachmentsImg = attachments.map((url) => `\n${url}`)
return `${message}\n${attachmentsImg.join('')}`
} else {
return message
}
}
export function getPageIcon(page: Page) {
switch (page.type) {
case PageType.Markdown:
case PageType.Reference:
case PageType.Integration:
return <Book strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
case PageType.GithubDiscussion:
return <Github strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
default:
throw new Error(`Unknown page type '${page.type}'`)
}
}
export function getPageSectionIcon(page: Page) {
switch (page.type) {
case PageType.Markdown:
case PageType.Reference:
case PageType.Integration:
return <Hash strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
case PageType.GithubDiscussion:
return <MessageSquare strokeWidth={1.5} className="!mr-0 !w-4 !h-4" />
default:
throw new Error(`Unknown page type '${page.type}'`)
}
}
export function generateLink(pageType: PageType, link: string): string {
switch (pageType) {
case PageType.Markdown:
case PageType.Reference:
return `https://supabase.com/docs${link}`
case PageType.Integration:
return `https://supabase.com${link}`
case PageType.GithubDiscussion:
return link
default:
throw new Error(`Unknown page type '${pageType}'`)
}
}
export function formatSectionUrl(page: Page, section: PageSection): string {
switch (page.type) {
case PageType.Markdown:
case PageType.GithubDiscussion:
return `${generateLink(page.type, page.path)}#${section.slug ?? ''}`
case PageType.Reference:
return `${generateLink(page.type, page.path)}/${section.slug ?? ''}`
case PageType.Integration:
return generateLink(page.type, page.path) // Assuming no section slug for Integration pages
default:
throw new Error(`Unknown page type '${page.type}'`)
}
}
@@ -0,0 +1,719 @@
import { zodResolver } from '@hookform/resolvers/zod'
import * as Sentry from '@sentry/nextjs'
import { useSupabaseClient } from '@supabase/auth-helpers-react'
import { ExternalLink, Loader2, Mail, Plus, X } from 'lucide-react'
import Link from 'next/link'
import { ChangeEvent, useEffect, 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 { 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 { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useFlag } from 'hooks/ui/useFlag'
import { useProfile } from 'lib/profile'
import {
Badge,
Button,
Checkbox_Shadcn_,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
ScrollArea,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Separator,
TextArea_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import DisabledStateForFreeTier from './DisabledStateForFreeTier'
import { DocsLinkGroup } from './DocsLink'
import { PlanExpectationInfoBox } from './PlanExpectationInfoBox'
import {
CATEGORY_OPTIONS,
IPV4_MIGRATION_STRINGS,
SERVICE_OPTIONS,
SEVERITY_OPTIONS,
} from './Support.constants'
import { LibrarySuggestions } from './LibrarySuggestions'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import { IPV4SuggestionAlert } from './IPV4SuggestionAlert'
import { formatMessage, uploadAttachments } from './SupportForm.utils'
import { detectBrowser } from 'lib/helpers'
import { getProjectAuthConfig } from 'data/auth/auth-config-query'
const MAX_ATTACHMENTS = 5
const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive']
const CONTAINER_CLASSES = 'px-6'
interface SupportFormV2Props {
setSentCategory: (value: string) => void
setSelectedProject: (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 = ({ setSentCategory, setSelectedProject }: SupportFormV2Props) => {
const { profile } = useProfile()
const supabaseClient = useSupabaseClient()
const { ref, slug, category: urlCategory, subject: urlSubject, message: urlMessage } = useParams()
const enableFreeSupport = useFlag('enableFreeSupport')
const uploadButtonRef = useRef(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [docsResults, setDocsResults] = useState<Page[]>([])
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])
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: false,
}
const form = useForm<z.infer<typeof FormSchema>>({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues,
})
const { organizationSlug, projectRef, category, severity, subject, library } = form.watch()
const {
handleDocsSearchDebounced,
searchState,
searchState: state,
} = useDocsSearch(supabaseClient)
const {
data: organizations,
isLoading: isLoadingOrganizations,
isSuccess: isSuccessOrganizations,
} = useOrganizationsQuery()
const {
data: subscription,
isLoading: isLoadingSubscription,
isSuccess: isSuccessSubscription,
} = useOrgSubscriptionQuery({
orgSlug: organizationSlug,
})
const {
data: allProjects,
isLoading: isLoadingProjects,
isSuccess: isSuccessProjects,
} = useProjectsQuery()
const { mutate: submitSupportTicket } = useSendSupportTicketMutation({
onSuccess: (res, variables) => {
toast.success('Support request sent. Thank you!')
setSentCategory(variables.category)
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 = subscription?.plan.id
const projects = [
...(allProjects ?? []).filter((project) => project.organization_slug === organizationSlug),
{ ref: 'no-project', name: 'No specific project' } as Partial<Project>,
]
const hasResults =
state.status === 'fullResults' ||
state.status === 'partialResults' ||
(state.status === 'loading' && state.staleResults.length > 0)
const onFilesUpload = async (event: ChangeEvent<HTMLInputElement>) => {
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<z.infer<typeof FormSchema>> = 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,
library:
values.category === 'Problem' && selectedLibrary !== undefined ? selectedLibrary.key : '',
message: formatMessage(values.message, attachments),
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 (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) {
const selectedOrganization = organizations?.find((org) => org.slug === slug)
if (selectedOrganization !== undefined) {
form.setValue('organizationSlug', selectedOrganization.slug)
}
} else if (ref === undefined && slug === undefined) {
form.setValue('organizationSlug', organizations[0].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])
return (
<Form_Shadcn_ {...form}>
<form
id="support-form"
className={cn('flex flex-col gap-y-8')}
onSubmit={form.handleSubmit(onSubmit)}
>
<h3 className={cn(CONTAINER_CLASSES, 'text-xl')}>How can we help?</h3>
<FormField_Shadcn_
name="organizationSlug"
control={form.control}
render={({ field }) => (
<FormItemLayout
className={cn(CONTAINER_CLASSES)}
layout="vertical"
label="Which organization is affected?"
>
<FormControl_Shadcn_>
<Select_Shadcn_
{...field}
disabled={isLoadingOrganizations}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ asChild placeholder="Select an organization">
<div className="flex items-center gap-x-2">
{(organizations ?? []).find((o) => o.slug === field.value)?.name}
{isLoadingSubscription && <Loader2 size={14} className="animate-spin" />}
{isSuccessSubscription && (
<Badge variant="outline" className="capitalize">
{subscriptionPlanId}
</Badge>
)}
</div>
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{organizations?.map((org) => (
<SelectItem_Shadcn_ value={org.slug}>{org.name}</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<div className={cn(CONTAINER_CLASSES, 'flex flex-col gap-y-2')}>
<FormField_Shadcn_
name="projectRef"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="vertical" label="Which project is affected?">
<FormControl_Shadcn_>
<Select_Shadcn_
{...field}
disabled={isLoadingProjects}
defaultValue={field.value}
onValueChange={(val) => {
if (val.length > 0) field.onChange(val)
}}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select a project" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{projects?.map((project) => (
<SelectItem_Shadcn_ value={project.ref as string}>
{project.name}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{organizationSlug &&
subscriptionPlanId !== 'enterprise' &&
category !== 'Login_issues' && (
<PlanExpectationInfoBox
orgSlug={organizationSlug}
projectRef={projectRef}
planId={subscriptionPlanId}
/>
)}
</div>
<div
className={cn(
CONTAINER_CLASSES,
'grid sm:grid-cols-2 sm:grid-rows-1 gap-4 grid-cols-1 grid-rows-2'
)}
>
<FormField_Shadcn_
name="category"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="vertical" label="What areas are you having problems with?">
<FormControl_Shadcn_>
<Select_Shadcn_
{...field}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_>
{CATEGORY_OPTIONS.find((o) => o.value === field.value)?.label}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{CATEGORY_OPTIONS.map((option) => (
<SelectItem_Shadcn_ 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>
)}
/>
<FormField_Shadcn_
name="severity"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="vertical" label="Severity">
<FormControl_Shadcn_>
<Select_Shadcn_
{...field}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Select a project">
{field.value}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{SEVERITY_OPTIONS.map((option) => (
<SelectItem_Shadcn_ 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>
)}
/>
{(severity === 'Urgent' || severity === 'High') && (
<p className="text-sm text-foreground-light mt-2 sm:col-span-2">
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.
</p>
)}
</div>
<Separator />
{!enableFreeSupport || (category === 'Performance' && subscriptionPlanId === 'free') ? (
<DisabledStateForFreeTier
category={CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? ''}
organizationSlug={organizationSlug}
/>
) : (
<>
<div className={cn(CONTAINER_CLASSES, 'flex flex-col gap-y-2')}>
<FormField_Shadcn_
name="subject"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Subject"
description={
field.value.length > 0 &&
INCLUDE_DISCUSSIONS.includes(category) && (
<p className="flex items-center gap-x-1">
<span>Check our </span>
<Link
key="gh-discussions"
href={`https://github.com/orgs/supabase/discussions?discussions_q=${field.value}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-x-1 text-foreground-light underline hover:text-foreground transition"
>
Github discussions
<ExternalLink size={14} strokeWidth={2} />
</Link>
<span> for a quick answer</span>
</p>
)
}
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Summary of the problem you have" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{docsResults.length > 0 && hasResults && (
<div className="pt-4 px-4 border rounded-md">
<h2 className="text-sm text-foreground-light px-2 mb-4">
Suggested resources ({Math.min(docsResults.length, 5)})
</h2>
<ScrollArea className={docsResults.length > 3 ? 'h-[300px]' : ''}>
{docsResults.slice(0, 5).map((page, i) => (
<DocsLinkGroup key={`${page.id}-group`} page={page} />
))}
</ScrollArea>
</div>
)}
</div>
{category === 'Problem' && (
<FormField_Shadcn_
name="library"
control={form.control}
render={({ field }) => (
<FormItemLayout
className={cn(CONTAINER_CLASSES)}
layout="vertical"
label="Which library are you having issues with"
>
<FormControl_Shadcn_>
<Select_Shadcn_
{...field}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ className="w-full">
<SelectValue_Shadcn_ placeholder="Please select a library" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{CLIENT_LIBRARIES.map((option) => (
<SelectItem_Shadcn_ value={option.language}>
{option.language}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
{library.length > 0 && <LibrarySuggestions library={library} />}
{category !== 'Login_issues' && (
<FormField_Shadcn_
name="affectedServices"
control={form.control}
render={({ field }) => (
<FormItemLayout
className={cn(CONTAINER_CLASSES)}
layout="vertical"
label="Which services are affected?"
>
<FormControl_Shadcn_>
<MultiSelectV2
options={SERVICE_OPTIONS}
value={field.value.length === 0 ? [] : field.value?.split(', ')}
placeholder="No particular service"
searchPlaceholder="Search for a service"
onChange={(services) =>
form.setValue('affectedServices', services.join(', '))
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
<FormField_Shadcn_
name="message"
control={form.control}
render={({ field }) => (
<FormItemLayout
className={cn(CONTAINER_CLASSES)}
layout="vertical"
label="Message"
labelOptional="5000 character limit"
description={
IPV4_MIGRATION_STRINGS.some((str) => field.value.includes(str)) && (
<IPV4SuggestionAlert />
)
}
>
<FormControl_Shadcn_>
<TextArea_Shadcn_
{...field}
rows={4}
maxLength={5000}
placeholder="Describe the issue you're facing, along with any relevant information. Please be as detailed and specific as possible."
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{['Problem', 'Database_unresponsive', 'Performance'].includes(category) && (
<FormField_Shadcn_
name="allowSupportAccess"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="flex"
className={cn(CONTAINER_CLASSES)}
label="Allow Supabase Support to access your project temporarily"
description="In some cases, we may require temporary access to your project to complete troubleshooting, or to answer questions related specifically to your project"
>
<FormControl_Shadcn_>
<Checkbox_Shadcn_
{...field}
value={String(field.value)}
checked={field.value}
onCheckedChange={(value) => field.onChange(value)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
<div className={cn(CONTAINER_CLASSES)}>
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground-light">Attachments</p>
<p className="text-sm text-foreground-lighter">
Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue
that you're facing
</p>
</div>
<input
multiple
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
onChange={onFilesUpload}
/>
<div className="flex items-center gap-x-2 mt-4">
{uploadedDataUrls.map((x: any, idx: number) => (
<div
key={idx}
style={{ backgroundImage: `url("${x}")` }}
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
>
<div
className={[
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer',
].join(' ')}
onClick={() => removeUploadedFile(idx)}
>
<X size={12} strokeWidth={2} />
</div>
</div>
))}
{uploadedFiles.length < MAX_ATTACHMENTS && (
<div
className={[
'border border-stronger opacity-50 transition hover:opacity-100',
'group flex h-14 w-14 cursor-pointer items-center justify-center rounded',
].join(' ')}
onClick={() => {
if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
}}
>
<Plus strokeWidth={2} size={20} />
</div>
)}
</div>
</div>
<div className={cn(CONTAINER_CLASSES)}>
<div className="flex items-center space-x-1 justify-end block text-sm mt-0 mb-2">
<p className="text-foreground-light">We will contact you at</p>
<p className="text-foreground font-medium">{respondToEmail}</p>
</div>
<div className="flex items-center space-x-1 justify-end block text-sm mt-0 mb-2">
<p className="text-foreground-light">
Please ensure you haven't blocked Hubspot in your emails
</p>
</div>
<div className="flex justify-end">
<Button
htmlType="submit"
size="small"
icon={<Mail />}
disabled={isSubmitting}
loading={isSubmitting}
>
Send support request
</Button>
</div>
</div>
</>
)}
</form>
</Form_Shadcn_>
)
}
+6 -4
View File
@@ -1,6 +1,8 @@
import { ExternalLink, Maximize2, Minimize2 } from 'lucide-react'
import Link from 'next/link'
import { ReactNode, useState } from 'react'
import { Button, IconExternalLink, IconMaximize2, IconMinimize2 } from 'ui'
import { Button } from 'ui'
interface InformationBoxProps {
icon?: ReactNode
@@ -48,9 +50,9 @@ const InformationBox = ({
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<IconMinimize2 size={14} strokeWidth={1.5} />
<Minimize2 size={14} strokeWidth={1.5} />
) : (
<IconMaximize2 size={14} strokeWidth={1.5} />
<Maximize2 size={14} strokeWidth={1.5} />
)}
</div>
) : null}
@@ -66,7 +68,7 @@ const InformationBox = ({
{url && (
<div>
<Button asChild type="default" icon={<IconExternalLink />}>
<Button asChild type="default" icon={<ExternalLink />}>
<Link href={url} target="_blank" rel="noreferrer">
{urlLabel}
</Link>
+15 -24
View File
@@ -1,15 +1,15 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { Loader2, Wrench } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import SVG from 'react-inlinesvg'
import Success from 'components/interfaces/Support/Success'
import SupportForm from 'components/interfaces/Support/SupportForm'
import { SupportFormV2 } from 'components/interfaces/Support/SupportFormV2'
import { usePlatformStatusQuery } from 'data/platform/platform-status-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { withAuth } from 'hooks/misc/withAuth'
import { BASE_PATH } from 'lib/constants'
import { Button, IconLoader, IconTool } from 'ui'
import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui'
const SupportPage = () => {
const [sentCategory, setSentCategory] = useState<string>()
@@ -28,8 +28,8 @@ const SupportPage = () => {
<SVG src={`${BASE_PATH}/img/supabase-logo.svg`} className="h-4 w-4" />
<h1 className="m-0 text-lg">Supabase support</h1>
</div>
<div className="flex items-center space-x-3">
<Button asChild type="default" icon={<IconTool />}>
<div className="flex items-center gap-x-3">
<Button asChild type="default" icon={<Wrench />}>
<Link
href="https://supabase.com/docs/guides/platform/troubleshooting"
target="_blank"
@@ -38,14 +38,14 @@ const SupportPage = () => {
Troubleshooting
</Link>
</Button>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
asChild
type="default"
icon={
isLoading ? (
<IconLoader className="animate-spin" />
<Loader2 className="animate-spin" />
) : isHealthy ? (
<div className="h-2 w-2 bg-brand rounded-full" />
) : (
@@ -61,23 +61,14 @@ const SupportPage = () => {
: 'Active incident ongoing'}
</Link>
</Button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">Check Supabase status page</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center">
Check Supabase status page
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
</div>
<div
className={[
'min-w-full w-full space-y-12 rounded border bg-panel-body-light shadow-md',
@@ -92,7 +83,7 @@ const SupportPage = () => {
projects={projectsData}
/>
) : (
<SupportForm
<SupportFormV2
setSentCategory={setSentCategory}
setSelectedProject={setSelectedProject}
/>