Files
supabase/apps/studio/components/interfaces/Support/SupportForm.utils.tsx
T
Danny White 3960e6ac41 chore(deps): bump lucide-react to v1 (#51770)
## Problem

Lucide React v1 improves screen reader behaviour (icons are hidden from
assistive tech by default). It also removes brand icons such as GitHub.
We were still on Lucide 0.x across the monorepo.

## Solution

- Move every workspace onto one shared Lucide React v1 version via the
pnpm catalog.
- But Lucide React v1 [deprecated brand
icons](https://github.com/lucide-icons/lucide/blob/main/BRAND_LOGOS_STATEMENT.md)
- Meaning we need to handle any _old_ brand icons we used from Lucide
0.x.
- Luckily, that was just `GitHubIcon`. So...
- Add a `GithubIcon` in `packages/ui` that matches Lucide’s previous
GitHub icon, and use it wherever we imported GitHub from Lucide.
  - No other Lucide brand icons were in use.
- Studio “Continue with GitHub” sign-in still uses the existing SVG
asset (`github-icon.svg`), not this component.
- Keep the `Icon` suffix import style [recently
introduced](https://github.com/supabase/supabase/pull/51659)
- Monorepo apps now use `catalog` Lucide React v1.

## Review instructions

1. Open [Studio
preview](https://studio-staging-git-dnywh-bump-lucide-react-v1-supabase.vercel.app/dashboard/org)
on a project that is connected to GitHub:
   - Project home → Activity stats “GitHub” row
   - Org project list → GitHub badge on a connected project card/row
- Branches → GitHub link on a branch row, or “Sync with a GitHub branch”
   - Project Settings → Integrations → GitHub integration panel
2. Open
[/kb/guides/sample-guide](https://supabase.com/kb/guides/sample-guide) →
confirm “Go to the GitHub discussion” shows the icon.
3. Open
[/docs/reference/javascript](https://supabase.com/docs/reference/javascript)
→ confirm the header GitHub control still shows the icon. Or
[/docs/guides/troubleshooting/rls-simplified-BJTcS8](https://supabase.com/docs/guides/troubleshooting/rls-simplified-BJTcS8)
→ “View discussion on GitHub”.
2026-10-15 23:00:55 +00:00

262 lines
8.4 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 { BookIcon, HashIcon, MessageSquareIcon } from 'lucide-react'
import {
createLoader,
createParser,
createSerializer,
parseAsString,
type inferParserType,
type UseQueryStatesKeysMap,
} from 'nuqs'
import { GithubIcon } from 'ui'
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 <BookIcon strokeWidth={1.5} className="mr-0! w-4! h-4!" />
case PageType.GithubDiscussion:
return <GithubIcon 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 <HashIcon strokeWidth={1.5} className="mr-0! w-4! h-4!" />
case PageType.GithubDiscussion:
return <MessageSquareIcon 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
}