diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 919802bbdf57b..ea55aafa52efc 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -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' diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx index 880542bdf43be..237f6b381a00c 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx @@ -48,6 +48,14 @@ const FormSchema = z.object({ const defaultValues = { secrets: [{ name: '', value: '' }], } + +const removeWrappingQuotes = (str: string): string => { + if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) { + return str.slice(1, -1) + } + return str +} + const AddNewSecretForm = () => { const { ref: projectRef } = useParams() const [showSecretValue, setShowSecretValue] = useState(false) @@ -102,9 +110,10 @@ const AddNewSecretForm = () => { lines.forEach((line) => { const [key, ...valueParts] = line.split('=') if (key && valueParts.length) { + const valueStr = valueParts.join('=').trim() pairs.push({ name: key.trim(), - value: valueParts.join('=').trim(), + value: removeWrappingQuotes(valueStr), }) } }) diff --git a/apps/studio/components/interfaces/Support/AIAssistantOption.tsx b/apps/studio/components/interfaces/Support/AIAssistantOption.tsx index e2b6db4f268e7..7d1a3372548be 100644 --- a/apps/studio/components/interfaces/Support/AIAssistantOption.tsx +++ b/apps/studio/components/interfaces/Support/AIAssistantOption.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/Support/AffectedServicesSelector.tsx b/apps/studio/components/interfaces/Support/AffectedServicesSelector.tsx new file mode 100644 index 0000000000000..660bc0b9603b7 --- /dev/null +++ b/apps/studio/components/interfaces/Support/AffectedServicesSelector.tsx @@ -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 + 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 ( + ( + + + form.setValue('affectedServices', services.join(', '))} + /> + + + )} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx new file mode 100644 index 0000000000000..9e2d5a9ef138b --- /dev/null +++ b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx @@ -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(null) + + const [uploadedFiles, setUploadedFiles] = useState([]) + const [uploadedDataUrls, setUploadedDataUrls] = useState([]) + + const isFull = uploadedFiles.length >= MAX_ATTACHMENTS + + const addFile = useCallback(() => { + uploadButtonRef.current?.click() + }, []) + + const handleFileUpload = useCallback( + async (event: ChangeEvent) => { + 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 + isFull: boolean + addFile: () => void + handleFileUpload: (event: ChangeEvent) => Promise + removeFileUpload: (idx: number) => void + uploadedDataUrls: Array +} + +export function AttachmentUploadDisplay({ + uploadButtonRef, + isFull, + addFile, + handleFileUpload, + removeFileUpload, + uploadedDataUrls, +}: AttachmentUploadDisplayProps) { + return ( +
+
+

Attachments

+

+ Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that you're + facing +

+
+ +
+ {uploadedDataUrls.map((url, idx) => ( +
+ +
+ ))} + {!isFull && ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx new file mode 100644 index 0000000000000..7d612c2d650fb --- /dev/null +++ b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx @@ -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 + category: ExtendedSupportCategories + severity: string + projectRef: string +} + +export function CategoryAndSeverityInfo({ + form, + category, + severity, + projectRef, +}: CategoryAndSeverityInfoProps) { + return ( +
+ + + + + + {(severity === 'Urgent' || severity === 'High') && ( + + )} +
+ ) +} + +interface CategorySelectorProps { + form: UseFormReturn +} + +function CategorySelector({ form }: CategorySelectorProps) { + return ( + { + const { ref: _ref, ...fieldWithoutRef } = field + return ( + + + + + + {field.value + ? CATEGORY_OPTIONS.find((o) => o.value === field.value)?.label + : null} + + + + + {CATEGORY_OPTIONS.map((option) => ( + + {option.label} + + {option.description} + + + ))} + + + + + + ) + }} + /> + ) +} + +interface SeveritySelectorProps { + form: UseFormReturn +} + +function SeveritySelector({ form }: SeveritySelectorProps) { + return ( + { + const { ref, ...fieldWithoutRef } = field + return ( + + + + + + {field.value} + + + + + {SEVERITY_OPTIONS.map((option) => ( + + {option.label} + + {option.description} + + + ))} + + + + + + ) + }} + /> + ) +} + +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 ( + + 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{' '} + here. + + ) + } + + if (category === SupportCategories.DATABASE_UNRESPONSIVE) { + return ( + + High memory or low disk IO bandwidth may be slowing down your database. Verify by checking + the infrastructure activity of your project{' '} + + here + + . + + ) + } + + if (category === SupportCategories.PERFORMANCE_ISSUES) { + return ( + + Identify slow running queries and get actionable insights on how to optimize them with the + Query Performance Advisor{' '} + + here + + . + + ) + } + + return null +} diff --git a/apps/studio/components/interfaces/Support/ClientLibraryInfo.tsx b/apps/studio/components/interfaces/Support/ClientLibraryInfo.tsx new file mode 100644 index 0000000000000..889af97acb82b --- /dev/null +++ b/apps/studio/components/interfaces/Support/ClientLibraryInfo.tsx @@ -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 + 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 ( +
+ ( + + + + + + + + + {CLIENT_LIBRARIES.map((option) => ( + + {option.language} + + ))} + + + + + + )} + /> + {library && library.length > 0 && } +
+ ) +} + +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 ( +
+
+

+ Found an issue or a bug? Try searching our GitHub issues or submit a new one. +

+
+
+ {selectedClientLibraries?.map((lib) => { + const libraryLanguage = library === 'Dart (Flutter)' ? lib.name.split('-')[1] : library + return ( +
+
+

{lib.name}

+

+ For issues regarding the {libraryLanguage} client library +

+
+
+ +
+
+ ) + })} +
+
+

supabase

+

For any issues about our API

+
+
+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Support/DocsSuggestions.tsx b/apps/studio/components/interfaces/Support/DocsSuggestions.tsx new file mode 100644 index 0000000000000..9bd626d5ad65b --- /dev/null +++ b/apps/studio/components/interfaces/Support/DocsSuggestions.tsx @@ -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' && } + {results.length > 0 && } + + ) +} + +function DocsSuggestions_Loading() { + return ( +
+ + Searching for relevant resources... +
+ ) +} + +interface DocsSuggestions_ResultsProps { + results: DocsSearchResult[] + isStale: boolean +} + +function DocsSuggestions_Results({ results, isStale }: DocsSuggestions_ResultsProps) { + return ( +
    + {results.slice(0, 5).map((page) => { + return ( +
  • + {page.type === 'github-discussions' ? ( + + ) : ( + + )} + + {page.title} + +
  • + ) + })} +
+ ) +} diff --git a/apps/studio/components/interfaces/Support/HighlightContext.tsx b/apps/studio/components/interfaces/Support/HighlightContext.tsx new file mode 100644 index 0000000000000..f0b2c9aad838f --- /dev/null +++ b/apps/studio/components/interfaces/Support/HighlightContext.tsx @@ -0,0 +1,62 @@ +import { parseAsBoolean, useQueryState } from 'nuqs' +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + type PropsWithChildren, +} from 'react' + +interface HighlightProjectRefContextValue { + ref: React.RefObject + shouldHighlightRef: boolean + setShouldHighlightRef: (value: boolean) => void + scrollToRef: () => void +} + +const HighlightProjectRefContext = createContext( + undefined +) + +export function HighlightProjectRefProvider({ children }: PropsWithChildren) { + const projectRefContainerRef = useRef(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 ( + + {children} + + ) +} + +export function useHighlightProjectRefContext() { + const context = useContext(HighlightProjectRefContext) + if (!context) { + throw new Error( + 'useHighlightProjectRefContext must be used within a HighlightProjectRefProvider' + ) + } + return context +} diff --git a/apps/studio/components/interfaces/Support/IssueSuggestions.tsx b/apps/studio/components/interfaces/Support/IssueSuggestions.tsx deleted file mode 100644 index 0ca5fd69632f1..0000000000000 --- a/apps/studio/components/interfaces/Support/IssueSuggestions.tsx +++ /dev/null @@ -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 ( - - 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{' '} - here. - - ) - } - - if (category === SupportCategories.DATABASE_UNRESPONSIVE) { - return ( - - High memory or low disk IO bandwidth may be slowing down your database. Verify by checking - the infrastructure activity of your project{' '} - - here - - . - - ) - } - - if (category === SupportCategories.PERFORMANCE_ISSUES) { - return ( - - Identify slow running queries and get actionable insights on how to optimize them with the - Query Performance Advisor{' '} - - here - - . - - ) - } - - return null -} diff --git a/apps/studio/components/interfaces/Support/LibrarySuggestions.tsx b/apps/studio/components/interfaces/Support/LibrarySuggestions.tsx deleted file mode 100644 index ec720d32fa9f6..0000000000000 --- a/apps/studio/components/interfaces/Support/LibrarySuggestions.tsx +++ /dev/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 ( -
-
-

- Found an issue or a bug? Try searching our GitHub issues or submit a new one. -

-
-
- {selectedClientLibraries?.map((lib) => { - const libraryLanguage = library === 'Dart (Flutter)' ? lib.name.split('-')[1] : library - return ( -
-
-

{lib.name}

-

- For issues regarding the {libraryLanguage} client library -

-
-
- -
-
- ) - })} -
-
-

supabase

-

For any issues about our API

-
-
- -
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Support/MessageField.tsx b/apps/studio/components/interfaces/Support/MessageField.tsx new file mode 100644 index 0000000000000..1a0196f3aba00 --- /dev/null +++ b/apps/studio/components/interfaces/Support/MessageField.tsx @@ -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 + originalError: string | null | undefined +} + +export function MessageField({ form, originalError }: MessageFieldProps) { + return ( + ( + field.value.includes(str)) && ( + + ) + } + > + + + + {originalError && ( + + )} + + )} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/OrganizationSelector.tsx b/apps/studio/components/interfaces/Support/OrganizationSelector.tsx new file mode 100644 index 0000000000000..2a137cec05831 --- /dev/null +++ b/apps/studio/components/interfaces/Support/OrganizationSelector.tsx @@ -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 + orgSlug: string | null +} + +export function OrganizationSelector({ form, orgSlug }: OrganizationSelectorProps) { + const { data: organizations, isSuccess: isSuccessOrganizations } = useOrganizationsQuery() + const subscriptionPlanId = getOrgSubscriptionPlan(organizations, orgSlug) + + return ( + { + const { ref: _ref, ...fieldWithoutRef } = field + return ( + + + { + const previousOrgSlug = form.getValues('organizationSlug') + field.onChange(value) + if (previousOrgSlug !== value) { + form.resetField('projectRef', { defaultValue: NO_PROJECT_MARKER }) + } + }} + > + + +
+ {orgSlug === NO_ORG_MARKER ? ( + No specific organization + ) : ( + (organizations ?? []).find((o) => o.slug === field.value)?.name + )} + {subscriptionPlanId && ( + + {subscriptionPlanId} + + )} +
+
+
+ + + {organizations?.map((org) => ( + + {org.name} + + ))} + {isSuccessOrganizations && (organizations ?? []).length === 0 && ( + + No specific organization + + )} + + +
+
+
+ ) + }} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx b/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx deleted file mode 100644 index cf4a34b552840..0000000000000 --- a/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx +++ /dev/null @@ -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 ( - } - 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: ( -
- {planId === 'free' && ( -

- 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. -

- )} - - {planId === 'pro' && ( -

- 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. -

- )} - - {planId === 'team' && ( -

- 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. -

- )} - -
- - -
-
- ), - })} - /> - ) -} diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx new file mode 100644 index 0000000000000..0d85a5015c55b --- /dev/null +++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx @@ -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 + 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 ( +
+ + + + {!hasProjectSelected && ( + + )} + + {orgSlug && subscriptionPlanId !== 'enterprise' && category !== 'Login_issues' && ( + + )} +
+ ) +} + +interface ProjectSelectorProps { + form: UseFormReturn + orgSlug: string | null + projectRef: string | null +} + +function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) { + return ( + ( + + + { + if (!projectRef || projectRef === NO_PROJECT_MARKER) + field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER) + }} + onSelect={(project) => field.onChange(project.ref)} + renderTrigger={({ isLoading, project }) => { + return ( + + ) + }} + renderActions={(setOpen) => ( + + { + field.onChange(NO_PROJECT_MARKER) + setOpen(false) + }} + > + {field.value === NO_PROJECT_MARKER && } +

+ No specific project +

+
+
+ )} + /> +
+
+ )} + /> + ) +} + +interface ProjectRefHighlightedProps { + projectRef: string | null +} + +function ProjectRefHighlighted({ projectRef }: ProjectRefHighlightedProps) { + const isVisible = !!projectRef && projectRef !== NO_PROJECT_MARKER + + const { shouldHighlightRef, setShouldHighlightRef: setHighlight } = + useHighlightProjectRefContext() + + return ( + + {isVisible && ( + +

+ Project ID:{' '} + + {projectRef} + +

+ { + toast.success('Copied to clipboard') + setHighlight(false) + }} + /> +
+ )} +
+ ) +} + +interface PlanExpectationInfoBoxProps { + orgSlug: string + planId?: string +} + +const PlanExpectationInfoBox = ({ orgSlug, planId }: PlanExpectationInfoBoxProps) => { + const { billingAll } = useIsFeatureEnabled(['billing:all']) + + return ( + } + defaultVisibility={true} + hideCollapse={true} + title="Expected response times are based on your organization's plan" + description={ +
+ {planId === 'free' && ( +

+ 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. +

+ )} + + {planId === 'pro' && ( +

+ 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. +

+ )} + + {planId === 'team' && ( +

+ 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. +

+ )} + + {billingAll && planId !== 'enterprise' && ( +
+ + +
+ )} +
+ } + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/SubjectAndSuggestionsInfo.tsx b/apps/studio/components/interfaces/Support/SubjectAndSuggestionsInfo.tsx new file mode 100644 index 0000000000000..3347129c2e456 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SubjectAndSuggestionsInfo.tsx @@ -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 + category: ExtendedSupportCategories + subject: string +} + +export function SubjectAndSuggestionsInfo({ + form, + category, + subject, +}: SubjectAndSuggestionsInfoProps) { + return ( +
+ ( + + + + + + )} + /> + + {subject && INCLUDE_DISCUSSIONS.includes(category) && ( + + )} +
+ ) +} + +interface GitHubDiscussionSuggestionProps { + subject: string +} + +function GitHubDiscussionSuggestion({ subject }: GitHubDiscussionSuggestionProps) { + return ( +

+ Check our + + GitHub discussions + + + for a quick answer +

+ ) +} diff --git a/apps/studio/components/interfaces/Support/SubmitButton.tsx b/apps/studio/components/interfaces/Support/SubmitButton.tsx new file mode 100644 index 0000000000000..3403baa705169 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SubmitButton.tsx @@ -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 +} + +export function SubmitButton({ isSubmitting, userEmail, onClick }: SubmitButtonProps) { + return ( +
+ +
+
+ We will contact you at + {userEmail} +
+ + Please ensure emails from supabase.com are allowed + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Support/Support.constants.ts b/apps/studio/components/interfaces/Support/Support.constants.ts index 9b209304c48fd..777d187c6507b 100644 --- a/apps/studio/components/interfaces/Support/Support.constants.ts +++ b/apps/studio/components/interfaces/Support/Support.constants.ts @@ -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 = [ diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx new file mode 100644 index 0000000000000..fad9a7da22ab9 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -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 +} + +export function SupportAccessToggle({ form }: SupportAccessToggleProps) { + return ( + { + return ( + + Allow support access to your project + Recommended + + } + description={ +
+ + Human support and AI diagnostic access. + + + + + More information + + +

+ 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. +

+

+ We are committed to maintaining strict data privacy and security standards in + all support activities.{' '} + + Privacy Policy + +

+
+
+
+ } + > + +
+ ) + }} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/SupportForm.schema.ts b/apps/studio/components/interfaces/Support/SupportForm.schema.ts new file mode 100644 index 0000000000000..79c9af8cfeb29 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportForm.schema.ts @@ -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 diff --git a/apps/studio/components/interfaces/Support/SupportForm.state.ts b/apps/studio/components/interfaces/Support/SupportForm.state.ts new file mode 100644 index 0000000000000..dd4605c458db5 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportForm.state.ts @@ -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) + } +} diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index 0a8c7682d596a..3ee06ef84baa5 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -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' - -const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || '' -const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || '' - -export const uploadAttachments = async (ref: string, files: File[]) => { - const supportSupabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, { - auth: { - persistSession: false, - autoRefreshToken: false, - // @ts-ignore - multiTab: false, - detectSessionInUrl: false, - localStorage: { - getItem: (key: string) => undefined, - setItem: (key: string, value: string) => {}, - removeItem: (key: string) => {}, - }, - }, - }) - - const filesToUpload = Array.from(files) - const uploadedFiles = await Promise.all( - filesToUpload.map(async (file) => { - const suffix = file.type.split('/')[1] - const prefix = `${ref}/${uuidv4()}.${suffix}` - const options = { cacheControl: '3600' } - - const { data, error } = await supportSupabaseClient.storage - .from('support-attachments') - .upload(prefix, file, options) - - if (error) console.error('Failed to upload:', file.name, error) - return data - }) - ) - const keys = compact(uploadedFiles).map((file) => file.path) - - if (keys.length === 0) return [] - - const { data, error } = await supportSupabaseClient.storage - .from('support-attachments') - .createSignedUrls(keys, 10 * 365 * 24 * 60 * 60) - if (error) { - console.error('Failed to retrieve URLs for attachments', error) - } - return data ? data.map((file) => file.signedUrl) : [] -} +import type { Organization } from 'types' +import { CATEGORY_OPTIONS } from './Support.constants' + +export const NO_PROJECT_MARKER = 'no-project' +export const NO_ORG_MARKER = 'no-org' -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 + +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, + } +} diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx new file mode 100644 index 0000000000000..e4d48d0b9a4d6 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -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 ( + + + + ) +} + +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 ( + + + + + + + ) +} + +function SupportFormWrapper({ children }: PropsWithChildren) { + return ( +
+
+
{children}
+
+
+ ) +} + +function SupportFormHeader() { + const { data, isLoading, isError } = usePlatformStatusQuery() + const isHealthy = data?.isHealthy + + return ( +
+
+ +

Supabase support

+
+ +
+ + + + + + + Check Supabase status page + + +
+
+ ) +} + +function SupportFormDirectEmailInfo() { + const { scrollToRef, setShouldHighlightRef: setHighlight } = useHighlightProjectRefContext() + + return ( + +

+ Email us directly at{' '} + + support@supabase.com + + toast.success('Copied to clipboard')} + /> +

+

+ Please, make sure to{' '} + {' '} + and as much information as possible. +

+ + } + defaultVisibility={true} + hideCollapse={true} + /> + ) +} + +interface SupportFromBodyProps { + form: UseFormReturn + state: SupportFormState + dispatch: Dispatch + initialError: string | null + selectedProjectRef: string | null +} + +function SupportFormBody({ + form, + state, + dispatch, + initialError, + selectedProjectRef, +}: SupportFromBodyProps) { + const showSuccessMessage = state.type === 'success' + + return ( +
+ {showSuccessMessage ? ( + + ) : ( + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 10574b2e8e240..afbea1f3accb5 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,315 +1,116 @@ -import { zodResolver } from '@hookform/resolvers/zod' -import * as Sentry from '@sentry/nextjs' -import { SupportCategories } from '@supabase/shared-types/out/constants' -import { AnimatePresence, motion } from 'framer-motion' -import { - Book, - Check, - ChevronRight, - ChevronsUpDown, - ExternalLink, - Github, - Loader2, - Mail, - Plus, - X, -} from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useQueryState } from 'nuqs' -import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' +import type { Dispatch, MouseEventHandler } from 'react' +import type { SubmitHandler, UseFormReturn } from 'react-hook-form' +// End of third-party imports -import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { CLIENT_LIBRARIES } from 'common/constants' -import CopyButton from 'components/ui/CopyButton' -import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' -import { PLAN_REQUEST_EMPTY_PLACEHOLDER } from 'components/ui/UpgradePlanButton' import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { getProjectDetail } from 'data/projects/project-detail-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { DOCS_URL } from 'lib/constants' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' +import { DialogSectionSeparator, Form_Shadcn_, Separator } from 'ui' import { - Badge, - Button, - cn, - Collapsible_Shadcn_, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - CommandGroup_Shadcn_, - CommandItem_Shadcn_, - Form_Shadcn_, - FormControl_Shadcn_, - FormField_Shadcn_, - Input_Shadcn_, - Select_Shadcn_, - SelectContent_Shadcn_, - SelectGroup_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, - Separator, - Switch, - TextArea_Shadcn_, -} from 'ui' -import { Admonition } from 'ui-patterns' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' -import { IssueSuggestion } from './IssueSuggestions' -import { LibrarySuggestions } from './LibrarySuggestions' -import { PlanExpectationInfoBox } from './PlanExpectationInfoBox' + CATEGORIES_WITHOUT_AFFECTED_SERVICES, + AffectedServicesSelector, +} from './AffectedServicesSelector' +import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload' +import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' +import { ClientLibraryInfo } from './ClientLibraryInfo' +import { MessageField } from './MessageField' +import { OrganizationSelector } from './OrganizationSelector' +import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' +import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' +import { SubmitButton } from './SubmitButton' +import { SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import type { SupportFormValues } from './SupportForm.schema' +import type { SupportFormActions, SupportFormState } from './SupportForm.state' import { - CATEGORY_OPTIONS, - IPV4_MIGRATION_STRINGS, - SERVICE_OPTIONS, - SEVERITY_OPTIONS, -} from './Support.constants' -import { formatMessage, uploadAttachments } from './SupportForm.utils' - -const MAX_ATTACHMENTS = 5 -const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] -const ALLOW_SUPPORT_ACCESS_CATEGORIES = ['Problem', 'Database_unresponsive', 'Performance'] -const CONTAINER_CLASSES = 'px-6' - -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.string().min(1, 'Please select an issue type'), - 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(), - }) - - 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 defaultValues = { - organizationSlug: '', - // [Joshen TODO] We should refactor this to accept a null value instead of a magic string - projectRef: 'no-project', - category: '', - severity: 'Low', - library: '', - subject: '', - message: '', - affectedServices: '', - allowSupportAccess: true, -} + formatMessage, + getOrgSubscriptionPlan, + NO_ORG_MARKER, + NO_PROJECT_MARKER, +} from './SupportForm.utils' interface SupportFormV2Props { - onProjectSelected: (value: string) => void - onOrganizationSelected: (value: string) => void - setSentCategory: (value: string) => void + form: UseFormReturn + initialError: string | null + state: SupportFormState + dispatch: Dispatch } -export const SupportFormV2 = ({ - onProjectSelected: setSelectedProject, - onOrganizationSelected: setSelectedOrganization, - setSentCategory, -}: SupportFormV2Props) => { +export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFormV2Props) => { const { profile } = useProfile() - const [highlightRef, setHighlightRef] = useQueryState('highlight', { defaultValue: '' }) - - // [Joshen] Ideally refactor all these to use nuqs - const { - projectRef: urlRef, - slug: urlSlug, - category: urlCategory, - subject: urlSubject, - message: urlMessage, - error, - } = useParams() - const router = useRouter() - const dashboardSentryIssueId = router.query.sid as string - - const isBillingEnabled = useIsFeatureEnabled('billing:all') - const showClientLibraries = useIsFeatureEnabled('support:show_client_libraries') - - const categoryOptions = useMemo(() => { - return CATEGORY_OPTIONS.filter((option) => { - if ( - option.value === SupportCategories.BILLING || - option.value === SupportCategories.REFUND || - option.value === SupportCategories.SALES_ENQUIRY - ) { - return isBillingEnabled - } else if (option.value === 'Plan_upgrade') { - return !isBillingEnabled - } - - return true - }) - }, [isBillingEnabled]) - - const uploadButtonRef = useRef(null) - const [isSubmitting, setIsSubmitting] = useState(false) - const [docsResults, setDocsResults] = useState([]) - const [uploadedFiles, setUploadedFiles] = useState([]) - const [uploadedDataUrls, setUploadedDataUrls] = useState([]) - - const FormSchema = useMemo(() => createFormSchema(showClientLibraries), [showClientLibraries]) - - const form = useForm>({ - mode: 'onBlur', - reValidateMode: 'onBlur', - resolver: zodResolver(FormSchema), - defaultValues, - }) + const respondToEmail = profile?.primary_email ?? 'your email' const { organizationSlug, projectRef, category, severity, subject, library } = form.watch() - const { handleDocsSearchDebounced, searchState, searchState: state } = useDocsSearch() + const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug + const selectedProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef - const { - data: organizations, - isLoading: isLoadingOrganizations, - isSuccess: isSuccessOrganizations, - } = useOrganizationsQuery() + const { data: organizations } = useOrganizationsQuery() + const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug) - const selectedOrganization = useMemo( - () => organizations?.find((org) => org.slug === organizationSlug), - [organizationSlug, organizations] - ) - - const { mutate: sendEvent } = useSendEventMutation() + const attachmentUpload = useAttachmentUpload() const { mutate: submitSupportTicket } = useSendSupportTicketMutation({ - onSuccess: (res, variables) => { - toast.success('Support request sent. Thank you!') - setSentCategory(variables.category) - sendEvent({ - action: 'support_ticket_submitted', - properties: { - ticketCategory: variables.category, - }, - groups: { - project: projectRef === 'no-project' ? undefined : projectRef, - organization: variables.organizationSlug, - }, + onSuccess: (_, variables) => { + dispatch({ + type: 'SUCCESS', + sentProjectRef: variables.projectRef, + sentOrgSlug: variables.organizationSlug, + sentCategory: variables.category, }) - setSelectedProject(variables.projectRef ?? 'no-project') }, onError: (error) => { - toast.error(`Failed to submit support ticket: ${error.message}`) - Sentry.captureMessage('Failed to submit Support Form: ' + error.message) - setIsSubmitting(false) + dispatch({ + type: 'ERROR', + message: error.message, + }) }, }) - const respondToEmail = profile?.primary_email ?? 'your email' - const subscriptionPlanId = selectedOrganization?.plan.id - - const hasResults = - state.status === 'fullResults' || - state.status === 'partialResults' || - (state.status === 'loading' && state.staleResults.length > 0) - - const onFilesUpload = async (event: ChangeEvent) => { - event.persist() - const items = event.target.files || (event as any).dataTransfer.items - const itemsCopied = Array.prototype.map.call(items, (item) => item) as File[] - const itemsToBeUploaded = itemsCopied.slice(0, MAX_ATTACHMENTS - uploadedFiles.length) - - setUploadedFiles(uploadedFiles.concat(itemsToBeUploaded)) - if (items.length + uploadedFiles.length > MAX_ATTACHMENTS) { - toast(`Only up to ${MAX_ATTACHMENTS} attachments are allowed`) - } - event.target.value = '' - } - - const removeUploadedFile = (idx: number) => { - const updatedFiles = uploadedFiles?.slice() - updatedFiles.splice(idx, 1) - setUploadedFiles(updatedFiles) + const onSubmit: SubmitHandler = async (values) => { + dispatch({ type: 'SUBMIT' }) + const attachments = await attachmentUpload.createAttachments(projectRef) - const updatedDataUrls = uploadedDataUrls.slice() - uploadedDataUrls.splice(idx, 1) - setUploadedDataUrls(updatedDataUrls) - } - - const onSubmit: SubmitHandler> = async (values) => { - setIsSubmitting(true) - const attachments = - uploadedFiles.length > 0 ? await uploadAttachments(values.projectRef, uploadedFiles) : [] const selectedLibrary = values.library ? CLIENT_LIBRARIES.find((library) => library.language === values.library) : undefined const payload = { ...values, - organizationSlug: values.organizationSlug === 'no-org' ? undefined : values.organizationSlug, - allowSupportAccess: ALLOW_SUPPORT_ACCESS_CATEGORIES.includes(values.category) + organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, + projectRef: values.projectRef ?? NO_PROJECT_MARKER, + allowSupportAccess: SUPPORT_ACCESS_CATEGORIES.includes(values.category) ? values.allowSupportAccess : false, library: - values.category === 'Problem' && selectedLibrary !== undefined ? selectedLibrary.key : '', - message: formatMessage(values.message, attachments, error), + values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined + ? selectedLibrary.key + : '', + message: formatMessage(values.message, attachments, initialError), verified: true, tags: ['dashboard-support-form'], siteUrl: '', additionalRedirectUrls: '', - affectedServices: values.affectedServices - .split(',') - .map((x) => x.trim().replace(/ /g, '_').toLowerCase()) - .join(';'), + affectedServices: CATEGORIES_WITHOUT_AFFECTED_SERVICES.includes(values.category) + ? '' + : values.affectedServices + .split(',') + .map((x) => x.trim().replace(/ /g, '_').toLowerCase()) + .join(';'), browserInformation: detectBrowser(), - ...(dashboardSentryIssueId && { dashboardSentryIssueId }), } - if (values.projectRef !== 'no-project') { + if (values.projectRef !== NO_PROJECT_MARKER) { try { - const authConfig = await getProjectAuthConfig({ projectRef: values.projectRef }) + const authConfig = await getProjectAuthConfig({ + projectRef: values.projectRef, + }) payload.siteUrl = authConfig.SITE_URL payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST - } catch (error) { + } catch { // [Joshen] No error handler required as fetching these info are nice to haves, not necessary } } @@ -317,674 +118,58 @@ export const SupportFormV2 = ({ submitSupportTicket(payload) } - useEffect(() => { - if (subject !== urlSubject && subject.trim().length > 0) { - handleDocsSearchDebounced(subject.trim()) - } else { - setDocsResults([]) - } - }, [subject, urlSubject]) - - useEffect(() => { - if (subject.trim().length > 0 && searchState.status === 'fullResults') { - setDocsResults(searchState.results) - } else if (searchState.status === 'noResults' || searchState.status === 'error') { - setDocsResults([]) - } - }, [searchState]) - - useEffect(() => { - if (!uploadedFiles) return - const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file)) - setUploadedDataUrls(objectUrls) - - return () => { - objectUrls.forEach((url: any) => URL.revokeObjectURL(url)) - } - }, [uploadedFiles]) - - useEffect(() => { - // For prefilling form fields via URL, project ref will taking higher precedence than org slug - const prefillForm = async () => { - if (isSuccessOrganizations) { - if (organizations.length === 0) { - form.setValue('organizationSlug', 'no-org') - } else if (urlRef && urlRef !== 'no-project') { - // Check validity of project via project details - const selectedProject = await getProjectDetail({ ref: urlRef }) - if (!!selectedProject) { - const org = organizations.find((x) => x.id === selectedProject.organization_id) - if (!!org) form.setValue('organizationSlug', org.slug) - form.setValue('projectRef', selectedProject.ref) - } - } else if (urlSlug) { - if (organizations.some((it) => it.slug === urlSlug)) { - form.setValue('organizationSlug', urlSlug) - } - } else if (!urlRef && !urlSlug) { - const firstOrganization = organizations?.[0] - if (!!firstOrganization) { - form.setValue('organizationSlug', firstOrganization.slug) - } - } - } - } - prefillForm() - }, [urlRef, urlSlug, isSuccessOrganizations]) - - useEffect(() => { - if (urlCategory) { - const validCategory = categoryOptions.find((option) => { - if (option.value.toLowerCase() === ((urlCategory as string) ?? '').toLowerCase()) - return option - }) - if (validCategory !== undefined) form.setValue('category', validCategory.value) - } - }, [urlCategory]) + const handleFormSubmit = form.handleSubmit(onSubmit) - useEffect(() => { - if (urlSubject) form.setValue('subject', urlSubject) - }, [urlSubject]) - - useEffect(() => { - if (urlMessage) form.setValue('message', urlMessage) - }, [urlMessage]) - - // Sync organization selection with parent state - // Initialized as 'no-org' in parent if no org is selected - useEffect(() => { - setSelectedOrganization(organizationSlug) - }, [organizationSlug, setSelectedOrganization]) - - // Sync project selection with parent state - // Initialized as 'no-project' in parent if no project is selected - useEffect(() => { - setSelectedProject(projectRef) - }, [projectRef, setSelectedProject]) + const handleSubmitButtonClick: MouseEventHandler = (event) => { + handleFormSubmit(event) + } return ( -
-

How can we help?

- - ( - - - - - -
- {organizationSlug === 'no-org' ? ( - No specific organization - ) : ( - (organizations ?? []).find((o) => o.slug === field.value)?.name - )} - {subscriptionPlanId && ( - - {subscriptionPlanId} - - )} -
-
-
- - - {organizations?.map((org) => ( - - {org.name} - - ))} - {isSuccessOrganizations && (organizations ?? []).length === 0 && ( - - No specific organization - - )} - - -
-
-
- )} - /> - -
- ( - - - { - if (!urlRef) field.onChange(projects[0]?.ref ?? 'no-project') - }} - onSelect={(project) => field.onChange(project.ref)} - renderTrigger={({ isLoading, project }) => ( - - )} - renderActions={(setOpen) => ( - - { - field.onChange('no-project') - setOpen(false) - }} - > - {field.value === 'no-project' && } -

- No specific project -

-
-
- )} - /> -
-
- )} - /> - - - {projectRef !== 'no-project' && ( - -

- Project ID:{' '} - - {projectRef} - -

- { - toast.success('Copied to clipboard') - setHighlightRef(null) - }} - /> -
- )} -
- - {organizationSlug && - subscriptionPlanId !== 'enterprise' && - category !== 'Login_issues' && ( - - )} -
- -
- ( - - - - - - {field.value - ? categoryOptions.find((o) => o.value === field.value)?.label - : null} - - - - - {categoryOptions.map((option) => ( - - {option.label} - - {option.description} - - - ))} - - - - - - )} + +

How can we help?

+ +
+ + - ( - - - - - - {field.value} - - - - - {SEVERITY_OPTIONS.map((option) => ( - - {option.label} - - {option.description} - - - ))} - - - - - - )} + - - - - {(severity === 'Urgent' || severity === 'High') && ( -

- We do our best to respond to everyone as quickly as possible; however, prioritization - will be based on production status. We ask that you reserve High and Urgent severity - for production-impacting issues only. -

- )}
- + -
- ( - - - - - - )} - /> +
+ + + + + +
- {searchState.status === 'loading' && docsResults.length === 0 && ( -
- - Searching for relevant resources... -
- )} + - {docsResults.length > 0 && hasResults && ( +
+ {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( <> -
-
AI Suggested resources
- {searchState.status === 'loading' && ( -
- - Updating results... -
- )} -
- -
    - {docsResults.slice(0, 5).map((page, i) => { - return ( -
  • - {page.type === 'github-discussions' ? ( - - ) : ( - - )} - - {page.title} - -
  • - ) - })} -
+ + )} - - {form.getValues('subject').length > 0 && INCLUDE_DISCUSSIONS.includes(category) && ( -

- Check our - - GitHub discussions - - - for a quick answer -

- )} -
- - {category === 'Problem' && showClientLibraries && ( - ( - - - - - - - - - {CLIENT_LIBRARIES.map((option) => ( - - {option.language} - - ))} - - - - - - )} - /> - )} - - {library && library.length > 0 && } - - {category !== 'Login_issues' && category !== 'Plan_upgrade' && ( - ( - - - form.setValue('affectedServices', services.join(', '))} - /> - - - )} - /> - )} - - ( - field.value.includes(str)) && ( - - ) - } - > - - - - {error !== undefined && ( - - )} - - )} - /> - -
-
-

Attachments

-

- Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that - you're facing -

-
- -
- {uploadedDataUrls.map((x: any, idx: number) => ( -
-
removeUploadedFile(idx)} - > - -
-
- ))} - {uploadedFiles.length < MAX_ATTACHMENTS && ( -
{ - if (uploadButtonRef.current) (uploadButtonRef.current as any).click() - }} - > - -
- )} -
-
- - - - {ALLOW_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( - <> - { - return ( - - - Allow support access to your project - - Recommended -
- } - description={ -
- - Human support and AI diagnostic access. - - - - - More information - - -

- 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. -

-

- We are committed to maintaining strict data privacy and security - standards in all support activities.{' '} - - Privacy Policy - -

-
-
-
- } - > - - - ) - }} - /> - - - )} - -
- -
-
- We will contact you at - {respondToEmail} -
- - Please ensure emails from supabase.io are allowed - -
diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx new file mode 100644 index 0000000000000..b166b2afb8cbe --- /dev/null +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -0,0 +1,1270 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +// End of third-party imports + +import { API_URL } from 'lib/constants' +import { HttpResponse, http } from 'msw' +import { createMockOrganization, createMockProject } from 'tests/helpers' +import { customRender } from 'tests/lib/custom-render' +import { addAPIMock, mswServer } from 'tests/lib/msw' +import { createMockProfileContext } from 'tests/lib/profile-helpers' +import { NO_ORG_MARKER, NO_PROJECT_MARKER } from '../SupportForm.utils' +import { SupportFormPage } from '../SupportFormPage' + +type Screen = typeof screen + +const mockOrganizations = [ + createMockOrganization({ + id: 1, + slug: 'org-1', + name: 'Organization 1', + plan: { id: 'free', name: 'Free' }, + }), + createMockOrganization({ + id: 2, + slug: 'org-2', + name: 'Organization 2', + plan: { id: 'pro', name: 'Pro' }, + }), +] + +const mockProjects = [ + { + ...createMockProject({ + id: 1, + ref: 'project-1', + name: 'Project 1', + organization_id: 1, + }), + organization_slug: 'org-1', + preview_branch_refs: [], + }, + { + ...createMockProject({ + id: 2, + ref: 'project-2', + name: 'Project 2', + organization_id: 2, + }), + organization_slug: 'org-2', + preview_branch_refs: [], + }, + { + ...createMockProject({ + id: 3, + ref: 'project-3', + name: 'Project 3', + organization_id: 1, + }), + organization_slug: 'org-1', + preview_branch_refs: [], + }, +] + +vi.mock('react-inlinesvg', () => ({ + __esModule: true, + default: () => null, +})) + +// Mock the support storage client module - will be configured per test +vi.mock('../support-storage-client', () => ({ + createSupportStorageClient: vi.fn(), +})) + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock(import('common'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useParams: vi.fn().mockReturnValue({ ref: 'default' }), + useIsLoggedIn: vi.fn().mockReturnValue(true), + isFeatureEnabled: vi.fn((feature: any, disabledFeatures: any) => { + if (typeof feature === 'string') { + if (feature === 'support:show_client_libraries') { + return true + } + return (actual as any).isFeatureEnabled(feature, disabledFeatures) + } + + if (Array.isArray(feature)) { + const result = (actual as any).isFeatureEnabled(feature, disabledFeatures) + if (feature.includes('support:show_client_libraries')) { + if (result && typeof result === 'object') { + return { + ...result, + 'support:show_client_libraries': true, + } + } + } + return result + } + + return (actual as any).isFeatureEnabled(feature, disabledFeatures) + }), + } +}) + +vi.mock(import('lib/gotrue'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + auth: { + ...(actual.auth as any), + onAuthStateChange: vi.fn(), + }, + } +}) + +const renderSupportFormPage = (options?: Parameters[1]) => + customRender(, { + profileContext: createMockProfileContext(), + ...options, + }) + +const getStatusLink = (screen: Screen) => { + const statusLink = screen + .getAllByRole('link') + .find((el) => el.getAttribute('href') === 'https://status.supabase.com/') + expect(statusLink).toBeDefined() + return statusLink +} + +const getOrganizationSelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select an organization' }) + +const getProjectSelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select a project' }) + +const getSummaryField = (screen: Screen) => screen.getByPlaceholderText(/summary of the problem/i) + +const getMessageField = (screen: Screen) => screen.getByPlaceholderText(/describe the issue/i) + +const getCategorySelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select an issue' }) + +const getSubmitButton = (screen: Screen) => + screen.getByRole('button', { name: 'Send support request' }) + +const selectCategoryOption = async (screen: Screen, optionLabel: string) => { + await userEvent.click(getCategorySelector(screen)) + const option = await screen.findByRole('option', { + name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), + }) + await userEvent.click(option) +} + +const getSeveritySelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select a severity' }) + +const selectSeverityOption = async (screen: Screen, optionLabel: string) => { + await userEvent.click(getSeveritySelector(screen)) + const option = await screen.findByRole('option', { + name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), + }) + await userEvent.click(option) +} + +const getLibrarySelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select a library' }) + +const selectLibraryOption = async (screen: Screen, optionLabel: string) => { + // await waitFor(() => { + // expect(() => getLibrarySelector(screen)).not.toThrow() + // }) + const selector = getLibrarySelector(screen) + await userEvent.click(selector) + const option = await screen.findByRole('option', { + name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), + }) + await userEvent.click(option) +} + +const getSupportForm = () => { + const form = document.querySelector('form#support-form') + expect(form).not.toBeNull() + return form! +} + +const getAttachmentFileInput = () => { + const input = getSupportForm().querySelector( + 'input[type="file"][accept*="image"]' + ) + expect(input).not.toBeNull() + return input! +} + +const getAttachmentRemoveButtons = (screen: Screen) => + screen.queryAllByRole('button', { name: 'Remove attachment' }) + +const createDeferred = () => { + let resolve!: () => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +const originalUserAgent = window.navigator.userAgent + +describe('SupportFormPage', () => { + afterEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }) + }) + + beforeEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { + value: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }) + + Object.defineProperty(window, 'location', { + value: { search: '' }, + writable: true, + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: mockOrganizations, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects', + response: mockProjects, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: mockProjects[0], + }) + + addAPIMock({ + method: 'get', + path: '/platform/status', + response: { is_healthy: true } as any, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: { SITE_URL: 'https://supabase.com', URI_ALLOW_LIST: '' } as any, + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: ({ params, request }) => { + const slug = (params as { slug: string }).slug + const projects = mockProjects.filter((project) => project.organization_slug === slug) + + const url = new URL(request.url) + const limit = Number(url.searchParams.get('limit') ?? projects.length) + const offset = Number(url.searchParams.get('offset') ?? 0) + const sort = url.searchParams.get('sort') ?? 'name_asc' + + const sorted = [...projects].sort((a, b) => { + switch (sort) { + case 'name_desc': + return b.name.localeCompare(a.name) + default: + return a.name.localeCompare(b.name) + } + }) + + const paginated = sorted.slice(offset, offset + limit) + + return HttpResponse.json({ + projects: paginated, + pagination: { + count: projects.length, + limit, + offset, + }, + }) + }, + }) + + mswServer.use( + http.get('http://localhost:3000/img/supabase-logo.svg', () => HttpResponse.text('')) + ) + }) + + test('shows system status: healthy', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('All systems operational') + }) + }) + + test('shows system status: not healthy', async () => { + addAPIMock({ + method: 'get', + path: '/platform/status', + response: { is_healthy: false } as any, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('Active incident ongoing') + }) + }) + + test('shows system status: check failed', async () => { + mswServer.use( + http.get(`${API_URL}/platform/status`, () => HttpResponse.json(null, { status: 500 })) + ) + + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('Failed to check status') + }) + }) + + test('loading a URL with a valid project slug prefills the organization and project', async () => { + Object.defineProperty(window, 'location', { + value: { search: '?projectRef=project-3' }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(screen.getByRole('combobox', { name: 'Select a project' })).toHaveTextContent( + 'Project 3' + ) + }) + }) + + test('loading a URL with no project slug falls back to first organization and project', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + }) + + test('loading a URL with an invalid project slug falls back to first organization and project', async () => { + mswServer.use( + http.get(`${API_URL}/platform/projects/:ref`, () => HttpResponse.json(null, { status: 404 })) + ) + Object.defineProperty(window, 'location', { + value: { search: '?projectRef=project-nonexistent' }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + }) + + test('loading a URL with a message prefills the message field', async () => { + const testMessage = 'This is a test support message from URL' + Object.defineProperty(window, 'location', { + value: { search: `?message=${encodeURIComponent(testMessage)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getMessageField(screen)).toHaveValue(testMessage) + }) + }) + + test('loading a URL with a subject prefills the subject field', async () => { + const testSubject = 'Test Subject' + Object.defineProperty(window, 'location', { + value: { search: `?subject=${encodeURIComponent(testSubject)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + const subjectField = getSummaryField(screen) + expect(subjectField).toHaveValue(testSubject) + }) + }) + + test('loading a URL with a category prefills the category field', async () => { + const testCategory = 'Problem' + Object.defineProperty(window, 'location', { + value: { search: `?category=${encodeURIComponent(testCategory)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + }) + }) + + test('loading a URL with a category prefills the category field (case-insensitive)', async () => { + const testCategory = 'dashboard_bug' + Object.defineProperty(window, 'location', { + value: { search: `?category=${encodeURIComponent(testCategory)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + }) + + test('loading a URL with an invalid category gracefully falls back', async () => { + const testCategory = 'Invalid' + Object.defineProperty(window, 'location', { + value: { search: `?category=${encodeURIComponent(testCategory)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Select an issue') + }) + }) + + test('loading a URL with multiple initial fields fills them all in', async () => { + const testCategory = 'Problem' + const testSubject = 'Test Subject' + Object.defineProperty(window, 'location', { + value: { + search: `?category=${encodeURIComponent(testCategory)}&subject=${encodeURIComponent(testSubject)}`, + }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + expect(getSummaryField(screen)).toHaveValue(testSubject) + }) + }) + + test('includes Sentry issue ID from URL in submission payload', async () => { + const sentryIssueId = 'mock-sentry-id' + + const submitSpy = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + Object.defineProperty(window, 'location', { + value: { search: `?sid=${encodeURIComponent(sentryIssueId)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Dashboard stopped loading') + await userEvent.type(getMessageField(screen), 'The dashboard page loads blank after login') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + expect(submitSpy.mock.calls[0]?.[0]?.dashboardSentryIssueId).toBe(sentryIssueId) + }) + + test('includes initial error message from URL in submission payload', async () => { + const initialError = 'failed to fetch user data' + const messageBody = 'The dashboard page loads blank after login' + + const submitSpy = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + Object.defineProperty(window, 'location', { + value: { search: `?error=${encodeURIComponent(initialError)}` }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Dashboard stopped loading') + await userEvent.type(getMessageField(screen), messageBody) + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload?.message).toMatch(initialError) + }) + + test('submits support request with problem category, library, and affected services', async () => { + const submitSpy = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.example.com`, + URI_ALLOW_LIST: `https://${ref}.example.com/callbacks`, + } as any) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'APIs and client libraries') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + }) + + await selectSeverityOption(screen, 'High') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('High') + }) + + await selectLibraryOption(screen, 'JavaScript') + await waitFor(() => { + expect(getLibrarySelector(screen)).toHaveTextContent('Javascript') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'API requests failing in production') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'Requests return status 500 when calling the RPC endpoint') + + const supportAccessToggle = screen.getByRole('switch', { + name: /allow support access to your project/i, + }) + expect(supportAccessToggle).toBeChecked() + await userEvent.click(supportAccessToggle) + expect(supportAccessToggle).not.toBeChecked() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'API requests failing in production', + message: 'Requests return status 500 when calling the RPC endpoint', + category: 'Problem', + severity: 'High', + projectRef: 'project-1', + organizationSlug: 'org-1', + library: 'javascript', + affectedServices: '', + allowSupportAccess: false, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-1.example.com', + additionalRedirectUrls: 'https://project-1.example.com/callbacks', + browserInformation: 'Chrome', + }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }) + + test('submits urgent login issues ticket for a different organization', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.supabase.dev`, + URI_ALLOW_LIST: `https://${ref}.supabase.dev/redirect`, + } as any) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await userEvent.click(getOrganizationSelector(screen)) + await userEvent.click(await screen.findByRole('option', { name: 'Organization 2' })) + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') + expect(getProjectSelector(screen)).toHaveTextContent('Project 2') + }) + + await selectCategoryOption(screen, 'Issues with logging in') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Issues with logging in') + }) + + await selectSeverityOption(screen, 'Urgent') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('Urgent') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'Cannot log in to dashboard') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'MFA challenge fails with an unknown error code') + + expect( + screen.queryByRole('switch', { name: /allow support access to your project/i }) + ).toBeNull() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Cannot log in to dashboard', + message: 'MFA challenge fails with an unknown error code', + category: 'Login_issues', + severity: 'Urgent', + projectRef: 'project-2', + organizationSlug: 'org-2', + library: '', + affectedServices: '', + allowSupportAccess: false, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-2.supabase.dev', + additionalRedirectUrls: 'https://project-2.supabase.dev/redirect', + browserInformation: 'Chrome', + }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }) + + test('submits database unresponsive ticket with initial error', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.apps.supabase.co`, + URI_ALLOW_LIST: `https://${ref}.apps.supabase.co/auth`, + } as any) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: ({ params }) => { + const { ref } = params as { ref: string } + const project = mockProjects.find((candidate) => candidate.ref === ref) + return project ? HttpResponse.json(project) : HttpResponse.json(null, { status: 404 }) + }, + }) + + Object.defineProperty(window, 'location', { + value: { search: '?projectRef=project-3&error=Connection timeout detected' }, + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('Project 3') + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Database unresponsive') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') + }) + + await selectSeverityOption(screen, 'Normal') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('Normal') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'Database unreachable after upgrade') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'Connections time out after 30 seconds') + + const supportAccessToggle = screen.getByRole('switch', { + name: /allow support access to your project/i, + }) + expect(supportAccessToggle).toBeChecked() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Database unreachable after upgrade', + category: 'Database_unresponsive', + severity: 'Normal', + projectRef: 'project-3', + organizationSlug: 'org-1', + library: '', + affectedServices: '', + allowSupportAccess: true, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-3.apps.supabase.co', + additionalRedirectUrls: 'https://project-3.apps.supabase.co/auth', + browserInformation: 'Chrome', + }) + expect(payload.message).toBe( + 'Connections time out after 30 seconds\nError: Connection timeout detected' + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }) + + test('when organization changes, project selector updates to match', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await userEvent.click(getOrganizationSelector(screen)) + await userEvent.click(screen.getByRole('option', { name: 'Organization 2' })) + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') + }) + + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('Project 2') + }) + }) + + test('AI Assistant suggestion displays when valid project and organization are selected', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(screen.getByText('Try the AI Assistant')).toBeInTheDocument() + }) + }) + + test('can upload attachments', async () => { + const url = URL as unknown as { + createObjectURL?: (obj: Blob) => string + revokeObjectURL?: (url: string) => void + } + const originalCreateObjectURL = url.createObjectURL + const originalRevokeObjectURL = url.revokeObjectURL + + let urlIndex = 0 + const createObjectURLMock = vi.fn(() => { + urlIndex += 1 + return `blob:mock-url-${urlIndex}` + }) + const revokeObjectURLMock = vi.fn() + url.createObjectURL = createObjectURLMock + url.revokeObjectURL = revokeObjectURLMock + + let unmount: (() => void) | undefined + try { + const renderResult = renderSupportFormPage() + unmount = renderResult.unmount + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + const fileInput = getAttachmentFileInput() + const firstFile = new File(['first file'], 'first.png', { type: 'image/png' }) + const secondFile = new File(['second file'], 'second.jpg', { type: 'image/jpeg' }) + await userEvent.upload(fileInput, [firstFile, secondFile]) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) + }) + expect(createObjectURLMock).toHaveBeenCalledTimes(2) + + const firstRemoveButton = getAttachmentRemoveButtons(screen)[0] + await userEvent.click(firstRemoveButton) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(1) + }) + expect(revokeObjectURLMock).toHaveBeenCalled() + + const thirdFile = new File(['third file'], 'third.png', { type: 'image/png' }) + await userEvent.upload(getAttachmentFileInput(), thirdFile) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) + }) + + expect(createObjectURLMock).toHaveBeenCalled() + } finally { + unmount?.() + url.createObjectURL = originalCreateObjectURL + url.revokeObjectURL = originalRevokeObjectURL + } + }) + + test('cannot submit form again while it is submitting', async () => { + const submission = createDeferred() + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async () => { + submitSpy() + await submission.promise + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + try { + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + await userEvent.type(getSummaryField(screen), 'Unable to connect to database') + await userEvent.type(getMessageField(screen), 'Connections time out after 30 seconds') + + const submitButton = getSubmitButton(screen) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => { + expect(submitButton).toBeDisabled() + }) + + await userEvent.click(submitButton) + expect(submitSpy).toHaveBeenCalledTimes(1) + } finally { + submission.resolve() + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + } + }) + + test('shows toast on submission error and allows form re-editing and resubmission', async () => { + const submitSpy = vi.fn() + const toastErrorSpy = vi.fn() + const toastSuccessSpy = vi.fn() + + const { toast } = await import('sonner') + vi.mocked(toast.error).mockImplementation(toastErrorSpy) + vi.mocked(toast.success).mockImplementation(toastSuccessSpy) + + const errorMessage = 'Network error: Unable to reach server' + + // First attempt: return an error + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async () => { + return HttpResponse.json({ message: errorMessage }, { status: 500 }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Cannot access settings') + await userEvent.type(getMessageField(screen), 'Settings page shows 500 error') + + const submitButton = getSubmitButton(screen) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(toastErrorSpy).toHaveBeenCalled() + }) + expect(toastErrorSpy.mock.calls[0]?.[0]).toMatch(/Failed to submit support ticket/) + + await waitFor(() => { + expect(submitButton).not.toBeDisabled() + }) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'Settings page shows 500 error - updated description') + + await userEvent.click(submitButton) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.subject).toBe('Cannot access settings') + expect(payload.message).toBe('Settings page shows 500 error - updated description') + + await waitFor(() => { + expect(toastSuccessSpy).toHaveBeenCalledWith('Support request sent. Thank you!') + }) + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }) + + test('submits support request with attachments and includes attachment URLs in message', async () => { + const submitSpy = vi.fn() + + // Mock URL.createObjectURL and revokeObjectURL + const url = URL as unknown as { + createObjectURL?: (obj: Blob) => string + revokeObjectURL?: (url: string) => void + } + const originalCreateObjectURL = url.createObjectURL + const originalRevokeObjectURL = url.revokeObjectURL + + let urlIndex = 0 + const createObjectURLMock = vi.fn(() => { + urlIndex += 1 + return `blob:mock-url-${urlIndex}` + }) + const revokeObjectURLMock = vi.fn() + url.createObjectURL = createObjectURLMock + url.revokeObjectURL = revokeObjectURLMock + + // Mock the storage upload and createSignedUrls endpoints + const signedUrls = [ + 'https://storage.example.com/signed/file1.png?token=abc123', + 'https://storage.example.com/signed/file2.jpg?token=def456', + ] + + const { createSupportStorageClient } = await import('../support-storage-client') + const mockStorageClient = { + storage: { + from: vi.fn(() => ({ + upload: vi.fn(async (path: string) => ({ + data: { Id: path, Key: path, path }, + error: null, + })), + createSignedUrls: vi.fn(async (paths: Array) => ({ + data: paths.map((path, idx) => ({ + signedUrl: signedUrls[idx] || `https://storage.example.com/signed/${path}`, + path, + error: null, + })), + error: null, + })), + })), + }, + } + + vi.mocked(createSupportStorageClient).mockReturnValue(mockStorageClient as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.example.com`, + URI_ALLOW_LIST: `https://${ref}.example.com/auth`, + } as any) + }, + }) + + let unmount: (() => void) | undefined + try { + const renderResult = renderSupportFormPage() + unmount = renderResult.unmount + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Database unresponsive') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') + }) + + await selectSeverityOption(screen, 'High') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('High') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'Query timeouts after maintenance') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type( + messageField, + 'All queries timing out after scheduled maintenance window' + ) + + const fileInput = getAttachmentFileInput() + const firstFile = new File(['screenshot 1'], 'error-screenshot.png', { type: 'image/png' }) + const secondFile = new File(['screenshot 2'], 'logs-screenshot.jpg', { type: 'image/jpeg' }) + await userEvent.upload(fileInput, [firstFile, secondFile]) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) + }) + + const supportAccessToggle = screen.getByRole('switch', { + name: /allow support access to your project/i, + }) + expect(supportAccessToggle).toBeChecked() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Query timeouts after maintenance', + category: 'Database_unresponsive', + severity: 'High', + projectRef: 'project-1', + organizationSlug: 'org-1', + library: '', + affectedServices: '', + allowSupportAccess: true, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-1.example.com', + additionalRedirectUrls: 'https://project-1.example.com/auth', + browserInformation: 'Chrome', + }) + + // Verify that attachment URLs are included in the message + expect(payload.message).toContain('All queries timing out after scheduled maintenance window') + expect(payload.message).toContain(signedUrls[0]) + expect(payload.message).toContain(signedUrls[1]) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + } finally { + unmount?.() + url.createObjectURL = originalCreateObjectURL + url.revokeObjectURL = originalRevokeObjectURL + vi.mocked(createSupportStorageClient).mockReset() + } + }) + + test('can submit form with no organizations and no projects', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: [], + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects', + response: [], + }) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('No specific organization') + }) + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('No specific project') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Cannot access my account') + await userEvent.type(getMessageField(screen), 'I need help accessing my Supabase account') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Cannot access my account', + message: 'I need help accessing my Supabase account', + category: 'Dashboard_bug', + projectRef: NO_PROJECT_MARKER, + organizationSlug: NO_ORG_MARKER, + library: '', + affectedServices: '', + allowSupportAccess: false, + verified: true, + tags: ['dashboard-support-form'], + browserInformation: 'Chrome', + }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Support/support-storage-client.ts b/apps/studio/components/interfaces/Support/support-storage-client.ts new file mode 100644 index 0000000000000..7003da594c273 --- /dev/null +++ b/apps/studio/components/interfaces/Support/support-storage-client.ts @@ -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) => {}, + }, + }, + }) +} diff --git a/apps/studio/components/interfaces/Support/useSupportForm.ts b/apps/studio/components/interfaces/Support/useSupportForm.ts new file mode 100644 index 0000000000000..d00b5d63ca8f6 --- /dev/null +++ b/apps/studio/components/interfaces/Support/useSupportForm.ts @@ -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 = { + organizationSlug: NO_ORG_MARKER, + projectRef: NO_PROJECT_MARKER, + severity: 'Low', + category: undefined, + library: '', + subject: '', + message: '', + affectedServices: '', + allowSupportAccess: true, + dashboardSentryIssueId: '', +} + +interface UseSupportFormResult { + form: UseFormReturn + initialError: string | null + projectRef: string | null + orgSlug: string | null +} + +export function useSupportForm(dispatch: Dispatch): UseSupportFormResult { + const form = useForm({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(SupportFormSchema), + defaultValues: supportFormDefaultValues, + }) + + const urlParamsRef = useRef(null) + const [initialError, setInitialError] = useState(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, + } +} diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx index 5b228f3d299cf..f1a12adb50c09 100644 --- a/apps/studio/components/ui/OrganizationProjectSelector.tsx +++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx @@ -123,7 +123,7 @@ export const OrganizationProjectSelector = ({ return ( - {!!renderTrigger ? ( + {renderTrigger ? ( renderTrigger({ isLoading: isLoadingProjects || isFetching, project: selectedProject }) ) : ( - - - - - - Check Supabase status page - - -
- - -
- {sentCategory !== undefined ? ( - - ) : ( - - )} -
- - -

- Email us directly at{' '} - - support@supabase.com - - toast.success('Copied to clipboard')} - /> -

-

- Please, make sure to{' '} - { - const el = document.getElementById('projectRef-field') - el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }) - setHighlightRef('true') - }} - > - include your project ID - {' '} - and as much information as possible. -

- - } - defaultVisibility={true} - hideCollapse={true} - /> - - - - ) + return } SupportPage.getLayout = (page) => ( diff --git a/apps/studio/tests/helpers.tsx b/apps/studio/tests/helpers.tsx index c4885c9812669..2cfef6e0b9a49 100644 --- a/apps/studio/tests/helpers.tsx +++ b/apps/studio/tests/helpers.tsx @@ -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 => { + 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 => { + 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 = ({ children }) = } type renderParams = Parameters 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 diff --git a/apps/studio/tests/lib/custom-render.tsx b/apps/studio/tests/lib/custom-render.tsx index de287fa76831c..853744a0a31b4 100644 --- a/apps/studio/tests/lib/custom-render.tsx +++ b/apps/studio/tests/lib/custom-render.tsx @@ -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[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 = ( {children} ) + + return profileContext ? ( + {content} + ) : ( + 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, }) diff --git a/apps/studio/tests/lib/profile-helpers.tsx b/apps/studio/tests/lib/profile-helpers.tsx new file mode 100644 index 0000000000000..bb69c4da72bf3 --- /dev/null +++ b/apps/studio/tests/lib/profile-helpers.tsx @@ -0,0 +1,33 @@ +import type { Profile } from 'data/profile/types' +import type { ProfileContextType } from 'lib/profile' + +export const createMockProfile = (overrides: Partial = {}): 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 => { + return { + profile: overrides.profile ?? createMockProfile(), + error: overrides.error ?? null, + isLoading: overrides.isLoading ?? false, + isError: overrides.isError ?? false, + isSuccess: overrides.isSuccess ?? true, + } +}