mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
## Context This is largely just a mechanical change here: Opting to use lucide icons with the `Icon` suffix across all icon imports within `studio` - so that it’s clearer which components are icons + reduces the possibility of name collision with our own components - e.g <User /> vs <UserIcon /> Also added a eslint rule for this: <img width="1069" height="167" alt="image" src="https://github.com/user-attachments/assets/7f36f443-5ca2-4bf6-8d22-86e774513bfc" /> And updated `AGENTS.md` as such: ``` Icons come from `lucide-react`, always imported with the `Icon` suffix (`import { CheckIcon, Trash2Icon } from 'lucide-react'`) — never the bare names (`Check`) or `Check as CheckIcon` aliases (lint-enforced). ```
261 lines
8.3 KiB
TypeScript
261 lines
8.3 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, GithubIcon, HashIcon, MessageSquareIcon } 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 <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
|
|
}
|