Files
supabase/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

252 lines
8.3 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PropsWithChildren, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form,
FormControl,
FormField,
TextArea,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from '@/data/organizations/organization-members-query'
import {
PlanRequest,
useSendUpgradeRequestMutation,
} from '@/data/organizations/request-upgrade-mutation'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTrack } from '@/lib/telemetry/track'
const FormSchema = z.object({
note: z.string().optional(),
})
const formId = 'request-upgrade-form'
interface RequestUpgradeToBillingOwnersProps {
block?: boolean
plan?: PlanRequest
addon?: 'pitr' | 'customDomain' | 'ipv4' | 'spendCap' | 'computeSize'
/** Used in the default message template, e.g: "Upgrade to ..." */
featureProposition?: string
className?: string
variant?: 'primary' | 'default'
}
export const RequestUpgradeToBillingOwners = ({
block = false,
plan = 'Pro',
addon,
featureProposition,
children,
className,
variant = 'primary',
}: PropsWithChildren<RequestUpgradeToBillingOwnersProps>) => {
const [open, setOpen] = useState(false)
const track = useTrack()
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const slug = organization?.slug
const currentPlan = organization?.plan?.id
const isFreePlan = currentPlan === 'free'
const { data: members = [] } = useOrganizationMembersQuery({ slug: organization?.slug })
const { data: roles } = useOrganizationRolesV2Query({ slug: organization?.slug })
const orgRoles = roles?.org_scoped_roles ?? []
const { mutate: sendUpgradeRequest, isPending: isSubmitting } = useSendUpgradeRequestMutation({
onSuccess: () => {
track('request_upgrade_submitted', {
requestedPlan: plan,
addon,
currentPlan,
})
toast.success('Successfully sent request to billing owners!')
setOpen(false)
},
})
const formattedAddonName =
addon === 'pitr'
? 'PITR'
: addon === 'customDomain'
? 'Custom domain'
: addon === 'ipv4'
? 'dedicated IPv4 address'
: ''
const target = !!project
? `for the project "${project?.name}"`
: !!organization
? `for the organization "${organization.name}"`
: ''
const action =
addon === 'spendCap'
? `disable spend cap`
: addon === 'computeSize'
? `change the compute size`
: `enable the ${formattedAddonName} add-on`
const titleText = !!addon
? addon === 'spendCap'
? `Request to disable spend cap`
: addon === 'computeSize'
? 'Request to change compute size'
: `Request to enable the ${formattedAddonName} add-on`
: `Request an upgrade for the ${plan} Plan`
const buttonText = !!children
? children
: !!addon
? addon === 'spendCap'
? 'Request to disable spend cap'
: addon === 'computeSize'
? 'Request to change compute'
: 'Request to enable addon'
: `Request upgrade to ${plan}`
const defaultValues = {
note: !!addon
? `We'd like to ${isFreePlan ? 'upgrade to Pro and ' : ''}${action} ${target} so that we can ${featureProposition}`
: `We'd like to upgrade to the ${plan} plan ${!!featureProposition ? `to ${featureProposition} ` : ''}${target}`,
}
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues,
values: defaultValues,
})
// [Joshen] This is a pretty naive way of checking billing owners by raw role names
// Ideally we derive billing owners using permissions checking - but the current permissions
// logic is only contextualized to that of the current user, not other members
const billingOwners = members.filter((member) => {
const roles = member.role_ids
.map((x) => orgRoles.find((role) => role.id === x)?.name)
.filter(Boolean)
return !member.invited_id && (roles.includes('Owner') || roles.includes('Administrator'))
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (values) => {
if (!slug) return console.error('Slug is required')
sendUpgradeRequest({ slug, plan, note: values.note })
}
const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
track('request_upgrade_modal_opened', {
requestedPlan: plan,
addon,
currentPlan,
featureProposition,
})
}
setOpen(isOpen)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button block={block} variant={variant} className={className}>
{buttonText}
</Button>
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>{titleText}</DialogTitle>
<DialogDescription>
Let your organization's billing owners know your interest in this
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-6">
<div className="flex flex-col gap-y-2">
<p className="text-sm">
Your request will be sent to the following emails, who are billing owners of your
organization:
</p>
<div className="text-sm flex gap-x-2">
<p>
{billingOwners
.slice(0, 2)
.map((x) => x.primary_email)
.join(', ')}
</p>
{billingOwners.length > 2 && (
<Tooltip>
<TooltipTrigger tabIndex={-1}>
<Badge>+1 others</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">
<ul className="">
{billingOwners.slice(2).map((x) => (
<li key={x.gotrue_id}>{x.primary_email}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<FormField
control={form.control}
name="note"
render={({ field }) => (
<FormItemLayout
name="note"
label="Add a note to your request (optional)"
layout="vertical"
>
<FormControl>
<TextArea
id="note"
{...field}
rows={3}
placeholder={
!!addon
? addon === 'spendCap'
? 'e.g. We need to disabled spend cap on this project to do something'
: 'e.g. We need to enable this add-on to do something with the project'
: 'e.g. We need to upgrade to the Pro plan to use this feature'
}
/>
</FormControl>
</FormItemLayout>
)}
/>
</DialogSection>
<DialogFooter>
<Button disabled={isSubmitting} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="primary" type="submit" form={formId} loading={isSubmitting}>
Submit request
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}