refactor,tests(support form) (#39410)

* refactor: refactor support form

Refactor support form to make it easier to maintain:
- Split up large components into smaller components and hooks
- Lift state up so we don't have to do complex child/parent
state-syncing via useEffect
- Use nuqs parsing for consistent serialization/deserialization of
support form prefilled fields

* test: support form

Add comprehensive tests for support form

* fix(support form): project and org empty state

* Nit clean up

* More clean up

* cleannnn

* fix(support form): allow case-insensitive category in url

* clean(support form tests): remove unused param

* fix(support form): incorrect logic for sending affected services in payload

* clean(support form): use NO_ORG_MARKER and NO_PROJECT_MARKER instead of strings

* fix(support form): don't show upgrade cta if already on enterprise

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Charis
2025-10-13 23:04:33 -04:00
committed by GitHub
parent 68fbcad6cf
commit f5ff10e195
34 changed files with 3549 additions and 1360 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { isbot } from 'isbot'
import { type NextRequest, NextResponse } from 'next/server'
import { NextResponse, type NextRequest } from 'next/server'
import { clientSdkIds } from '~/content/navigation.references'
import { BASE_PATH } from '~/lib/constants'
@@ -2,13 +2,15 @@ import { AnimatePresence, motion } from 'framer-motion'
import { MessageSquare } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useState } from 'react'
// End of third-party imports
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { Button } from 'ui'
import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
interface AIAssistantOptionProps {
projectRef: string
organizationSlug: string
projectRef?: string | null
organizationSlug?: string | null
isCondensed?: boolean
}
@@ -18,7 +20,7 @@ export const AIAssistantOption = ({
isCondensed = false,
}: AIAssistantOptionProps) => {
const { mutate: sendEvent } = useSendEventMutation()
const [isVisible, setIsVisible] = useState(isCondensed ? true : false)
const [isVisible, setIsVisible] = useState(isCondensed)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 800)
@@ -29,18 +31,19 @@ export const AIAssistantOption = ({
sendEvent({
action: 'ai_assistant_in_support_form_clicked',
groups: {
project: projectRef === 'no-project' ? undefined : projectRef,
organization: organizationSlug,
project: projectRef === null || projectRef === NO_PROJECT_MARKER ? undefined : projectRef,
organization:
organizationSlug === null || organizationSlug === NO_ORG_MARKER
? undefined
: organizationSlug,
},
})
}, [projectRef, organizationSlug, sendEvent])
if (!organizationSlug || organizationSlug === 'no-org') {
return null
}
// If no specific project selected, use the wildcard route
const aiLink = `/project/${projectRef !== 'no-project' ? projectRef : '_'}?aiAssistantPanelOpen=true&slug=${organizationSlug}`
const aiLink = `/project/${projectRef !== NO_PROJECT_MARKER ? projectRef : '_'}?aiAssistantPanelOpen=true&slug=${organizationSlug}`
if (!organizationSlug || organizationSlug === NO_ORG_MARKER) return null
return (
<AnimatePresence initial={false}>
@@ -0,0 +1,43 @@
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { FormControl_Shadcn_, FormField_Shadcn_ } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2'
import { type ExtendedSupportCategories, SERVICE_OPTIONS } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
interface AffectedServicesSelectorProps {
form: UseFormReturn<SupportFormValues>
category: ExtendedSupportCategories
}
export const CATEGORIES_WITHOUT_AFFECTED_SERVICES: ExtendedSupportCategories[] = [
SupportCategories.LOGIN_ISSUES,
'Plan_upgrade',
]
export function AffectedServicesSelector({ form, category }: AffectedServicesSelectorProps) {
if (CATEGORIES_WITHOUT_AFFECTED_SERVICES.includes(category)) return null
return (
<FormField_Shadcn_
name="affectedServices"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage 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>
)}
/>
)
}
@@ -0,0 +1,187 @@
import { compact } from 'lodash'
import { Plus, X } from 'lucide-react'
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
// End of third-party imports
import { uuidv4 } from 'lib/helpers'
import { cn } from 'ui'
import { createSupportStorageClient } from './support-storage-client'
const MAX_ATTACHMENTS = 5
const uploadAttachments = async (ref: string, files: File[]) => {
const supportSupabaseClient = createSupportStorageClient()
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 function useAttachmentUpload() {
const uploadButtonRef = useRef<HTMLInputElement>(null)
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
const [uploadedDataUrls, setUploadedDataUrls] = useState<string[]>([])
const isFull = uploadedFiles.length >= MAX_ATTACHMENTS
const addFile = useCallback(() => {
uploadButtonRef.current?.click()
}, [])
const handleFileUpload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
event.persist()
const items = event.target.files || (event as any).dataTransfer.items
const itemsCopied = Array.prototype.map.call(items, (item: any) => 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 = ''
},
[uploadedFiles]
)
const removeFileUpload = useCallback(
(idx: number) => {
const updatedFiles = uploadedFiles.slice()
updatedFiles.splice(idx, 1)
setUploadedFiles(updatedFiles)
const updatedDataUrls = uploadedDataUrls.slice()
uploadedDataUrls.splice(idx, 1)
setUploadedDataUrls(updatedDataUrls)
},
[uploadedFiles, uploadedDataUrls]
)
useEffect(() => {
if (!uploadedFiles) return
const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file))
setUploadedDataUrls(objectUrls)
return () => {
objectUrls.forEach((url: any) => void URL.revokeObjectURL(url))
}
}, [uploadedFiles])
const createAttachments = useCallback(
async (projectRef: string) => {
const attachments =
uploadedFiles.length > 0 ? await uploadAttachments(projectRef, uploadedFiles) : []
return attachments
},
[uploadedFiles]
)
return useMemo(
() => ({
uploadButtonRef,
isFull,
addFile,
handleFileUpload,
removeFileUpload,
createAttachments,
uploadedDataUrls,
}),
[isFull, addFile, handleFileUpload, removeFileUpload, createAttachments, uploadedDataUrls]
)
}
interface AttachmentUploadDisplayProps {
uploadButtonRef: React.RefObject<HTMLInputElement>
isFull: boolean
addFile: () => void
handleFileUpload: (event: ChangeEvent<HTMLInputElement>) => Promise<void>
removeFileUpload: (idx: number) => void
uploadedDataUrls: Array<string>
}
export function AttachmentUploadDisplay({
uploadButtonRef,
isFull,
addFile,
handleFileUpload,
removeFileUpload,
uploadedDataUrls,
}: AttachmentUploadDisplayProps) {
return (
<div className="flex flex-col gap-y-4">
<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={handleFileUpload}
/>
<div className="flex items-center gap-x-2">
{uploadedDataUrls.map((url, idx) => (
<div
key={url}
style={{ backgroundImage: `url("${url}")` }}
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
>
<button
type="button"
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={12} strokeWidth={2} />
</button>
</div>
))}
{!isFull && (
<button
type="button"
className={cn(
'border border-stronger opacity-50 transition hover:opacity-100',
'group flex h-14 w-14 cursor-pointer items-center justify-center rounded'
)}
onClick={addFile}
>
<Plus strokeWidth={2} size={20} />
</button>
)}
</div>
</div>
)
}
@@ -0,0 +1,204 @@
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { InlineLink } from 'components/ui/InlineLink'
import {
cn,
FormControl_Shadcn_,
FormField_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
CATEGORY_OPTIONS,
type ExtendedSupportCategories,
SEVERITY_OPTIONS,
} from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
interface CategoryAndSeverityInfoProps {
form: UseFormReturn<SupportFormValues>
category: ExtendedSupportCategories
severity: string
projectRef: string
}
export function CategoryAndSeverityInfo({
form,
category,
severity,
projectRef,
}: CategoryAndSeverityInfoProps) {
return (
<div className={cn('grid sm:grid-cols-2 sm:grid-rows-1 gap-4 grid-cols-1 grid-rows-2')}>
<CategorySelector form={form} />
<SeveritySelector form={form} />
<IssueSuggestion category={category} projectRef={projectRef} />
{(severity === 'Urgent' || severity === 'High') && (
<Admonition
type="default"
className="mb-0 sm:col-span-2"
title="We do our best to respond to everyone as quickly as possible"
description="Prioritization will be based on production status. We ask that you reserve High and Urgent severity for production-impacting issues only."
/>
)}
</div>
)
}
interface CategorySelectorProps {
form: UseFormReturn<SupportFormValues>
}
function CategorySelector({ form }: CategorySelectorProps) {
return (
<FormField_Shadcn_
name="category"
control={form.control}
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.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>
)
}}
/>
)
}
interface SeveritySelectorProps {
form: UseFormReturn<SupportFormValues>
}
function SeveritySelector({ form }: SeveritySelectorProps) {
return (
<FormField_Shadcn_
name="severity"
control={form.control}
render={({ field }) => {
const { ref, ...fieldWithoutRef } = field
return (
<FormItemLayout hideMessage layout="vertical" label="Severity">
<FormControl_Shadcn_>
<Select_Shadcn_
{...fieldWithoutRef}
defaultValue={field.value}
onValueChange={field.onChange}
>
<SelectTrigger_Shadcn_ aria-label="Select a severity" className="w-full">
<SelectValue_Shadcn_ placeholder="Select a severity">
{field.value}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{SEVERITY_OPTIONS.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>
)
}}
/>
)
}
const IssueSuggestion = ({ category, projectRef }: { category: string; projectRef?: string }) => {
const baseUrl = `/project/${projectRef === 'no-project' ? '_' : projectRef}`
const className = 'col-span-2 mb-0'
if (category === SupportCategories.PROBLEM) {
return (
<Admonition
type="default"
className={className}
title="Have you checked your project's logs?"
>
Logs can help you identify errors that you might be running into when using your project's
API or client libraries. View logs for each product{' '}
<InlineLink href={`${baseUrl}/logs/edge-logs`}>here</InlineLink>.
</Admonition>
)
}
if (category === SupportCategories.DATABASE_UNRESPONSIVE) {
return (
<Admonition
type="default"
className={className}
title="Have you checked your project's infrastructure activity?"
>
High memory or low disk IO bandwidth may be slowing down your database. Verify by checking
the infrastructure activity of your project{' '}
<InlineLink href={`${baseUrl}/settings/infrastructure#infrastructure-activity`}>
here
</InlineLink>
.
</Admonition>
)
}
if (category === SupportCategories.PERFORMANCE_ISSUES) {
return (
<Admonition
type="default"
className={className}
title="Have you checked the Query Performance Advisor?"
>
Identify slow running queries and get actionable insights on how to optimize them with the
Query Performance Advisor{' '}
<InlineLink href={`${baseUrl}/settings/infrastructure#infrastructure-activity`}>
here
</InlineLink>
.
</Admonition>
)
}
return null
}
@@ -0,0 +1,128 @@
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { CLIENT_LIBRARIES } from 'common/constants'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import {
Button,
cn,
FormControl_Shadcn_,
FormField_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
interface ClientLibraryInfoProps {
form: UseFormReturn<SupportFormValues>
category: ExtendedSupportCategories
library: string | undefined
}
export function ClientLibraryInfo({ form, category, library }: ClientLibraryInfoProps) {
const showClientLibraries = useIsFeatureEnabled('support:show_client_libraries')
if (!showClientLibraries) return null
if (category !== 'Problem') return null
return (
<div className="flex flex-col gap-y-1">
<FormField_Shadcn_
name="library"
control={form.control}
render={({ field }) => (
<FormItemLayout 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" aria-label="Select a library">
<SelectValue_Shadcn_ placeholder="Select a library" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{CLIENT_LIBRARIES.map((option) => (
<SelectItem_Shadcn_ key={option.language} value={option.language}>
{option.language}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{library && library.length > 0 && <LibrarySuggestions library={library} />}
</div>
)
}
interface LibrarySuggestionsProps {
library: string
}
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="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={cn(
'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'
)}
>
<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,88 @@
import { Book, Github, Loader2 } from 'lucide-react'
import { useDocsSearch, type DocsSearchResult } from 'common'
import { useChangedSync } from 'hooks/misc/useChanged'
import { DOCS_URL } from 'lib/constants'
import { cn } from 'ui'
function useDocsSuggestions(subject: string) {
const { handleDocsSearchDebounced, resetSearch, searchState } = useDocsSearch()
const trimmedSubject = subject.trim()
const subjectChanged = useChangedSync(trimmedSubject)
if (subjectChanged && trimmedSubject) {
handleDocsSearchDebounced(trimmedSubject)
} else if (subjectChanged && !trimmedSubject) {
resetSearch()
}
return searchState
}
interface DocsSuggestionsProps {
searchString: string
}
export function DocsSuggestions({ searchString }: DocsSuggestionsProps) {
const searchState = useDocsSuggestions(searchString)
const results =
'results' in searchState
? searchState.results
: 'staleResults' in searchState
? searchState.staleResults
: []
const resultsStale = searchState.status === 'loading'
return (
<>
{searchState.status === 'loading' && <DocsSuggestions_Loading />}
{results.length > 0 && <DocsSuggestions_Results results={results} isStale={resultsStale} />}
</>
)
}
function DocsSuggestions_Loading() {
return (
<div className="flex items-center gap-2 text-sm text-foreground-light">
<Loader2 className="animate-spin" size={14} />
<span>Searching for relevant resources...</span>
</div>
)
}
interface DocsSuggestions_ResultsProps {
results: DocsSearchResult[]
isStale: boolean
}
function DocsSuggestions_Results({ results, isStale }: DocsSuggestions_ResultsProps) {
return (
<ul
className={cn(
'flex flex-col gap-y-0.5 transition-opacity duration-200',
isStale ? 'opacity-50' : 'opacity-100'
)}
>
{results.slice(0, 5).map((page) => {
return (
<li key={page.id} className="flex items-center gap-x-1">
{page.type === 'github-discussions' ? (
<Github size={16} className="text-foreground-muted" />
) : (
<Book size={16} className="text-foreground-muted" />
)}
<a
href={page.type === 'github-discussions' ? page.path : `${DOCS_URL}${page.path}`}
target="_blank"
rel="noreferrer"
className="text-sm text-foreground-light hover:text-foreground transition"
>
{page.title}
</a>
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,62 @@
import { parseAsBoolean, useQueryState } from 'nuqs'
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
type PropsWithChildren,
} from 'react'
interface HighlightProjectRefContextValue<T extends HTMLElement = HTMLDivElement> {
ref: React.RefObject<T>
shouldHighlightRef: boolean
setShouldHighlightRef: (value: boolean) => void
scrollToRef: () => void
}
const HighlightProjectRefContext = createContext<HighlightProjectRefContextValue | undefined>(
undefined
)
export function HighlightProjectRefProvider({ children }: PropsWithChildren) {
const projectRefContainerRef = useRef<HTMLDivElement>(null)
const [shouldHighlightRef, setShouldHighlightRef] = useQueryState(
'highlightRef',
parseAsBoolean.withDefault(false).withOptions({ clearOnDefault: true })
)
const scrollToRef = useCallback(() => {
projectRefContainerRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
})
}, [])
const ctx = useMemo(
() => ({
ref: projectRefContainerRef,
shouldHighlightRef,
setShouldHighlightRef,
scrollToRef,
}),
[shouldHighlightRef, setShouldHighlightRef, scrollToRef]
)
return (
<HighlightProjectRefContext.Provider value={ctx}>
{children}
</HighlightProjectRefContext.Provider>
)
}
export function useHighlightProjectRefContext() {
const context = useContext(HighlightProjectRefContext)
if (!context) {
throw new Error(
'useHighlightProjectRefContext must be used within a HighlightProjectRefProvider'
)
}
return context
}
@@ -1,65 +0,0 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { InlineLink } from 'components/ui/InlineLink'
import { Admonition } from 'ui-patterns'
const className = 'col-span-2 mb-0'
export const IssueSuggestion = ({
category,
projectRef,
}: {
category: string
projectRef?: string
}) => {
const baseUrl = `/project/${projectRef === 'no-project' ? '_' : projectRef}`
if (category === SupportCategories.PROBLEM) {
return (
<Admonition
type="default"
className={className}
title="Have you checked your project's logs?"
>
Logs can help you identify errors that you might be running into when using your project's
API or client libraries. View logs for each product{' '}
<InlineLink href={`${baseUrl}/logs/edge-logs`}>here</InlineLink>.
</Admonition>
)
}
if (category === SupportCategories.DATABASE_UNRESPONSIVE) {
return (
<Admonition
type="default"
className={className}
title="Have you checked your project's infrastructure activity?"
>
High memory or low disk IO bandwidth may be slowing down your database. Verify by checking
the infrastructure activity of your project{' '}
<InlineLink href={`${baseUrl}/settings/infrastructure#infrastructure-activity`}>
here
</InlineLink>
.
</Admonition>
)
}
if (category === SupportCategories.PERFORMANCE_ISSUES) {
return (
<Admonition
type="default"
className={className}
title="Have you checked the Query Performance Advisor?"
>
Identify slow running queries and get actionable insights on how to optimize them with the
Query Performance Advisor{' '}
<InlineLink href={`${baseUrl}/settings/infrastructure#infrastructure-activity`}>
here
</InlineLink>
.
</Admonition>
)
}
return null
}
@@ -1,68 +0,0 @@
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,53 @@
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { FormControl_Shadcn_, FormField_Shadcn_, TextArea_Shadcn_ } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { IPV4SuggestionAlert } from './IPV4SuggestionAlert'
import { IPV4_MIGRATION_STRINGS } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
interface MessageFieldProps {
form: UseFormReturn<SupportFormValues>
originalError: string | null | undefined
}
export function MessageField({ form, originalError }: MessageFieldProps) {
return (
<FormField_Shadcn_
name="message"
control={form.control}
render={({ field }) => (
<FormItemLayout
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_>
{originalError && (
<Admonition
showIcon={false}
type="default"
className="mt-2"
title="The error that you ran into will be included in your message for reference"
description={`Error: ${originalError}`}
/>
)}
</FormItemLayout>
)}
/>
)
}
@@ -0,0 +1,87 @@
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import {
Badge,
FormControl_Shadcn_,
FormField_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { SupportFormValues } from './SupportForm.schema'
import { getOrgSubscriptionPlan, NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
interface OrganizationSelectorProps {
form: UseFormReturn<SupportFormValues>
orgSlug: string | null
}
export function OrganizationSelector({ form, orgSlug }: OrganizationSelectorProps) {
const { data: organizations, isSuccess: isSuccessOrganizations } = useOrganizationsQuery()
const subscriptionPlanId = getOrgSubscriptionPlan(organizations, orgSlug)
return (
<FormField_Shadcn_
name="organizationSlug"
control={form.control}
render={({ field }) => {
const { ref: _ref, ...fieldWithoutRef } = field
return (
<FormItemLayout hideMessage layout="vertical" label="Which organization is affected?">
<FormControl_Shadcn_>
<Select_Shadcn_
{...fieldWithoutRef}
disabled={!isSuccessOrganizations}
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_ asChild placeholder="Select an organization">
<div className="flex items-center gap-x-2">
{orgSlug === NO_ORG_MARKER ? (
<span>No specific organization</span>
) : (
(organizations ?? []).find((o) => o.slug === field.value)?.name
)}
{subscriptionPlanId && (
<Badge variant="outline" className="capitalize">
{subscriptionPlanId}
</Badge>
)}
</div>
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{organizations?.map((org) => (
<SelectItem_Shadcn_ key={org.slug} value={org.slug}>
{org.name}
</SelectItem_Shadcn_>
))}
{isSuccessOrganizations && (organizations ?? []).length === 0 && (
<SelectItem_Shadcn_ value={NO_ORG_MARKER}>
No specific organization
</SelectItem_Shadcn_>
)}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)
}}
/>
)
}
@@ -1,80 +0,0 @@
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&source=planSupportExpectationInfoBox`}
>
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>
),
})}
/>
)
}
@@ -0,0 +1,252 @@
import { AnimatePresence, motion } from 'framer-motion'
import { AlertCircle, Check, ChevronsUpDown, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import type { UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
// End of third-party imports
import CopyButton from 'components/ui/CopyButton'
import InformationBox from 'components/ui/InformationBox'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import {
Button,
CommandGroup_Shadcn_,
CommandItem_Shadcn_,
cn,
FormControl_Shadcn_,
FormField_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { useHighlightProjectRefContext } from './HighlightContext'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
interface ProjectAndPlanProps {
form: UseFormReturn<SupportFormValues>
orgSlug: string | null
projectRef: string | null
category: ExtendedSupportCategories
subscriptionPlanId: string | undefined
}
export function ProjectAndPlanInfo({
form,
orgSlug,
projectRef,
category,
subscriptionPlanId,
}: ProjectAndPlanProps) {
const { ref } = useHighlightProjectRefContext()
const hasProjectSelected = projectRef && projectRef !== NO_PROJECT_MARKER
return (
<div ref={ref} className={'flex flex-col gap-y-2'}>
<ProjectSelector form={form} orgSlug={orgSlug} projectRef={projectRef} />
<ProjectRefHighlighted projectRef={projectRef} />
{!hasProjectSelected && (
<Admonition type="default" title="Please note that no project has been selected" />
)}
{orgSlug && subscriptionPlanId !== 'enterprise' && category !== 'Login_issues' && (
<PlanExpectationInfoBox orgSlug={orgSlug} planId={subscriptionPlanId} />
)}
</div>
)
}
interface ProjectSelectorProps {
form: UseFormReturn<SupportFormValues>
orgSlug: string | null
projectRef: string | null
}
function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
return (
<FormField_Shadcn_
name="projectRef"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical" label="Which project is affected?">
<FormControl_Shadcn_>
<OrganizationProjectSelector
key={orgSlug}
sameWidthAsTrigger
checkPosition="left"
slug={!orgSlug || orgSlug === NO_ORG_MARKER ? undefined : orgSlug}
selectedRef={field.value}
onInitialLoad={(projects) => {
if (!projectRef || projectRef === NO_PROJECT_MARKER)
field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER)
}}
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" />}
>
{!!orgSlug && 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>
)}
/>
)
}
interface ProjectRefHighlightedProps {
projectRef: string | null
}
function ProjectRefHighlighted({ projectRef }: ProjectRefHighlightedProps) {
const isVisible = !!projectRef && projectRef !== NO_PROJECT_MARKER
const { shouldHighlightRef, setShouldHighlightRef: setHighlight } =
useHighlightProjectRefContext()
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-x-1"
>
<p
className={cn(
'text-sm prose transition',
shouldHighlightRef ? 'text-foreground' : 'text-foreground-lighter'
)}
>
Project ID:{' '}
<code
className={cn(
'transition',
shouldHighlightRef
? 'text-brand font-medium border-brand-500 animate-pulse'
: 'text-foreground-light'
)}
>
{projectRef}
</code>
</p>
<CopyButton
iconOnly
type="text"
text={projectRef}
onClick={() => {
toast.success('Copied to clipboard')
setHighlight(false)
}}
/>
</motion.div>
)}
</AnimatePresence>
)
}
interface PlanExpectationInfoBoxProps {
orgSlug: string
planId?: string
}
const PlanExpectationInfoBox = ({ orgSlug, planId }: PlanExpectationInfoBoxProps) => {
const { billingAll } = useIsFeatureEnabled(['billing:all'])
return (
<InformationBox
icon={<AlertCircle size={18} strokeWidth={2} />}
defaultVisibility={true}
hideCollapse={true}
title="Expected response times are based on your organization's plan"
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>
)}
{billingAll && planId !== 'enterprise' && (
<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&source=planSupportExpectationInfoBox`}
>
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>
}
/>
)
}
@@ -0,0 +1,71 @@
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { DocsSuggestions } from './DocsSuggestions'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
const INCLUDE_DISCUSSIONS: ExtendedSupportCategories[] = [
SupportCategories.DATABASE_UNRESPONSIVE,
SupportCategories.PROBLEM,
]
interface SubjectAndSuggestionsInfoProps {
form: UseFormReturn<SupportFormValues>
category: ExtendedSupportCategories
subject: string
}
export function SubjectAndSuggestionsInfo({
form,
category,
subject,
}: SubjectAndSuggestionsInfoProps) {
return (
<div className={'flex flex-col gap-y-2'}>
<FormField_Shadcn_
name="subject"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="vertical" label="Subject">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Summary of the problem you have" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<DocsSuggestions searchString={subject} />
{subject && INCLUDE_DISCUSSIONS.includes(category) && (
<GitHubDiscussionSuggestion subject={subject} />
)}
</div>
)
}
interface GitHubDiscussionSuggestionProps {
subject: string
}
function GitHubDiscussionSuggestion({ subject }: GitHubDiscussionSuggestionProps) {
return (
<p className="flex items-center gap-x-1 text-foreground-lighter text-sm">
<span>Check our </span>
<Link
key="gh-discussions"
href={`https://github.com/orgs/supabase/discussions?discussions_q=${subject}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-x-1 underline hover:text-foreground transition"
>
GitHub discussions
<ExternalLink size={14} strokeWidth={2} />
</Link>
<span> for a quick answer</span>
</p>
)
}
@@ -0,0 +1,38 @@
import { Mail } from 'lucide-react'
import type { MouseEventHandler } from 'react'
// End of third-party imports
import { Button } from 'ui'
interface SubmitButtonProps {
isSubmitting: boolean
userEmail: string
onClick?: MouseEventHandler<HTMLButtonElement>
}
export function SubmitButton({ isSubmitting, userEmail, onClick }: SubmitButtonProps) {
return (
<div className={'flex flex-col items-end gap-3'}>
<Button
htmlType="submit"
size="large"
block
icon={<Mail />}
disabled={isSubmitting}
loading={isSubmitting}
onClick={onClick}
>
Send support request
</Button>
<div className="flex flex-col items-end gap-1">
<div className="space-x-1 text-xs">
<span className="text-foreground-light">We will contact you at</span>
<span className="text-foreground font-medium">{userEmail}</span>
</div>
<span className="text-foreground-light text-xs">
Please ensure emails from supabase.com are allowed
</span>
</div>
</div>
)
}
@@ -1,7 +1,12 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { isFeatureEnabled } from 'common'
const billingEnabled = isFeatureEnabled('billing:all')
export type ExtendedSupportCategories = SupportCategories | 'Plan_upgrade'
export const CATEGORY_OPTIONS: {
value: SupportCategories | 'Plan_upgrade'
value: ExtendedSupportCategories
label: string
description: string
query?: string
@@ -30,43 +35,48 @@ export const CATEGORY_OPTIONS: {
description: 'Reporting of performance issues is only available on the Pro Plan',
query: 'Performance',
},
{
value: SupportCategories.SALES_ENQUIRY,
label: 'Sales enquiry',
description: 'Questions about pricing, paid plans and Enterprise plans',
query: undefined,
},
{
value: SupportCategories.BILLING,
label: 'Billing',
description: 'Issues with credit card charges | invoices | overcharging',
query: undefined,
},
{
value: SupportCategories.ABUSE,
label: 'Abuse report',
description: 'Report abuse of a Supabase project or Supabase brand',
query: undefined,
},
{
value: SupportCategories.REFUND,
label: 'Refund enquiry',
description: 'Formal enquiry form for requesting refunds',
query: undefined,
},
{
value: SupportCategories.LOGIN_ISSUES,
label: 'Issues with logging in',
description: 'Issues with logging in and MFA',
query: undefined,
},
// [Joshen] Ideally shift this to shared-types, although not critical as API isn't validating the category
{
value: 'Plan_upgrade',
label: 'Plan upgrade',
description: 'Enquire a plan upgrade for your organization',
query: undefined,
},
...(billingEnabled
? [
{
value: SupportCategories.SALES_ENQUIRY,
label: 'Sales enquiry',
description: 'Questions about pricing, paid plans and Enterprise plans',
query: undefined,
},
{
value: SupportCategories.BILLING,
label: 'Billing',
description: 'Issues with credit card charges | invoices | overcharging',
query: undefined,
},
{
value: SupportCategories.REFUND,
label: 'Refund enquiry',
description: 'Formal enquiry form for requesting refunds',
query: undefined,
},
]
: [
// [Joshen] Ideally shift this to shared-types, although not critical as API isn't validating the category
{
value: 'Plan_upgrade' as const,
label: 'Plan upgrade',
description: 'Enquire a plan upgrade for your organization',
query: undefined,
},
]),
]
export const SEVERITY_OPTIONS = [
@@ -0,0 +1,100 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import type { UseFormReturn } from 'react-hook-form'
// End of third-party imports
import { SupportCategories } from '@supabase/shared-types/out/constants'
import {
Badge,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
FormField_Shadcn_,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
export const SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [
SupportCategories.DATABASE_UNRESPONSIVE,
SupportCategories.PERFORMANCE_ISSUES,
SupportCategories.PROBLEM,
]
interface SupportAccessToggleProps {
form: UseFormReturn<SupportFormValues>
}
export function SupportAccessToggle({ form }: SupportAccessToggleProps) {
return (
<FormField_Shadcn_
name="allowSupportAccess"
control={form.control}
render={({ field }) => {
return (
<FormItemLayout
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
strokeWidth={2}
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>
)
}}
/>
)
}
@@ -0,0 +1,66 @@
import { z } from 'zod'
import { isFeatureEnabled } from 'common'
import { PLAN_REQUEST_EMPTY_PLACEHOLDER } from 'components/ui/UpgradePlanButton'
import { CATEGORY_OPTIONS, type ExtendedSupportCategories } from './Support.constants'
const createFormSchema = (showClientLibraries: boolean) => {
const baseSchema = z.object({
organizationSlug: z.string().min(1, 'Please select an organization'),
projectRef: z.string().min(1, 'Please select a project'),
category: z.enum(
CATEGORY_OPTIONS.map((opt) => opt.value) as [
ExtendedSupportCategories,
...ExtendedSupportCategories[],
]
),
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(),
dashboardSentryIssueId: z.string().optional(),
})
if (showClientLibraries) {
return baseSchema
.refine(
(data) => {
return !(data.category === 'Problem' && data.library === '')
},
{
message: "Please select the library that you're facing issues with",
path: ['library'],
}
)
.refine(
(data) => {
return !data.message.includes(PLAN_REQUEST_EMPTY_PLACEHOLDER)
},
{
message: `Please let us know which plan you'd like to upgrade to for your organization`,
path: ['message'],
}
)
}
// When showClientLibraries is false, make library optional and remove the refine validation
return baseSchema
.extend({
library: z.string().optional(),
})
.refine(
(data) => {
return !data.message.includes(PLAN_REQUEST_EMPTY_PLACEHOLDER)
},
{
message: `Please let us know which plan you'd like to upgrade to for your organization`,
path: ['message'],
}
)
}
const showClientLibraries = isFeatureEnabled('support:show_client_libraries')
export const SupportFormSchema = createFormSchema(showClientLibraries)
export type SupportFormValues = z.infer<typeof SupportFormSchema>
@@ -0,0 +1,96 @@
import { neverGuard } from 'lib/helpers'
import type { ExtendedSupportCategories } from './Support.constants'
export type SupportFormState =
| {
type: 'initializing'
}
| {
type: 'editing'
}
| {
type: 'submitting'
}
| {
type: 'success'
sentProjectRef: string | undefined
sentOrgSlug: string | undefined
sentCategory: ExtendedSupportCategories
}
| {
type: 'error'
message: string
}
export type SupportFormActions =
| { type: 'INITIALIZE'; debugSource?: string }
| { type: 'SUBMIT'; debugSource?: string }
| {
type: 'SUCCESS'
sentProjectRef: string | undefined
sentOrgSlug: string | undefined
sentCategory: ExtendedSupportCategories
debugSource?: string
}
| { type: 'ERROR'; message: string; debugSource?: string }
| { type: 'RETURN_TO_EDITING'; debugSource?: string }
export function createInitialSupportFormState(): SupportFormState {
return {
type: 'initializing',
}
}
export function supportFormReducer(
state: SupportFormState,
action: SupportFormActions
): SupportFormState {
switch (state.type) {
case 'initializing':
if (action.type === 'INITIALIZE') {
return { type: 'editing' }
}
console.warn(
`[SupportForm > supportFormReducer] ${action.type} action not allowed in 'initializing' state`
)
return state
case 'editing':
if (action.type === 'SUBMIT') {
return { type: 'submitting' }
}
console.warn(
`[SupportForm > supportFromReducer] ${action.type} action not allowed in 'filling_out' state`
)
return state
case 'submitting':
if (action.type === 'SUCCESS') {
return {
type: 'success',
sentProjectRef: action.sentProjectRef,
sentOrgSlug: action.sentOrgSlug,
sentCategory: action.sentCategory,
}
}
if (action.type === 'ERROR') {
return {
type: 'error',
message: action.message,
}
}
console.warn(
`[SupportForm > supportFormReducer] ${action.type} action not allowed in 'submitting' state`
)
return state
case 'success':
console.warn(`[SupportForm > supportFormReducer] ${action.type} allowed in 'success' state`)
return state
case 'error':
if (action.type === 'RETURN_TO_EDITING') {
return { type: 'editing' }
}
console.warn(`[SupportForm > supportFormReducer] ${action.type} allowed in 'success' state`)
return state
default:
return neverGuard(state)
}
}
@@ -1,64 +1,34 @@
import { createClient } from '@supabase/supabase-js'
import { compact } from 'lodash'
import { Book, Github, Hash, MessageSquare } from 'lucide-react'
import {
createLoader,
createParser,
createSerializer,
type inferParserType,
parseAsString,
parseAsStringLiteral,
type UseQueryStatesKeysMap,
} from 'nuqs'
// End of third-party imports
import {
DocsSearchResultType as PageType,
type DocsSearchResult as Page,
type DocsSearchResultSection as PageSection,
DocsSearchResultType as PageType,
} from 'common'
import { getProjectDetail } from 'data/projects/project-detail-query'
import { DOCS_URL } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import type { Organization } from 'types'
import { CATEGORY_OPTIONS } from './Support.constants'
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
export const NO_PROJECT_MARKER = 'no-project'
export const NO_ORG_MARKER = 'no-org'
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[], error?: string) => {
const errorString = error !== undefined ? `\nError: ${error}` : ''
export const formatMessage = (
message: string,
attachments: string[],
error: string | null | undefined
) => {
const errorString = error != null ? `\nError: ${error}` : ''
if (attachments.length > 0) {
const attachmentsImg = attachments.map((url) => `\n${url}`)
return `${message}\n${attachmentsImg.join('')}${errorString}`
@@ -120,3 +90,92 @@ export function formatSectionUrl(page: Page, section: PageSection): string {
throw new Error(`Unknown page type '${page.type}'`)
}
}
export function getOrgSubscriptionPlan(orgs: Organization[] | undefined, orgSlug: string | null) {
if (!orgs || !orgSlug) return undefined
const selectedOrg = orgs?.find((org) => org.slug === orgSlug)
const subscriptionPlanId = selectedOrg?.plan.id
return subscriptionPlanId
}
const categoryOptionsLower = CATEGORY_OPTIONS.map((option) => option.value.toLowerCase())
const parseAsCategoryOption = createParser({
parse(queryValue) {
const lowerValue = queryValue.toLowerCase()
const matchingIndex = categoryOptionsLower.indexOf(lowerValue)
return matchingIndex !== -1 ? CATEGORY_OPTIONS[matchingIndex].value : null
},
serialize(value) {
return value ?? null
},
})
const supportFormUrlState = {
projectRef: parseAsString.withDefault(NO_PROJECT_MARKER),
orgSlug: parseAsString.withDefault(NO_ORG_MARKER),
category: parseAsCategoryOption,
subject: parseAsString.withDefault(''),
message: parseAsString.withDefault(''),
error: parseAsString,
/** Sentry event ID */
sid: parseAsString,
} satisfies UseQueryStatesKeysMap
export type SupportFormUrlKeys = inferParserType<typeof supportFormUrlState>
export const loadSupportFormInitialParams = createLoader(supportFormUrlState)
const serializeSupportFormInitialParams = createSerializer(supportFormUrlState)
export function createSupportFormUrl(initialParams: SupportFormUrlKeys) {
const serializedParams = serializeSupportFormInitialParams(initialParams)
return `/support/new${serializedParams ?? ''}`
}
/**
* Determines which organization to select based on combination of:
* - Selected project (if any)
* - URL param (if any)
* - Fallback
*/
export async function selectInitalOrgAndProject({
projectRef,
orgSlug,
orgs,
}: {
projectRef: string | null
orgSlug: string | null
orgs: Organization[]
}): Promise<{ projectRef: string | null; orgSlug: string | null }> {
if (projectRef) {
try {
const projectDetails = await getProjectDetail({ ref: projectRef })
if (projectDetails?.organization_id) {
const org = orgs.find((o) => o.id === projectDetails.organization_id)
if (org?.slug) {
return {
projectRef,
orgSlug: org.slug,
}
}
}
} catch {
// Can safely ignore, consider provided project ref invalid
}
}
if (orgSlug) {
const org = orgs.find((o) => o.slug === orgSlug)
if (org?.slug) {
return {
projectRef: null,
orgSlug: org.slug,
}
}
}
return {
projectRef: null,
orgSlug: orgs[0]?.slug ?? null,
}
}
@@ -0,0 +1,247 @@
import * as Sentry from '@sentry/nextjs'
import { Loader2, Wrench } from 'lucide-react'
import Link from 'next/link'
import { type Dispatch, type PropsWithChildren, useCallback, useReducer } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import SVG from 'react-inlinesvg'
import { toast } from 'sonner'
// End of third-party imports
import CopyButton from 'components/ui/CopyButton'
import InformationBox from 'components/ui/InformationBox'
import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink'
import { usePlatformStatusQuery } from 'data/platform/platform-status-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useStateTransition } from 'hooks/misc/useStateTransition'
import { BASE_PATH, DOCS_URL } from 'lib/constants'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { AIAssistantOption } from './AIAssistantOption'
import { HighlightProjectRefProvider, useHighlightProjectRefContext } from './HighlightContext'
import { Success } from './Success'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
import {
createInitialSupportFormState,
type SupportFormActions,
supportFormReducer,
type SupportFormState,
} from './SupportForm.state'
import { SupportFormV2 } from './SupportFormV2'
import { useSupportForm } from './useSupportForm'
function useSupportFormTelemetry() {
const { mutate: sendEvent } = useSendEventMutation()
return useCallback(
({
projectRef,
orgSlug,
category,
}: {
projectRef: string | undefined
orgSlug: string | undefined
category: ExtendedSupportCategories
}) =>
sendEvent({
action: 'support_ticket_submitted',
properties: {
ticketCategory: category,
},
groups: {
project: projectRef,
organization: orgSlug,
},
}),
[sendEvent]
)
}
export function SupportFormPage() {
return (
<HighlightProjectRefProvider>
<SupportFormPageContent />
</HighlightProjectRefProvider>
)
}
function SupportFormPageContent() {
const [state, dispatch] = useReducer(supportFormReducer, undefined, createInitialSupportFormState)
const { form, initialError, projectRef, orgSlug } = useSupportForm(dispatch)
const sendTelemetry = useSupportFormTelemetry()
useStateTransition(state, 'submitting', 'success', (_, curr) => {
toast.success('Support request sent. Thank you!')
sendTelemetry({
projectRef: curr.sentProjectRef,
orgSlug: curr.sentOrgSlug,
category: curr.sentCategory,
})
})
useStateTransition(state, 'submitting', 'error', (_, curr) => {
toast.error(`Failed to submit support ticket: ${curr.message}`)
Sentry.captureMessage(`Failed to submit Support Form: ${curr.message}`)
dispatch({ type: 'RETURN_TO_EDITING' })
})
return (
<SupportFormWrapper>
<SupportFormHeader />
<AIAssistantOption projectRef={projectRef} organizationSlug={orgSlug} />
<SupportFormBody
form={form}
state={state}
dispatch={dispatch}
initialError={initialError}
selectedProjectRef={projectRef}
/>
<SupportFormDirectEmailInfo />
</SupportFormWrapper>
)
}
function SupportFormWrapper({ 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>
)
}
function SupportFormHeader() {
const { data, isLoading, isError } = usePlatformStatusQuery()
const isHealthy = data?.isHealthy
return (
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-y-2">
<div className="flex items-center space-x-3">
<SVG src={`${BASE_PATH}/img/supabase-logo.svg`} className="h-4 w-4" />
<h3 className="m-0 text-lg">Supabase support</h3>
</div>
<div className="flex items-center gap-x-3">
<Button asChild type="default" icon={<Wrench />}>
<Link
href={`${DOCS_URL}/guides/platform/troubleshooting`}
target="_blank"
rel="noreferrer"
>
Troubleshooting
</Link>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
type="default"
icon={
isLoading ? (
<Loader2 className="animate-spin" />
) : isHealthy ? (
<div className="h-2 w-2 bg-brand rounded-full" />
) : (
<div className="h-2 w-2 bg-yellow-900 rounded-full" />
)
}
>
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
{isLoading
? 'Checking status'
: isError
? 'Failed to check status'
: isHealthy
? 'All systems operational'
: 'Active incident ongoing'}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Check Supabase status page
</TooltipContent>
</Tooltip>
</div>
</div>
)
}
function SupportFormDirectEmailInfo() {
const { scrollToRef, setShouldHighlightRef: setHighlight } = useHighlightProjectRefContext()
return (
<InformationBox
title="Having trouble submitting the form?"
description={
<div className="flex flex-col gap-y-4">
<p className="flex items-center gap-x-1 ">
Email us directly at{' '}
<InlineLink href="mailto:support@supabase.com" className="font-mono">
support@supabase.com
</InlineLink>
<CopyButton
type="text"
text="support@supabase.com"
iconOnly
onClick={() => toast.success('Copied to clipboard')}
/>
</p>
<p>
Please, make sure to{' '}
<button
type="button"
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={() => {
scrollToRef()
setHighlight(true)
}}
>
include your project ID
</button>{' '}
and as much information as possible.
</p>
</div>
}
defaultVisibility={true}
hideCollapse={true}
/>
)
}
interface SupportFromBodyProps {
form: UseFormReturn<SupportFormValues>
state: SupportFormState
dispatch: Dispatch<SupportFormActions>
initialError: string | null
selectedProjectRef: string | null
}
function SupportFormBody({
form,
state,
dispatch,
initialError,
selectedProjectRef,
}: SupportFromBodyProps) {
const showSuccessMessage = state.type === 'success'
return (
<div
className={cn(
'min-w-full w-full space-y-12 rounded border bg-panel-body-light shadow-md',
`${showSuccessMessage ? 'pt-8' : 'py-8'}`,
'border-default'
)}
>
{showSuccessMessage ? (
<Success
selectedProject={selectedProjectRef ?? undefined}
sentCategory={state.sentCategory}
/>
) : (
<SupportFormV2 form={form} initialError={initialError} state={state} dispatch={dispatch} />
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
export const createSupportStorageClient = (): SupabaseClient => {
const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || ''
const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || ''
return createClient(SUPPORT_API_URL, SUPPORT_API_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
// @ts-expect-error
multiTab: false,
detectSessionInUrl: false,
localStorage: {
getItem: (_key: string) => undefined,
setItem: (_key: string, _value: string) => {},
removeItem: (_key: string) => {},
},
},
})
}
@@ -0,0 +1,135 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { type Dispatch, useEffect, useRef, useState } from 'react'
import { type DefaultValues, type UseFormReturn, useForm, useWatch } from 'react-hook-form'
// End of third-party imports
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { SupportFormSchema, type SupportFormValues } from './SupportForm.schema'
import type { SupportFormActions } from './SupportForm.state'
import {
loadSupportFormInitialParams,
NO_ORG_MARKER,
NO_PROJECT_MARKER,
type SupportFormUrlKeys,
selectInitalOrgAndProject,
} from './SupportForm.utils'
const supportFormDefaultValues: DefaultValues<SupportFormValues> = {
organizationSlug: NO_ORG_MARKER,
projectRef: NO_PROJECT_MARKER,
severity: 'Low',
category: undefined,
library: '',
subject: '',
message: '',
affectedServices: '',
allowSupportAccess: true,
dashboardSentryIssueId: '',
}
interface UseSupportFormResult {
form: UseFormReturn<SupportFormValues>
initialError: string | null
projectRef: string | null
orgSlug: string | null
}
export function useSupportForm(dispatch: Dispatch<SupportFormActions>): UseSupportFormResult {
const form = useForm<SupportFormValues>({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(SupportFormSchema),
defaultValues: supportFormDefaultValues,
})
const urlParamsRef = useRef<SupportFormUrlKeys | null>(null)
const [initialError, setInitialError] = useState<string | null>(null)
// Load initial values from URL params
useEffect(() => {
const params = loadSupportFormInitialParams(window.location.search)
urlParamsRef.current = params
setInitialError(params.error ?? null)
if (params.category && !form.getFieldState('category').isDirty) {
form.setValue('category', params.category, { shouldDirty: false })
}
if (typeof params.subject === 'string' && !form.getFieldState('subject').isDirty) {
form.setValue('subject', params.subject, { shouldDirty: false })
}
if (typeof params.message === 'string' && !form.getFieldState('message').isDirty) {
form.setValue('message', params.message, { shouldDirty: false })
}
if (params.sid && !form.getFieldState('dashboardSentryIssueId').isDirty) {
form.setValue('dashboardSentryIssueId', params.sid, {
shouldDirty: false,
})
}
}, [form])
const hasAppliedOrgProjectRef = useRef(false)
const { data: organizations, isLoading: organizationsLoading } = useOrganizationsQuery()
// Organization slug and project ref need to be validated after loading from
// URL params
useEffect(() => {
if (hasAppliedOrgProjectRef.current) return
if (!urlParamsRef.current) return
if (organizationsLoading) return
hasAppliedOrgProjectRef.current = true
const orgSlugFromUrl =
urlParamsRef.current.orgSlug && urlParamsRef.current.orgSlug !== NO_ORG_MARKER
? urlParamsRef.current.orgSlug
: null
const projectRefFromUrl =
urlParamsRef.current.projectRef && urlParamsRef.current.projectRef !== NO_PROJECT_MARKER
? urlParamsRef.current.projectRef
: null
selectInitalOrgAndProject({
projectRef: projectRefFromUrl,
orgSlug: orgSlugFromUrl,
orgs: organizations ?? [],
})
.then(({ orgSlug, projectRef }) => {
if (!form.getFieldState('organizationSlug').isDirty) {
form.setValue('organizationSlug', orgSlug ?? NO_ORG_MARKER, {
shouldDirty: false,
})
}
if (!form.getFieldState('projectRef').isDirty) {
form.setValue('projectRef', projectRef ?? NO_PROJECT_MARKER, {
shouldDirty: false,
})
}
})
.catch(() => {
// Ignored: fall back to defaults when lookup fails
})
.finally(() => {
dispatch({ type: 'INITIALIZE', debugSource: 'useSupportForm' })
})
}, [organizations, organizationsLoading, form, dispatch])
const watchedProjectRef = useWatch({
control: form.control,
name: 'projectRef',
})
const watchedOrgSlug = useWatch({
control: form.control,
name: 'organizationSlug',
})
const projectRef =
watchedProjectRef && watchedProjectRef !== NO_PROJECT_MARKER ? watchedProjectRef : null
const orgSlug = watchedOrgSlug && watchedOrgSlug !== NO_ORG_MARKER ? watchedOrgSlug : null
return {
form,
initialError,
projectRef,
orgSlug,
}
}
@@ -123,7 +123,7 @@ export const OrganizationProjectSelector = ({
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
{!!renderTrigger ? (
{renderTrigger ? (
renderTrigger({ isLoading: isLoadingProjects || isFetching, project: selectedProject })
) : (
<Button
@@ -1,13 +1,15 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { type UseMutationOptions, useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
// End of third-party imports
import type { ExtendedSupportCategories } from 'components/interfaces/Support/Support.constants'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
export type sendSupportTicketVariables = {
subject: string
message: string
category: string
category: ExtendedSupportCategories
severity: string
projectRef?: string
organizationSlug?: string
@@ -0,0 +1,27 @@
import { useRef } from 'react'
export function useStateTransition<
State extends { type: string },
PrevType extends State['type'],
NewType extends State['type'],
>(
state: State,
prevTest: PrevType,
newTest: NewType,
cb: (
prevState: Extract<State, { type: PrevType }>,
currState: Extract<State, { type: NewType }>
) => void
): void {
const prevState = useRef(state)
const savedPrevState = prevState.current
const shouldRunCallback = savedPrevState.type === prevTest && state.type === newTest
prevState.current = state
if (shouldRunCallback) {
cb(
savedPrevState as Extract<State, { type: PrevType }>,
state as Extract<State, { type: NewType }>
)
}
}
+2
View File
@@ -348,3 +348,5 @@ export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => {
}, timeoutMs)
}
}
export function neverGuard(_: never): any {}
+2 -134
View File
@@ -1,143 +1,11 @@
import { Loader2, Wrench } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import SVG from 'react-inlinesvg'
import { toast } from 'sonner'
import { AIAssistantOption } from 'components/interfaces/Support/AIAssistantOption'
import { Success } from 'components/interfaces/Support/Success'
import { SupportFormV2 } from 'components/interfaces/Support/SupportFormV2'
import { SupportFormPage } from 'components/interfaces/Support/SupportFormPage'
import AppLayout from 'components/layouts/AppLayout/AppLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import CopyButton from 'components/ui/CopyButton'
import InformationBox from 'components/ui/InformationBox'
import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink'
import { usePlatformStatusQuery } from 'data/platform/platform-status-query'
import { withAuth } from 'hooks/misc/withAuth'
import { BASE_PATH, DOCS_URL } from 'lib/constants'
import { useQueryState } from 'nuqs'
import { NextPageWithLayout } from 'types'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
const SupportPage: NextPageWithLayout = () => {
const [sentCategory, setSentCategory] = useState<string>()
// For AIAssistantOption projectRef prop
const [selectedProject, setSelectedProject] = useState<string>('no-project')
// For AIAssistantOption organizationSlug prop
const [selectedOrganization, setSelectedOrganization] = useState<string>('no-org')
const { data, isLoading } = usePlatformStatusQuery()
const isHealthy = data?.isHealthy
const [_, setHighlightRef] = useQueryState('highlight', { defaultValue: '' })
return (
<div className="relative flex overflow-y-auto overflow-x-hidden">
<div className="mx-auto my-8 max-w-2xl w-full px-4 lg:px-6">
<div className="flex flex-col gap-y-8 py-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-y-2">
<div className="flex items-center space-x-3">
<SVG src={`${BASE_PATH}/img/supabase-logo.svg`} className="h-4 w-4" />
<h3 className="m-0 text-lg">Supabase support</h3>
</div>
<div className="flex items-center gap-x-3">
<Button asChild type="default" icon={<Wrench />}>
<Link
href={`${DOCS_URL}/guides/platform/troubleshooting`}
target="_blank"
rel="noreferrer"
>
Troubleshooting
</Link>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
type="default"
icon={
isLoading ? (
<Loader2 className="animate-spin" />
) : isHealthy ? (
<div className="h-2 w-2 bg-brand rounded-full" />
) : (
<div className="h-2 w-2 bg-yellow-900 rounded-full" />
)
}
>
<Link href="https://status.supabase.com/" target="_blank" rel="noreferrer">
{isLoading
? 'Checking status'
: isHealthy
? 'All systems operational'
: 'Active incident ongoing'}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="center">
Check Supabase status page
</TooltipContent>
</Tooltip>
</div>
</div>
<AIAssistantOption projectRef={selectedProject} organizationSlug={selectedOrganization} />
<div
className={[
'min-w-full w-full space-y-12 rounded border bg-panel-body-light shadow-md',
`${sentCategory === undefined ? 'py-8' : 'pt-8'}`,
'border-default',
].join(' ')}
>
{sentCategory !== undefined ? (
<Success sentCategory={sentCategory} selectedProject={selectedProject} />
) : (
<SupportFormV2
onProjectSelected={setSelectedProject}
onOrganizationSelected={setSelectedOrganization}
setSentCategory={setSentCategory}
/>
)}
</div>
<InformationBox
title="Having trouble submitting the form?"
description={
<div className="flex flex-col gap-y-4">
<p className="flex items-center gap-x-1 ">
Email us directly at{' '}
<InlineLink href="mailto:support@supabase.com" className="font-mono">
support@supabase.com
</InlineLink>
<CopyButton
type="text"
text="support@supabase.com"
iconOnly
onClick={() => toast.success('Copied to clipboard')}
/>
</p>
<p>
Please, make sure to{' '}
<span
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={() => {
const el = document.getElementById('projectRef-field')
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' })
setHighlightRef('true')
}}
>
include your project ID
</span>{' '}
and as much information as possible.
</p>
</div>
}
defaultVisibility={true}
hideCollapse={true}
/>
</div>
</div>
</div>
)
return <SupportFormPage />
}
SupportPage.getLayout = (page) => (
+53 -2
View File
@@ -1,8 +1,14 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, getByText, render as originalRender, screen } from '@testing-library/react'
import React, { useState } from 'react'
import type React from 'react'
import { useState } from 'react'
// End of third-party imports
import type { Project } from 'data/projects/project-detail-query'
import type { Organization } from 'types'
import { TooltipProvider } from 'ui'
interface SelectorOptions {
container?: HTMLElement
}
@@ -40,6 +46,48 @@ export const clickDropdown = (elem: HTMLElement) => {
)
}
export const createMockOrganization = (details: Partial<Organization>): Organization => {
const base: Organization = {
id: 1,
name: 'Organization 1',
slug: 'abcdefghijklmnopqrst',
plan: { id: 'free', name: 'Free' },
managed_by: 'supabase',
is_owner: true,
billing_email: 'billing@example.com',
billing_partner: null,
usage_billing_enabled: false,
stripe_customer_id: 'stripe-1',
subscription_id: 'subscription-1',
organization_requires_mfa: false,
opt_in_tags: [],
restriction_status: null,
restriction_data: null,
}
return Object.assign(base, details)
}
export const createMockProject = (details: Partial<Project>): Project => {
const base: Project = {
id: 1,
ref: 'abcdefghijklmnopqrst',
name: 'Project 1',
status: 'ACTIVE_HEALTHY',
organization_id: 1,
cloud_provider: 'AWS',
region: 'us-east-1',
inserted_at: new Date().toISOString(),
subscription_id: 'subscription-1',
db_host: 'db.supabase.co',
is_branch_enabled: false,
is_physical_backups_enabled: false,
restUrl: 'https://project-1.supabase.co',
}
return Object.assign(base, details)
}
/**
* A custom render function for react testing library
* https://testing-library.com/docs/react-testing-library/setup/#custom-render
@@ -70,4 +118,7 @@ const ReactQueryTestConfig: React.FC<React.PropsWithChildren> = ({ children }) =
}
type renderParams = Parameters<typeof originalRender>
export const render = ((ui: renderParams[0], options: renderParams[1]) =>
originalRender(ui, { wrapper: ReactQueryTestConfig, ...options })) as typeof originalRender
originalRender(ui, {
wrapper: ReactQueryTestConfig,
...options,
})) as typeof originalRender
+16 -2
View File
@@ -1,6 +1,9 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook, RenderOptions } from '@testing-library/react'
import { type RenderOptions, render, renderHook } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
// End of third-party imports
import { ProfileContext, type ProfileContextType } from 'lib/profile'
import { TooltipProvider } from 'ui'
type AdapterProps = Partial<Parameters<typeof NuqsTestingAdapter>[0]>
@@ -9,10 +12,12 @@ const CustomWrapper = ({
children,
queryClient,
nuqs,
profileContext,
}: {
children: React.ReactNode
queryClient?: QueryClient
nuqs?: AdapterProps
profileContext?: ProfileContextType
}) => {
const _queryClient =
queryClient ??
@@ -24,18 +29,25 @@ const CustomWrapper = ({
},
})
return (
const content = (
<QueryClientProvider client={_queryClient}>
<NuqsTestingAdapter {...nuqs}>
<TooltipProvider>{children}</TooltipProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
)
return profileContext ? (
<ProfileContext.Provider value={profileContext}>{content}</ProfileContext.Provider>
) : (
content
)
}
type CustomRenderOpts = RenderOptions & {
queryClient?: QueryClient
nuqs?: AdapterProps
profileContext?: ProfileContextType
}
export const customRender = (component: React.ReactElement, renderOptions?: CustomRenderOpts) => {
@@ -44,6 +56,7 @@ export const customRender = (component: React.ReactElement, renderOptions?: Cust
CustomWrapper({
queryClient: renderOptions?.queryClient,
nuqs: renderOptions?.nuqs,
profileContext: renderOptions?.profileContext,
children,
}),
...renderOptions,
@@ -57,6 +70,7 @@ export const customRenderHook = (hook: () => any, renderOptions?: CustomRenderOp
children,
queryClient: renderOptions?.queryClient,
nuqs: renderOptions?.nuqs,
profileContext: renderOptions?.profileContext,
}),
...renderOptions,
})
+33
View File
@@ -0,0 +1,33 @@
import type { Profile } from 'data/profile/types'
import type { ProfileContextType } from 'lib/profile'
export const createMockProfile = (overrides: Partial<Profile> = {}): Profile => {
const baseProfile: Profile = {
id: 1,
primary_email: 'test@example.com',
username: 'test-user',
first_name: 'Test',
last_name: 'User',
auth0_id: 'github|test-user',
is_alpha_user: false,
disabled_features: [],
free_project_limit: 2,
gotrue_id: '00000000-0000-0000-0000-000000000000',
is_sso_user: false,
mobile: '000-000-0000',
}
return Object.assign(baseProfile, overrides)
}
export const createMockProfileContext = (
overrides: Partial<ProfileContextType> = {}
): ProfileContextType => {
return {
profile: overrides.profile ?? createMockProfile(),
error: overrides.error ?? null,
isLoading: overrides.isLoading ?? false,
isError: overrides.isError ?? false,
isSuccess: overrides.isSuccess ?? true,
}
}