Files
supabase/apps/www/components/Pricing/ComputePricingCalculator.tsx
Ivan VasilovandClaude 3f7d62ef3d Fix key prop in ComputePricingCalculator dropdown menu items (#43110)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

The dropdown menu items in the ComputePricingCalculator component use a
hardcoded key value of `"custom-expiry"` for all plan items in the map
function. This causes React to not properly identify unique list items,
which can lead to rendering issues and state management problems when
multiple plans are displayed.

## What is the new behavior?

Changed the key prop from the hardcoded `"custom-expiry"` to
`plan.name`, which uniquely identifies each plan item. This ensures
React can properly track and render each dropdown menu item.

## Additional context

This is a common React anti-pattern where using a constant key for list
items prevents React from properly reconciling the virtual DOM. Using
`plan.name` as the key ensures each plan has a unique identifier for
proper rendering and state management.

https://claude.ai/code/session_01HDfar7A1XXJFJDchPPSQTK

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 11:09:15 +08:00

316 lines
12 KiB
TypeScript

'use client'
import { InformationCircleIcon } from '@heroicons/react/outline'
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Slider_Shadcn_,
cn,
} from 'ui'
import { ComputeBadge } from 'ui-patterns/ComputeBadge'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import pricingAddOn from '~/data/PricingAddOnTable.json'
const plans = [
{
name: 'Pro',
price: 25,
},
{
name: 'Team',
price: 599,
},
]
const findIntanceValueByColumn = (instance: any, column: string) =>
instance.columns?.find((col: any) => col.key === column)?.value
const parsePrice = (price: string) => parseInt(price?.toString().replace('$', '').replace(',', ''))
const ComputePricingCalculator = ({ disableInteractivity }: { disableInteractivity?: boolean }) => {
// Filter out rows with no specific pricing
const computeInstances = pricingAddOn.database.rows.filter((row) =>
row.columns.some((it) => it.key === 'pricing' && it.value !== 'Contact Us')
)
const priceSteps = computeInstances.map((instance) =>
parsePrice(findIntanceValueByColumn(instance, 'pricing'))
)
// Base discount credits that come with every paid plan
const COMPUTE_CREDITS = 10
const [activePlan, setActivePlan] = useState(plans[0])
const [activeInstances, setActiveInstances] = useState([{ ...computeInstances[0], position: 0 }])
// Final calculated price: plan cost + compute aggregate - compute credits
const [activePrice, setActivePrice] = useState(activePlan.price + priceSteps[0] - COMPUTE_CREDITS)
useEffect(() => {
setActivePrice(activePlan.price + priceSteps[0] - COMPUTE_CREDITS)
}, [])
const handleUpdateInstance = (position: number, value: number[]) => {
const newArray = activeInstances.map((activeInstance) => {
// only update the instance corresponding to the correct slider
if (activeInstance.position === position) {
return { ...computeInstances[value[0] - 1], position }
} else {
return activeInstance
}
})
setActiveInstances(newArray)
}
const calculateComputeAggregate = (price: number) => {
activeInstances.map(
(activeInstance: any) =>
(price += parsePrice(findIntanceValueByColumn(activeInstance, 'pricing')))
)
return price
}
const calculatePrice = () => {
let aggregatePrice = 0
const computeAggregate = calculateComputeAggregate(aggregatePrice)
return setActivePrice(computeAggregate + activePlan.price - COMPUTE_CREDITS)
}
useEffect(() => {
calculatePrice()
}, [activeInstances, activePlan])
const removeInstance = (position: number) => {
const newArray = activeInstances
.filter((activeInstance) => activeInstance.position !== position)
.map((instance, index) => {
instance.position = index
return instance
})
setActiveInstances(newArray)
}
const findSliderComputeValue = (activeInstance: any) => {
// find index of compute based on active compute name
const selectedCompute = computeInstances
.map((compute) => findIntanceValueByColumn(compute, 'plan'))
.indexOf(findIntanceValueByColumn(activeInstance, 'plan'))
return [selectedCompute + 1]
}
const PriceSummary = () => (
<div className="flex flex-col gap-1 text-lighter text-right leading-4 w-full border-b pb-1 mb-1">
<div className="flex items-center justify-between">
<span className="text-foreground-muted">Plan</span>
<span className="text-light font-mono" translate="no">
${activePlan.price}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-foreground-muted">Total Compute</span>
<span className="text-light font-mono">${calculateComputeAggregate(0)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-foreground-muted">Compute Credits</span>
<span className="text-light font-mono" translate="no">
- ${COMPUTE_CREDITS}
</span>
</div>
</div>
)
return (
<div className="flex flex-col lg:grid grid-cols-4 gap-4 h-full mt-4 lg:mt-0 border border-strong rounded-xl p-4">
<div className="flex justify-between w-full">
<div className="flex flex-col text-lighter leading-4 text-xs w-full gap-4">
<div className="h-full w-full flex flex-col justify-between">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="tiny"
type="outline"
iconRight={<ChevronDown />}
icon={
activePlan.name === 'Pro' ? (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3.5"
y="3.5"
width="6.77393"
height="6.77393"
rx="1.5"
stroke="hsl(var(--foreground-default))"
/>
<rect
x="5.55078"
y="5.55127"
width="2.67139"
height="2.67139"
rx="1.33569"
stroke="hsl(var(--foreground-muted))"
/>
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 13 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3.26953"
y="3.61279"
width="6.77393"
height="6.77393"
rx="1.5"
stroke="hsl(var(--foreground-muted))"
/>
<rect
x="1.08984"
y="1.43359"
width="11.1323"
height="11.1323"
rx="3.5"
stroke="hsl(var(--foreground-default))"
/>
<rect
x="5.32031"
y="5.66406"
width="2.67139"
height="2.67139"
rx="1.33569"
stroke="hsl(var(--foreground-muted))"
/>
</svg>
)
}
className="w-full pl-1 py-2"
>
<div className="lg:min-w-[80px] flex items-center grow w-full gap-1">
<span className="text-foreground-light">Plan</span>{' '}
<span>{activePlan.name}</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{plans.map((plan: any) => (
<DropdownMenuItem key={plan.name} onClick={() => setActivePlan(plan)}>
{plan.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<PriceSummary />
<div className="flex items-center gap-1 w-full justify-between">
<span>Total Estimate</span>
<span className="text-foreground font-mono flex items-center gap-1">
<InfoTooltip side="top" className="max-w-[250px]">
This estimate only includes Plan and Compute add-on monthly costs. Other resources
might concur in the final invoice.
</InfoTooltip>
${activePrice}
</span>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2 col-span-3 items-start flex-1 w-full lg:w-auto lg:border-l lg:pl-4">
<div
className={cn(
'w-full flex flex-col items-start gap-y-2',
activeInstances.length === 1 && 'border-none'
)}
>
{activeInstances.map((activeInstance, index) => (
<div
className="group w-full flex flex-col gap-3 p-3 bg-surface-200 rounded border"
key={`instance-${index}`}
>
<div className="w-full flex justify-between items-center">
<div className="flex items-center gap-2">
<ComputeBadge
infraComputeSize={findIntanceValueByColumn(activeInstance, 'plan')}
/>
<p className="text-xs text-foreground-lighter">
Project {activeInstance.position + 1}
</p>
</div>
<span className="leading-3 text-sm" translate="no">
{findIntanceValueByColumn(activeInstance, 'pricing')}
</span>
</div>
<Slider_Shadcn_
onValueChange={(value) => handleUpdateInstance(activeInstance.position, value)}
value={findSliderComputeValue(activeInstance)}
min={1}
key={`${index}-${activeInstance.position}`}
max={priceSteps.length}
step={1}
className="w-full mt-1"
/>
<div className="flex items-center justify-between text-sm">
<div className="w-full flex items-center gap-2">
<span className="text-lighter text-xs md:text-[13px]">
{findIntanceValueByColumn(activeInstance, 'memory')} RAM /{' '}
{findIntanceValueByColumn(activeInstance, 'cpu')} CPU / Connections: Direct{' '}
{findIntanceValueByColumn(activeInstance, 'directConnections')}, Pooler{' '}
{findIntanceValueByColumn(activeInstance, 'poolerConnections')}
</span>
</div>
<div className="flex items-center gap-2">
{activeInstances.length > 1 && (
<button
aria-label="Remove item"
title="Remove item"
className="p-1 text-lighter hover:text-foreground rounded opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeInstance(activeInstance.position)}
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
))}
</div>
<div className="w-full">
<Button
size="tiny"
type="outline"
block
icon={<Plus />}
onClick={() => {
if (disableInteractivity) return
setActiveInstances([
...activeInstances,
{ ...computeInstances[0], position: activeInstances.length },
])
}}
className="w-full border-dashed text-foreground-light hover:text-foreground"
>
<span className="w-full text-left">Add Project</span>
</Button>
</div>
</div>
</div>
)
}
export default ComputePricingCalculator