Files
supabase/apps/studio/components/interfaces/Support/SupportForm.utils.tsx
T
Monica KhouryandAli Waseem fff5928056 fix: scroll to required fields on support form submit (#49147)
Fixes FE-4130. 

## What is the current behavior?

When submitting the support form with required fields missing, the form
does not scroll to or focus the required empty field. Users have to
manually find which field they missed.

## What is the new behavior?

On submit, the form automatically scrolls to and focuses the first
required field that's missing a value - including "What issue are you
having?" and "Which library are you having issues with?".

## Additional context

The scroll wasn't working due to a Chrome bug where scrollIntoView is
blocked when overflow-x: hidden and overflow-y: auto are on the same
element (the sidebar scroll container). The fix manually walks the DOM
to find the scrollable parent and calls scrollTo() directly. Dropdown
fields (Radix Selects) were also unfindable via the usual name
attribute, so data-support-field attributes are used as a stable DOM
hook for those.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Bug Fixes

* Improved support form validation by automatically scrolling to the
first invalid or missing required field.
* Added smooth scrolling and focus behavior to help users quickly
correct form errors.
* Ensured the client library field is brought into view when required
information is missing.
* Improved field targeting for category and client library validation
messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-08-18 15:27:01 +03:00

258 lines
8.0 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(''),
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,
}
}
// 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
}