Files
Monica KhouryandJoshen Lim 4b365eb4ee fix: pass projectRef/orgSlug to support link in table grid error (#50724)
## Summary

Fixes
[FE-3987](https://linear.app/supabase/issue/FE-3987/contact-support-pre-fills-the-wrong-supabase-project-id):
the "Contact support" button shown in the Table Editor's inline error
banner (e.g. "Failed to retrieve rows from table") didn't pass the
current project or organization to the support form. This caused the
support form to fall back to the user's first organization/project
instead of the one actually affected — especially noticeable when the
Management API request used to resolve the org also fails.

## Test plan

  - [ ] Open a project in Studio, go to **Table Editor**, open a table.
- [ ] Trigger a failing table query — either block the `rest/v1/<table>`
request in DevTools, or apply a filter with a mismatched type (e.g. `id
= 'abc'` on an int column).
- [ ] On the inline red "Failed to retrieve rows from table" banner,
click **Contact support**.
- [ ] Confirm the support form pre-fills the **correct organization and
project** — the one the failing table actually belongs to.
- [ ] Repeat with a project belonging to an organization that is *not*
first in your org list, to confirm it's not coincidentally correct.
- [ ] Repeat while simulating a Management API failure (e.g. block
`api.supabase.com`/`*.supabase.co/platform/*`) to confirm the org still
resolves correctly via the `orgSlug` fallback instead of silently
defaulting to your first org.
- [ ] Sanity check other "Contact support" entry points (header Feedback
dropdown, Help sidebar) are unaffected — they use a separate,
already-correct code path.

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

* **Bug Fixes**
* Improved error handling when project details cannot be loaded,
preserving relevant project and organization context.
* Improved fallback behavior for identifying the correct organization
when project information is unavailable or unresolved.
* Support requests opened from error messages now include applicable
project and organization information.
* Error messages now consistently display available additional actions
alongside contact support options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-23 10:02:32 +00:00

260 lines
8.2 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 })
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
}