Files
supabase/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
Danny White 5bfbae22a9 chore(studio + design-system): more flexible Admonition (#45302)
## What kind of change does this PR introduce?

Feature and design-system cleanup. Resolves DEPR-551.

## What is the current behavior?

Admonition supports several overlapping content shapes, but it
previously did not support a first-class success state or
description-only usage cleanly. Title-only usage was also possible,
which made some callouts read like floating headings without body copy.

Docs MDX Admonitions could also pick up prose spacing around rich
children, while the design-system Tailwind config emitted an
ESM/CommonJS warning in the design-system app.

## What is the new behavior?

Adds a `success` Admonition type, description-only support, and a
stricter content contract: `title` or legacy `label` now requires either
`description` or `children`. Existing title-only Studio callsites have
been converted to description-only callouts.

The design-system docs now include examples for description-only and
success Admonitions, plus guidance for `title`, `description`,
`children`, and legacy `label` usage.

This also tightens Admonition body spacing so rich MDX children keep
docs link/code styling without inheriting excessive prose margins, and
renames the design-system Tailwind config to `tailwind.config.cjs` so it
matches its CommonJS syntax.

Warning and destructive alerts now explicitly set `text-foreground`,
preventing nested Admonition titles from inheriting muted
form-description colour after the Tailwind v4 cascade changes.

| Before | After |
| --- | --- |
| <img width="1818" height="388" alt="Image"
src="https://github.com/user-attachments/assets/283a1853-348a-4d74-a408-013957350e5e"
/> | <img width="1380" height="462" alt="Image"
src="https://github.com/user-attachments/assets/e5761e8e-3697-423b-805b-45110205099a"
/> |
| <img width="1398" height="550" alt="CleanShot 2026-04-28 at 15 12
41@2x"
src="https://github.com/user-attachments/assets/982694d9-5461-4362-8bae-a6e2b4c60e8b"
/> | <img width="1402" height="450" alt="CleanShot 2026-04-28 at 15 13
09@2x"
src="https://github.com/user-attachments/assets/0b1257c4-6b58-4c39-a182-4861a9e378ee"
/> |
| <img width="1640" height="716" alt="CleanShot 2026-04-28 at 15 17
25@2x"
src="https://github.com/user-attachments/assets/a5be4d5f-2bf7-4dc2-b396-56129fe64ec9"
/> | <img width="1630" height="716" alt="CleanShot 2026-04-28 at 15 16
00@2x"
src="https://github.com/user-attachments/assets/0d589252-aaf8-4efc-9d81-15ec4f99ec61"
/> |

| Design System Docs |
| --- |
| <img width="1646" height="1864" alt="CleanShot 2026-04-28 at 14 59
15@2x"
src="https://github.com/user-attachments/assets/12d13595-8972-4fb2-a04a-fb916388ebb6"
/> |


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

* **New Features**
* Added a "success" admonition variant and new example previews
demonstrating success and description-only usages.

* **Documentation**
* Clarified admonition guidance: when to use title vs description vs
children; added example sections for short callouts and success
messages.

* **Refactor**
* Standardized UI by moving short/advisory text into description across
the app and harmonized trailing punctuation.

* **Style**
* Ensured warning/destructive admonitions use consistent foreground text
styling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-01 07:15:00 -06:00

231 lines
8.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// End of third-party imports
import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Check, ChevronsUpDown, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import type { UseFormReturn } from 'react-hook-form'
import { toast } from 'sonner'
import { Button, cn, CommandGroup_Shadcn_, CommandItem_Shadcn_, FormControl, FormField } from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import type { ExtendedSupportCategories } from './Support.constants'
import type { SupportFormValues } from './SupportForm.schema'
import { NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils'
import CopyButton from '@/components/ui/CopyButton'
import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
interface ProjectAndPlanProps {
form: UseFormReturn<SupportFormValues>
orgSlug: string | null
projectRef: string | null
category: ExtendedSupportCategories
subscriptionPlanId: string | undefined
showPlanExpectationInfo?: boolean
}
export function ProjectAndPlanInfo({
form,
orgSlug,
projectRef,
category,
subscriptionPlanId,
showPlanExpectationInfo = true,
}: ProjectAndPlanProps) {
const hasProjectSelected = projectRef && projectRef !== NO_PROJECT_MARKER
return (
<div className="flex flex-col gap-y-2">
<ProjectSelector form={form} orgSlug={orgSlug} projectRef={projectRef} />
<ProjectRefHighlighted projectRef={projectRef} />
{!hasProjectSelected && (
<Admonition type="default" description="No project has been selected." />
)}
{showPlanExpectationInfo &&
orgSlug &&
subscriptionPlanId !== 'enterprise' &&
subscriptionPlanId !== 'platform' &&
category !== 'Login_issues' && (
<PlanExpectationInfoBox orgSlug={orgSlug} planId={subscriptionPlanId} />
)}
</div>
)
}
interface ProjectSelectorProps {
form: UseFormReturn<SupportFormValues>
orgSlug: string | null
projectRef: string | null
}
function ProjectSelector({ form, orgSlug, projectRef }: ProjectSelectorProps) {
const { projectRef: urlProjectRef } = useParams()
return (
<FormField
name="projectRef"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical" label="Which project is affected?">
<FormControl>
<OrganizationProjectSelector
key={orgSlug}
sameWidthAsTrigger
fetchOnMount
checkPosition="left"
slug={!orgSlug || orgSlug === NO_ORG_MARKER ? undefined : orgSlug}
selectedRef={field.value}
onInitialLoad={(projects) => {
if (!urlProjectRef && (!projectRef || projectRef === NO_PROJECT_MARKER))
field.onChange(projects[0]?.ref ?? NO_PROJECT_MARKER)
}}
onSelect={(project) => field.onChange(project.ref)}
renderTrigger={({ isLoading, project, listboxId, open }) => {
return (
<Button
block
type="default"
role="combobox"
aria-label="Select a project"
aria-expanded={open}
aria-controls={listboxId}
size="small"
className="justify-between"
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
>
{!!orgSlug && isLoading ? (
<ShimmeringLoader className="w-44 py-2" />
) : !field.value || field.value === NO_PROJECT_MARKER ? (
'No specific project'
) : (
(project?.name ?? 'Unknown project')
)}
</Button>
)
}}
renderActions={(setOpen) => (
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_
className="w-full gap-x-2"
onSelect={() => {
field.onChange(NO_PROJECT_MARKER)
setOpen(false)
}}
>
{field.value === NO_PROJECT_MARKER && <Check size={16} />}
<p className={cn(field.value !== NO_PROJECT_MARKER && 'ml-6')}>
No specific project
</p>
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
)}
/>
</FormControl>
</FormItemLayout>
)}
/>
)
}
interface ProjectRefHighlightedProps {
projectRef: string | null
}
function ProjectRefHighlighted({ projectRef }: ProjectRefHighlightedProps) {
const isVisible = !!projectRef && projectRef !== NO_PROJECT_MARKER
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-x-1"
>
<p className="text-sm transition text-foreground-lighter">
Project ID:{' '}
<code className="text-code-inline text-foreground-light!">{projectRef}</code>
</p>
<CopyButton
iconOnly
type="text"
text={projectRef}
onClick={() => toast.success('Copied project ID to clipboard')}
/>
</motion.div>
)}
</AnimatePresence>
)
}
interface PlanExpectationInfoBoxProps {
orgSlug: string
planId?: string
}
const PlanExpectationInfoBox = ({ orgSlug, planId }: PlanExpectationInfoBoxProps) => {
const { billingAll } = useIsFeatureEnabled(['billing:all'])
const shouldShowUpgradeActions = billingAll && planId !== 'enterprise'
return (
<Admonition
type="default"
title="Expected response times are based on your organization’s plan"
description={
<>
{planId === 'free' && (
<p>
Support on the Free plan is provided through the community and by the team on a
best-effort basis. For a guaranteed response time, we recommend upgrading to the Pro
plan. Enhanced support SLAs are available on the Enterprise plan.
</p>
)}
{planId === 'pro' && (
<p>
The Pro plan includes email support. In most cases, you can expect a response within 1
business day for all severities. For prioritized ticketing on all issues and
prioritized escalation to product engineering, we recommend upgrading to the Team
plan. Enhanced support SLAs are available on the Enterprise plan.
</p>
)}
{planId === 'team' && (
<p>
The Team plan includes email support with prioritized ticketing and escalation to
product engineering. Low, normal, and high-severity tickets are typically handled
within 1 business day. Urgent issues are handled within 1 day, 365 days a year.
Enhanced support SLAs are available on the Enterprise plan.
</p>
)}
</>
}
actions={
shouldShowUpgradeActions && (
<>
<Button asChild>
<Link
href={`/org/${orgSlug}/billing?panel=subscriptionPlan&source=planSupportExpectationInfoBox`}
>
Upgrade plan
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link href="https://supabase.com/contact/enterprise" target="_blank" rel="noreferrer">
Enquire about Enterprise
</Link>
</Button>
</>
)
}
/>
)
}