mirror of
https://github.com/supabase/supabase.git
synced 2026-06-20 22:06:04 +08:00
Refactors our help sidebar within Studio to include the actual support form itself when contact is selected. This PR also cleans up the initial state of the sidebar and the options within. ## To test: - Open an org and click the help icon top right - Click contact support - Submit a support ticket - Click done to return to support sidebar state <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Support form V3 and support sidebar with status button; direct-email helper and URL prefill * Success screen supports onFinish callback and customizable finish label * AI Assistant and Help options accept optional click callbacks; resource items gain keyboard/accessibility support * **Refactor** * Help panel split into home/support views with back navigation * Support components accept flexible align/className props and layout/styling tweaks * Initial URL params loader added for support form * **Tests** * New/updated tests for support flows, success screen, and help options interactions <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
209 lines
6.3 KiB
TypeScript
209 lines
6.3 KiB
TypeScript
// End of third-party imports
|
|
|
|
import {
|
|
DocsSearchResultType as PageType,
|
|
type DocsSearchResult as Page,
|
|
type DocsSearchResultSection as PageSection,
|
|
} from 'common'
|
|
import dayjs from 'dayjs'
|
|
import { partition } from 'lodash'
|
|
import { Book, Github, Hash, MessageSquare } from 'lucide-react'
|
|
import {
|
|
createLoader,
|
|
createParser,
|
|
createSerializer,
|
|
parseAsString,
|
|
type inferParserType,
|
|
type UseQueryStatesKeysMap,
|
|
} from 'nuqs'
|
|
|
|
import { CATEGORY_OPTIONS } from './Support.constants'
|
|
import { getProjectDetail } from '@/data/projects/project-detail-query'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
import type { Organization } from '@/types'
|
|
|
|
export const NO_PROJECT_MARKER = 'no-project'
|
|
export const NO_ORG_MARKER = 'no-org'
|
|
|
|
export const formatMessage = ({
|
|
message,
|
|
attachments = [],
|
|
error,
|
|
}: {
|
|
message: string
|
|
attachments?: Array<string>
|
|
error: string | null | undefined
|
|
}) => {
|
|
const [harFiles, images] = partition(attachments, (x) => x.split('?token')[0].endsWith('.har'))
|
|
const errorString = error != null ? `\n\nError: ${error}` : ''
|
|
|
|
const imagesString = images.length > 0 ? `\n\nImage Attachments:\n${images.join('\n\n')}` : ''
|
|
const harFilesString = harFiles.length > 0 ? `\n\nHAR Files:\n${harFiles.join('\n\n')}` : ''
|
|
|
|
return `${message}${errorString}${imagesString}${harFilesString}`
|
|
}
|
|
|
|
export const formatStudioVersion = (commit: { commitSha: string; commitTime: string }): string => {
|
|
const formattedTime =
|
|
commit.commitTime === 'unknown'
|
|
? 'unknown time'
|
|
: dayjs(commit.commitTime).format('YYYY-MM-DD HH:mm:ss Z')
|
|
return `SHA ${commit.commitSha} deployed at ${formattedTime}`
|
|
}
|
|
|
|
export function getPageIcon(page: Page) {
|
|
switch (page.type) {
|
|
case PageType.Markdown:
|
|
case PageType.Reference:
|
|
case PageType.Integration:
|
|
return <Book strokeWidth={1.5} className="mr-0! w-4! h-4!" />
|
|
case PageType.GithubDiscussion:
|
|
return <Github strokeWidth={1.5} className="mr-0! w-4! h-4!" />
|
|
default:
|
|
throw new Error(`Unknown page type '${page.type}'`)
|
|
}
|
|
}
|
|
|
|
export function getPageSectionIcon(page: Page) {
|
|
switch (page.type) {
|
|
case PageType.Markdown:
|
|
case PageType.Reference:
|
|
case PageType.Integration:
|
|
return <Hash strokeWidth={1.5} className="mr-0! w-4! h-4!" />
|
|
case PageType.GithubDiscussion:
|
|
return <MessageSquare strokeWidth={1.5} className="mr-0! w-4! h-4!" />
|
|
default:
|
|
throw new Error(`Unknown page type '${page.type}'`)
|
|
}
|
|
}
|
|
|
|
export function generateLink(pageType: PageType, link: string): string {
|
|
switch (pageType) {
|
|
case PageType.Markdown:
|
|
case PageType.Reference:
|
|
return `${DOCS_URL}${link}`
|
|
case PageType.Integration:
|
|
return `https://supabase.com${link}`
|
|
case PageType.GithubDiscussion:
|
|
return link
|
|
default:
|
|
throw new Error(`Unknown page type '${pageType}'`)
|
|
}
|
|
}
|
|
|
|
export function formatSectionUrl(page: Page, section: PageSection): string {
|
|
switch (page.type) {
|
|
case PageType.Markdown:
|
|
case PageType.GithubDiscussion:
|
|
return `${generateLink(page.type, page.path)}#${section.slug ?? ''}`
|
|
case PageType.Reference:
|
|
return `${generateLink(page.type, page.path)}/${section.slug ?? ''}`
|
|
case PageType.Integration:
|
|
return generateLink(page.type, page.path) // Assuming no section slug for Integration pages
|
|
default:
|
|
throw new Error(`Unknown page type '${page.type}'`)
|
|
}
|
|
}
|
|
|
|
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(''),
|
|
orgSlug: parseAsString.withDefault(''),
|
|
category: parseAsCategoryOption,
|
|
subject: parseAsString.withDefault(''),
|
|
message: parseAsString.withDefault(''),
|
|
error: parseAsString,
|
|
/** Sentry event ID */
|
|
sid: parseAsString,
|
|
} satisfies UseQueryStatesKeysMap
|
|
export type SupportFormUrlKeys = inferParserType<typeof supportFormUrlState>
|
|
|
|
export const loadSupportFormInitialParams = createLoader(supportFormUrlState)
|
|
|
|
export function loadSupportFormInitialParamsFromObject(
|
|
initialParams: Partial<SupportFormUrlKeys>
|
|
): SupportFormUrlKeys {
|
|
const normalizedParams = Object.fromEntries(
|
|
Object.entries(initialParams).flatMap(([key, value]) =>
|
|
value == null ? [] : [[key, String(value)]]
|
|
)
|
|
)
|
|
|
|
return loadSupportFormInitialParams(normalizedParams)
|
|
}
|
|
|
|
const serializeSupportFormInitialParams = createSerializer(supportFormUrlState)
|
|
|
|
export function createSupportFormUrl(initialParams: Partial<SupportFormUrlKeys>) {
|
|
const serializedParams = serializeSupportFormInitialParams(initialParams)
|
|
const query = serializedParams && serializedParams !== '?' ? serializedParams : ''
|
|
return `/support/new${query}`
|
|
}
|
|
|
|
/**
|
|
* Determines which organization to select based on combination of:
|
|
* - Selected project (if any)
|
|
* - URL param (if any)
|
|
* - Fallback
|
|
*/
|
|
export async function selectInitialOrgAndProject({
|
|
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,
|
|
}
|
|
}
|