mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## Problem AlertError forces a vertical layout whenever additional actions are supplied, even when the caller explicitly requests horizontal or responsive layout. Stripe Sync Engine's uninstall error is one affected call site, the other was Pipelines as demoed in #51311. ## Solution Honour an explicit layout. Preserve the existing defaults: vertical with additional actions, responsive otherwise. Replace the standalone Contact support action with an InlineLink in the contact support prose, preserving support form context and breadcrumb capture. Keep custom actions such as Retry. If instructions are hidden or custom prose omits contact support, retain a separate inline support link. With `hideContactSupport`, show no support link and shorten the default instructions to “Try refreshing your browser.” Custom descriptions remain unchanged. The local Pipelines configuration error explicitly hides support. Add the explicit responsive layout at the Pipelines call site. | Before | After | | --- | --- | | <img width="914" height="426" alt="CleanShot 2026-10-07 at 17 51 20@2x" src="https://github.com/user-attachments/assets/6cbddfee-97f5-4a7a-bafa-7b5dfd6ab8bf" /> | <img width="916" height="422" alt="CleanShot 2026-10-07 at 18 26 06@2x" src="https://github.com/user-attachments/assets/a21952a6-1c1a-437c-af73-c5736f33fd98" /> | | <img width="1566" height="384" alt="CleanShot 2026-10-07 at 18 28 07@2x" src="https://github.com/user-attachments/assets/bdc2a043-421f-4c22-9bd3-37859c6e85c7" /> | <img width="1568" height="308" alt="CleanShot 2026-10-07 at 18 26 58@2x" src="https://github.com/user-attachments/assets/da0f2c6a-6254-4196-944b-5665e87b3c3c" /> | ## Review instructions 1. In a fresh local test project with no existing `stripe` schema, run this in SQL Editor: ```sql begin; create schema stripe; comment on schema stripe is '{"status":"uninstall error","errorMessage":"Local layout test: uninstallation failed"}'; commit; ``` 2. Open **Integrations → Stripe Sync Engine → Overview** and reload. Check **Failed to uninstall Stripe Sync Engine** at wide and narrow widths, including **Retry uninstallation** and the inline **contact support** link. Do not click Retry: it invokes the real uninstall operation. 3. Remove the empty fixture with `drop schema stripe restrict;`. 4. Block the Pipelines source-status request and resize the page: Retry uses the responsive layout. 5. AlertError callers without an explicit layout should retain their existing presentation. Confirm default and custom contact support prose use an inline link, with no standalone support action. Hidden instructions and custom prose without contact support retain an inline fallback. With `hideContactSupport`, the default prose is “Try refreshing your browser.” and no support link appears; custom descriptions remain unchanged. 6. Automated regression coverage checks that an explicit responsive layout survives additional actions, and that hiding support removes the default support wording while preserving custom descriptions. --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
261 lines
8.3 KiB
TypeScript
261 lines
8.3 KiB
TypeScript
// End of third-party imports
|
|
|
|
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
|
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, type ExtendedSupportCategories } 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 DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [
|
|
SupportCategories.ACCOUNT_DELETION,
|
|
SupportCategories.SALES_ENQUIRY,
|
|
SupportCategories.REFUND,
|
|
]
|
|
|
|
export function canAllowSupportAccess(
|
|
category: ExtendedSupportCategories | undefined,
|
|
projectRef: string
|
|
): boolean {
|
|
return (
|
|
!!category &&
|
|
!DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) &&
|
|
projectRef !== NO_PROJECT_MARKER
|
|
)
|
|
}
|
|
|
|
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(''),
|
|
// Not `error`: auth-js treats `?error=` in any URL as a failed auth redirect
|
|
errorMessage: 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 })
|
|
const projectOrg = orgs.find((o) => o.id === projectDetails?.organization_id)
|
|
if (projectOrg?.slug) {
|
|
return {
|
|
projectRef,
|
|
orgSlug: projectOrg.slug,
|
|
}
|
|
}
|
|
} catch {
|
|
// Can safely ignore, fall through to the orgSlug fallback below
|
|
}
|
|
}
|
|
|
|
// Reached when there's no projectRef, or the project lookup above failed to
|
|
// resolve a known org (e.g. the Management API request errored). Returning
|
|
// `projectRef` here (rather than hardcoding `null`) preserves it when we
|
|
// still have one, instead of silently discarding it.
|
|
if (orgSlug) {
|
|
const org = orgs.find((o) => o.slug === orgSlug)
|
|
if (org?.slug) {
|
|
return {
|
|
projectRef,
|
|
orgSlug: org.slug,
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
projectRef: null,
|
|
orgSlug: orgs[0]?.slug ?? null,
|
|
}
|
|
}
|
|
|
|
// Chrome blocks scrollIntoView (triggered by .focus()) when overflow-x: hidden and
|
|
// overflow-y: auto are on the same element. Manually scroll the sidebar instead.
|
|
export function scrollToRequiredField(formId: string, fieldName: string) {
|
|
const form = document.getElementById(formId)
|
|
if (!form) return
|
|
|
|
// Selects use data-support-field since they have no name attr; inputs/textareas use name.
|
|
const el =
|
|
form.querySelector(`[data-support-field="${fieldName}"]`) ??
|
|
form.querySelector(`[name="${fieldName}"]`)
|
|
if (!(el instanceof HTMLElement)) return
|
|
|
|
const scrollContainer = findScrollableParent(el)
|
|
if (scrollContainer) {
|
|
const offset = el.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top - 80
|
|
scrollContainer.scrollTo({ top: scrollContainer.scrollTop + offset, behavior: 'smooth' })
|
|
}
|
|
|
|
el.focus({ preventScroll: true })
|
|
}
|
|
|
|
function findScrollableParent(el: HTMLElement): HTMLElement | null {
|
|
let parent = el.parentElement
|
|
while (parent && parent !== document.body) {
|
|
const { overflowY } = window.getComputedStyle(parent)
|
|
if (overflowY === 'auto' || overflowY === 'scroll') return parent
|
|
parent = parent.parentElement
|
|
}
|
|
return null
|
|
}
|