Files
supabase/apps/studio/components/interfaces/Support/useSupportForm.ts
T
Danny WhiteandJoshen Lim 78dc739901 fix(studio): show support inline and honour AlertError layouts (#51377)
## 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>
2026-10-08 16:04:32 +11:00

145 lines
4.8 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useRef, useState, type Dispatch } from 'react'
import { useForm, useWatch, type DefaultValues, type UseFormReturn } from 'react-hook-form'
import { SupportFormSchema, type SupportFormValues } from './SupportForm.schema'
import type { SupportFormActions } from './SupportForm.state'
import {
loadSupportFormInitialParams,
loadSupportFormInitialParamsFromObject,
NO_ORG_MARKER,
NO_PROJECT_MARKER,
selectInitialOrgAndProject,
type SupportFormUrlKeys,
} from './SupportForm.utils'
// End of third-party imports
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
const supportFormDefaultValues: DefaultValues<SupportFormValues> = {
organizationSlug: NO_ORG_MARKER,
projectRef: NO_PROJECT_MARKER,
severity: 'Low',
category: undefined,
library: '',
subject: '',
message: '',
affectedServices: '',
allowSupportAccess: true,
attachDashboardLogs: true,
dashboardSentryIssueId: '',
}
interface UseSupportFormResult {
form: UseFormReturn<SupportFormValues>
initialError: string | null
projectRef: string | null
orgSlug: string | null
}
export function useSupportForm(
dispatch: Dispatch<SupportFormActions>,
initialParams?: Partial<SupportFormUrlKeys>
): UseSupportFormResult {
const form = useForm<SupportFormValues>({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(SupportFormSchema),
defaultValues: supportFormDefaultValues,
})
const urlParamsRef = useRef<SupportFormUrlKeys | null>(null)
const providedInitialParamsRef = useRef(initialParams)
const [initialError, setInitialError] = useState<string | null>(null)
// Load initial values from URL params after mount so SSR/SSG render with
// bare defaults (no `window` access) and the client hydrates against the
// same HTML. URL-derived values are applied here, post-hydration.
useEffect(() => {
const params =
providedInitialParamsRef.current !== undefined
? loadSupportFormInitialParamsFromObject(providedInitialParamsRef.current)
: loadSupportFormInitialParams(window.location.search)
urlParamsRef.current = params
setInitialError(params.errorMessage ?? 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, isPending: 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 ?? null
selectInitialOrgAndProject({
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,
}
}