mirror of
https://github.com/supabase/supabase.git
synced 2026-10-19 16:19:10 +03:00
## 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”.
262 lines
8.4 KiB
TypeScript
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
|
|
}
|