mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
@@ -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 }>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -348,3 +348,5 @@ export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => {
|
||||
}, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
export function neverGuard(_: never): any {}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user