feat(dify-ui): Meter primitive and billing adoption (#35380)

This commit is contained in:
yyh
2026-04-18 15:13:54 +08:00
committed by GitHub
parent 3c7d6739b5
commit ae9c4244d6
19 changed files with 505 additions and 292 deletions

View File

@@ -37,6 +37,10 @@
"types": "./src/dropdown-menu/index.tsx",
"import": "./src/dropdown-menu/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"
},
"./number-field": {
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"

View File

@@ -17,7 +17,6 @@ import {
} from '../overlay-shared'
import { parsePlacement } from '../placement'
/** @public */
export type { Placement }
export const ContextMenu = BaseContextMenu.Root

View File

@@ -12,7 +12,6 @@ import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import { cn } from '../cn'
export const Dialog = BaseDialog.Root
/** @public */
export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description

View File

@@ -0,0 +1,128 @@
import { render } from 'vitest-browser-react'
import {
MeterIndicator,
MeterLabel,
MeterRoot,
MeterTrack,
MeterValue,
} from '../index'
describe('Meter compound primitives', () => {
it('exposes role="meter" with ARIA value metadata', async () => {
const screen = await render(
<MeterRoot value={40} aria-label="Quota">
<MeterTrack>
<MeterIndicator />
</MeterTrack>
</MeterRoot>,
)
const meter = screen.getByLabelText('Quota')
await expect.element(meter).toHaveAttribute('role', 'meter')
await expect.element(meter).toHaveAttribute('aria-valuemin', '0')
await expect.element(meter).toHaveAttribute('aria-valuemax', '100')
await expect.element(meter).toHaveAttribute('aria-valuenow', '40')
})
it('respects custom min and max', async () => {
const screen = await render(
<MeterRoot value={3} min={1} max={5} aria-label="Quota">
<MeterTrack>
<MeterIndicator />
</MeterTrack>
</MeterRoot>,
)
const meter = screen.getByLabelText('Quota')
await expect.element(meter).toHaveAttribute('aria-valuemin', '1')
await expect.element(meter).toHaveAttribute('aria-valuemax', '5')
await expect.element(meter).toHaveAttribute('aria-valuenow', '3')
})
it('sets indicator width from value/min/max', async () => {
const screen = await render(
<MeterRoot value={42} aria-label="Quota">
<MeterTrack>
<MeterIndicator data-testid="indicator" />
</MeterTrack>
</MeterRoot>,
)
const indicator = screen.getByTestId('indicator').element() as HTMLElement
expect(indicator.getAttribute('style')).toContain('width: 42%')
})
it('applies tone="error" to the indicator', async () => {
const screen = await render(
<MeterRoot value={95} aria-label="Quota">
<MeterTrack>
<MeterIndicator tone="error" data-testid="indicator" />
</MeterTrack>
</MeterRoot>,
)
const indicator = screen.getByTestId('indicator').element() as HTMLElement
expect(indicator.className).toContain('bg-components-progress-error-progress')
})
it('applies tone="warning" to the indicator', async () => {
const screen = await render(
<MeterRoot value={85} aria-label="Quota">
<MeterTrack>
<MeterIndicator tone="warning" data-testid="indicator" />
</MeterTrack>
</MeterRoot>,
)
const indicator = screen.getByTestId('indicator').element() as HTMLElement
expect(indicator.className).toContain('bg-components-progress-warning-progress')
})
it('defaults to the neutral tone when none is supplied', async () => {
const screen = await render(
<MeterRoot value={25} aria-label="Quota">
<MeterTrack>
<MeterIndicator data-testid="indicator" />
</MeterTrack>
</MeterRoot>,
)
const indicator = screen.getByTestId('indicator').element() as HTMLElement
expect(indicator.className).toContain('bg-components-progress-bar-progress-solid')
})
it('forwards className to MeterTrack alongside the themed base classes', async () => {
const screen = await render(
<MeterRoot value={10} aria-label="Quota">
<MeterTrack className="custom-track" data-testid="track">
<MeterIndicator />
</MeterTrack>
</MeterRoot>,
)
const track = screen.getByTestId('track').element() as HTMLElement
expect(track.className).toContain('custom-track')
expect(track.className).toContain('bg-components-progress-bar-bg')
})
it('renders MeterLabel and MeterValue inside a compound layout', async () => {
const screen = await render(
<MeterRoot
value={0.42}
min={0}
max={1}
format={{ style: 'percent', maximumFractionDigits: 0 }}
aria-label="Score"
>
<MeterLabel>Score</MeterLabel>
<MeterTrack>
<MeterIndicator />
</MeterTrack>
<MeterValue />
</MeterRoot>,
)
await expect.element(screen.getByText('Score')).toBeInTheDocument()
await expect.element(screen.getByText('42%')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { MeterIndicator, MeterLabel, MeterRoot, MeterTrack, MeterValue } from '.'
const meta = {
title: 'Base/UI/Meter',
component: MeterRoot,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A graphical display of a numeric value within a known range. '
+ 'Use the compound primitives (`MeterRoot / MeterTrack / MeterIndicator / '
+ 'MeterValue / MeterLabel`) for quota, capacity, or score indicators; do '
+ 'not use for task-completion progress.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof MeterRoot>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
'value': 42,
'aria-label': 'Quota used',
},
render: args => (
<div className="w-[320px]">
<MeterRoot {...args}>
<MeterTrack>
<MeterIndicator />
</MeterTrack>
</MeterRoot>
</div>
),
}
export const Warning: Story = {
args: {
'value': 85,
'aria-label': 'Quota used',
},
render: args => (
<div className="w-[320px]">
<MeterRoot {...args}>
<MeterTrack>
<MeterIndicator tone="warning" />
</MeterTrack>
</MeterRoot>
</div>
),
}
export const Error: Story = {
args: {
'value': 100,
'aria-label': 'Quota used',
},
render: args => (
<div className="w-[320px]">
<MeterRoot {...args}>
<MeterTrack>
<MeterIndicator tone="error" />
</MeterTrack>
</MeterRoot>
</div>
),
}
export const ComposedWithLabelAndValue: Story = {
args: {
'value': 62,
'aria-label': 'Storage used',
},
render: args => (
<div className="w-[320px] space-y-2 rounded-xl bg-components-panel-bg p-4">
<MeterRoot {...args}>
<div className="flex items-center justify-between">
<MeterLabel>Storage</MeterLabel>
<MeterValue />
</div>
<MeterTrack className="mt-2">
<MeterIndicator tone="warning" />
</MeterTrack>
</MeterRoot>
</div>
),
}
export const PercentFormatted: Story = {
args: {
'value': 0.73,
'min': 0,
'max': 1,
'format': { style: 'percent', maximumFractionDigits: 0 },
'aria-label': 'Retrieval score',
},
render: args => (
<div className="w-[320px] space-y-2">
<MeterRoot {...args}>
<div className="flex items-center justify-between">
<MeterLabel>Score</MeterLabel>
<MeterValue />
</div>
<MeterTrack className="mt-2">
<MeterIndicator />
</MeterTrack>
</MeterRoot>
</div>
),
}

View File

@@ -0,0 +1,88 @@
'use client'
/**
* Meter — a graphical display of a numeric value within a known range
* (quota usage, capacity, scores). For task-completion semantics use a
* Progress primitive instead; `role="meter"` and `role="progressbar"` are
* not interchangeable.
*
* Consumers import from `@langgenius/dify-ui/meter` and must NOT import
* `@base-ui/react/meter` directly.
*/
import type { VariantProps } from 'class-variance-authority'
import { Meter as BaseMeter } from '@base-ui/react/meter'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
export const MeterRoot = BaseMeter.Root
export type MeterRootProps = BaseMeter.Root.Props
const meterTrackClassName
= 'relative block h-1 w-full overflow-hidden rounded-md bg-components-progress-bar-bg'
export type MeterTrackProps = BaseMeter.Track.Props
export function MeterTrack({ className, ...props }: MeterTrackProps) {
return (
<BaseMeter.Track
className={cn(meterTrackClassName, className)}
{...props}
/>
)
}
const meterIndicatorVariants = cva(
'block h-full rounded-md transition-[width] motion-reduce:transition-none',
{
variants: {
tone: {
neutral: 'bg-components-progress-bar-progress-solid',
warning: 'bg-components-progress-warning-progress',
error: 'bg-components-progress-error-progress',
},
},
defaultVariants: {
tone: 'neutral',
},
},
)
export type MeterTone = NonNullable<VariantProps<typeof meterIndicatorVariants>['tone']>
export type MeterIndicatorProps = BaseMeter.Indicator.Props & {
tone?: MeterTone
}
export function MeterIndicator({ className, tone, ...props }: MeterIndicatorProps) {
return (
<BaseMeter.Indicator
className={cn(meterIndicatorVariants({ tone }), className)}
{...props}
/>
)
}
const meterValueClassName = 'system-xs-regular text-text-tertiary tabular-nums'
export type MeterValueProps = BaseMeter.Value.Props
export function MeterValue({ className, ...props }: MeterValueProps) {
return (
<BaseMeter.Value
className={cn(meterValueClassName, className)}
{...props}
/>
)
}
const meterLabelClassName = 'system-xs-medium text-text-tertiary'
export type MeterLabelProps = BaseMeter.Label.Props
export function MeterLabel({ className, ...props }: MeterLabelProps) {
return (
<BaseMeter.Label
className={cn(meterLabelClassName, className)}
{...props}
/>
)
}

View File

@@ -11,9 +11,7 @@ export type { Placement }
export const Popover = BasePopover.Root
export const PopoverTrigger = BasePopover.Trigger
export const PopoverClose = BasePopover.Close
/** @public */
export const PopoverTitle = BasePopover.Title
/** @public */
export const PopoverDescription = BasePopover.Description
type PopoverContentProps = {

View File

@@ -12,12 +12,10 @@ import {
} from '../overlay-shared'
import { parsePlacement } from '../placement'
/** @public */
export type { Placement }
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
/** @public */
export const SelectGroup = BaseSelect.Group
const selectTriggerVariants = cva(
@@ -81,7 +79,6 @@ export function SelectLabel({
)
}
/** @public */
export function SelectSeparator({
className,
...props

View File

@@ -3,7 +3,6 @@
import { Slider as BaseSlider } from '@base-ui/react/slider'
import { cn } from '../cn'
/** @public */
export const SliderRoot = BaseSlider.Root
type SliderRootProps = BaseSlider.Root.Props<number>
@@ -15,7 +14,6 @@ const sliderControlClassName = cn(
type SliderControlProps = BaseSlider.Control.Props
/** @public */
export function SliderControl({ className, ...props }: SliderControlProps) {
return (
<BaseSlider.Control
@@ -32,7 +30,6 @@ const sliderTrackClassName = cn(
type SliderTrackProps = BaseSlider.Track.Props
/** @public */
export function SliderTrack({ className, ...props }: SliderTrackProps) {
return (
<BaseSlider.Track
@@ -49,7 +46,6 @@ const sliderIndicatorClassName = cn(
type SliderIndicatorProps = BaseSlider.Indicator.Props
/** @public */
export function SliderIndicator({ className, ...props }: SliderIndicatorProps) {
return (
<BaseSlider.Indicator
@@ -71,7 +67,6 @@ const sliderThumbClassName = cn(
type SliderThumbProps = BaseSlider.Thumb.Props
/** @public */
export function SliderThumb({ className, ...props }: SliderThumbProps) {
return (
<BaseSlider.Thumb

View File

@@ -3,10 +3,22 @@ import { toast, ToastHost } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
declare global {
// eslint-disable-next-line vars-on-top
var BASE_UI_ANIMATIONS_DISABLED: boolean | undefined
}
describe('base/ui/toast', () => {
beforeAll(() => {
// Base UI waits for `requestAnimationFrame` + `getAnimations().finished`
// before unmounting a toast. Fake timers can't reliably drive that path,
// so short-circuit it to keep auto-dismiss assertions deterministic in CI.
globalThis.BASE_UI_ANIMATIONS_DISABLED = true
})
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.useFakeTimers()
toast.dismiss()
})
@@ -16,6 +28,10 @@ describe('base/ui/toast', () => {
vi.useRealTimers()
})
afterAll(() => {
globalThis.BASE_UI_ANIMATIONS_DISABLED = undefined
})
it('should render a success toast when called through the typed shortcut', async () => {
const screen = await render(<ToastHost />)

View File

@@ -490,8 +490,8 @@ describe('Capacity Full Components Integration', () => {
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
// Should show usage/total fraction "5/5"
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
// Should have a progress bar rendered
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
// Should have a meter rendered
expect(screen.getByRole('meter')).toBeInTheDocument()
})
it('should display upgrade tip and upgrade button for professional plan', () => {
@@ -531,10 +531,9 @@ describe('Capacity Full Components Integration', () => {
total: { buildApps: 5 },
})
render(<AppsFull loc="test" />)
const { container } = render(<AppsFull loc="test" />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
expect(container.querySelector('.bg-components-progress-error-progress')).toBeInTheDocument()
})
})
@@ -819,8 +818,8 @@ describe('Usage Display Edge Cases', () => {
render(<PlanComp loc="test" />)
// Should have an indeterminate progress bar
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
// Below-threshold storage renders the redacted placeholder instead of a Meter
expect(document.body.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
})
it('should show actual usage for pro plan above threshold', () => {
@@ -846,15 +845,10 @@ describe('Usage Display Edge Cases', () => {
total: { buildApps: 5 },
})
render(<PlanComp loc="test" />)
const { container } = render(<PlanComp loc="test" />)
// 20% usage - normal color
const progressBars = screen.getAllByTestId('billing-progress-bar')
// At least one should have the normal progress color
const hasNormalColor = progressBars.some(bar =>
bar.classList.contains('bg-components-progress-bar-progress-solid'),
)
expect(hasNormalColor).toBe(true)
// 20% usage — at least one Meter indicator should carry the neutral tone
expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument()
})
})

View File

@@ -197,8 +197,8 @@ describe('AppsFull', () => {
})
describe('Edge Cases', () => {
it('should apply distinct progress bar styling at different usage levels', () => {
const renderWithUsage = (used: number, total: number) => {
it('applies neutral / warning / error tone at distinct usage levels', () => {
const findToneClass = (used: number, total: number) => {
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
plan: {
...baseProviderContextValue.plan,
@@ -208,19 +208,18 @@ describe('AppsFull', () => {
reset: { apiRateLimit: null, triggerEvents: null },
},
}))
const { unmount } = render(<AppsFull loc="billing_dialog" />)
const className = screen.getByTestId('billing-progress-bar').className
const { container, unmount } = render(<AppsFull loc="billing_dialog" />)
const indicator = container.querySelector(
'[class*="bg-components-progress-"]:not([class*="progress-bar-bg"])',
)
const className = indicator?.className ?? ''
unmount()
return className
}
const normalClass = renderWithUsage(2, 10)
const warningClass = renderWithUsage(6, 10)
const errorClass = renderWithUsage(8, 10)
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
expect(findToneClass(2, 10)).toContain('bg-components-progress-bar-progress-solid')
expect(findToneClass(6, 10)).toContain('bg-components-progress-warning-progress')
expect(findToneClass(8, 10)).toContain('bg-components-progress-error-progress')
})
})
})

View File

@@ -1,10 +1,11 @@
'use client'
import type { MeterTone } from '@langgenius/dify-ui/meter'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ProgressBar from '@/app/components/billing/progress-bar'
import { Plan } from '@/app/components/billing/type'
import { mailToSupport } from '@/app/components/header/utils/util'
import { useAppContext } from '@/context/app-context'
@@ -12,9 +13,6 @@ import { useProviderContext } from '@/context/provider-context'
import UpgradeBtn from '../upgrade-btn'
import s from './style.module.css'
const LOW = 50
const MIDDLE = 80
const AppsFull: FC<{ loc: string, className?: string }> = ({
loc,
className,
@@ -25,16 +23,8 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
const isTeam = plan.type === Plan.team
const usage = plan.usage.buildApps
const total = plan.total.buildApps
const percent = usage / total * 100
const color = (() => {
if (percent < LOW)
return 'bg-components-progress-bar-progress-solid'
if (percent < MIDDLE)
return 'bg-components-progress-warning-progress'
return 'bg-components-progress-error-progress'
})()
const percent = total > 0 ? (usage / total) * 100 : 0
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
return (
<div className={cn(
'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs',
@@ -78,10 +68,11 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
{total}
</div>
</div>
<ProgressBar
percent={percent}
color={color}
/>
<MeterRoot value={Math.min(percent, 100)} max={100}>
<MeterTrack>
<MeterIndicator tone={tone} />
</MeterTrack>
</MeterRoot>
</div>
</div>
)

View File

@@ -1,58 +0,0 @@
import { render, screen } from '@testing-library/react'
import ProgressBar from '../index'
describe('ProgressBar', () => {
describe('Normal Mode (determinate)', () => {
it('renders with provided percent and color', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar.getAttribute('style')).toContain('width: 42%')
})
it('caps width at 100% when percent exceeds max', () => {
render(<ProgressBar percent={150} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar.getAttribute('style')).toContain('width: 100%')
})
it('renders with default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar.getAttribute('style')).toContain('width: 20%')
})
})
describe('Indeterminate Mode', () => {
it('should render indeterminate progress bar when indeterminate is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should not render normal progress bar when indeterminate is true', () => {
render(<ProgressBar percent={50} color="bg-test-color" indeterminate />)
expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument()
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render with different width based on indeterminateFull prop', () => {
const { rerender } = render(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />,
)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
const partialClassName = bar.className
rerender(
<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />,
)
const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className
expect(partialClassName).not.toBe(fullClassName)
})
})
})

View File

@@ -1,40 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
type ProgressBarProps = {
percent: number
color: string
indeterminate?: boolean
indeterminateFull?: boolean // For Sandbox users: full width stripe
}
const ProgressBar = ({
percent = 0,
color = 'bg-components-progress-bar-progress-solid',
indeterminate = false,
indeterminateFull = false,
}: ProgressBarProps) => {
if (indeterminate) {
return (
<div className="overflow-hidden rounded-md bg-components-progress-bar-bg">
<div
data-testid="billing-progress-bar-indeterminate"
className={cn('h-1 rounded-md bg-progress-bar-indeterminate-stripe', indeterminateFull ? 'w-full' : 'w-[30px]')}
/>
</div>
)
}
return (
<div className="overflow-hidden rounded-md bg-components-progress-bar-bg">
<div
data-testid="billing-progress-bar"
className={cn('h-1 rounded-md', color)}
style={{
width: `${Math.min(percent, 100)}%`,
}}
/>
</div>
)
}
export default ProgressBar

View File

@@ -71,8 +71,8 @@ describe('UsageInfo', () => {
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
it('applies distinct styling when usage is close to or exceeds the limit', () => {
const { rerender } = render(
it('applies the neutral / warning / error tone as usage crosses thresholds', () => {
const { rerender, container } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -81,7 +81,7 @@ describe('UsageInfo', () => {
/>,
)
const normalBarClass = screen.getByTestId('billing-progress-bar').className
expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument()
rerender(
<UsageInfo
@@ -92,8 +92,7 @@ describe('UsageInfo', () => {
/>,
)
const warningBarClass = screen.getByTestId('billing-progress-bar').className
expect(warningBarClass).not.toBe(normalBarClass)
expect(container.querySelector('.bg-components-progress-warning-progress')).toBeInTheDocument()
rerender(
<UsageInfo
@@ -104,9 +103,7 @@ describe('UsageInfo', () => {
/>,
)
const errorBarClass = screen.getByTestId('billing-progress-bar').className
expect(errorBarClass).not.toBe(normalBarClass)
expect(errorBarClass).not.toBe(warningBarClass)
expect(container.querySelector('.bg-components-progress-error-progress')).toBeInTheDocument()
})
it('does not render the icon when hideIcon is true', () => {
@@ -126,8 +123,8 @@ describe('UsageInfo', () => {
describe('Storage Mode', () => {
describe('Below Threshold', () => {
it('should render indeterminate progress bar when usage is below threshold', () => {
render(
it('should render the redacted placeholder when usage is below threshold', () => {
const { container } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -139,8 +136,8 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument()
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
})
it('should display "< threshold" format when usage is below threshold (non-sandbox)', () => {
@@ -183,8 +180,8 @@ describe('UsageInfo', () => {
expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1)
})
it('should render different indeterminate bar widths for sandbox vs non-sandbox', () => {
const { rerender } = render(
it('should render different placeholder widths for sandbox vs non-sandbox', () => {
const { rerender, container } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -197,7 +194,7 @@ describe('UsageInfo', () => {
/>,
)
const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
const sandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className
rerender(
<UsageInfo
@@ -212,14 +209,14 @@ describe('UsageInfo', () => {
/>,
)
const nonSandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className
const nonSandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className
expect(sandboxBarClass).not.toBe(nonSandboxBarClass)
})
})
describe('Sandbox Full Capacity', () => {
it('should render determinate progress bar when sandbox usage >= threshold', () => {
render(
it('should render the Meter when sandbox usage >= threshold', () => {
const { container } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -232,8 +229,8 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => {
@@ -258,8 +255,8 @@ describe('UsageInfo', () => {
})
describe('Pro/Team Users Above Threshold', () => {
it('should render normal progress bar when usage >= threshold', () => {
render(
it('should render the Meter when usage >= threshold', () => {
const { container } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
@@ -273,8 +270,8 @@ describe('UsageInfo', () => {
/>,
)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
})
it('should display actual usage when usage >= threshold', () => {

View File

@@ -3,6 +3,8 @@ import { defaultPlan } from '../../config'
import { Plan } from '../../type'
import VectorSpaceInfo from '../vector-space-info'
const queryPlaceholder = () => document.body.querySelector('[aria-hidden="true"]')
// Mock provider context with configurable plan
let mockPlanType = Plan.sandbox
let mockVectorSpaceUsage = 30
@@ -55,16 +57,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 30
})
it('should render indeterminate progress bar when usage is below threshold', () => {
it('should render the redacted placeholder when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render indeterminate bar for sandbox users', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
expect(queryPlaceholder()).toBeInTheDocument()
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
})
it('should display "< 50" format for sandbox below threshold', () => {
@@ -80,11 +77,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 50
})
it('should render determinate progress bar when at full capacity', () => {
it('should render the Meter when at full capacity', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(queryPlaceholder()).toBeNull()
})
it('should display "50 / 50 MB" format when at full capacity', () => {
@@ -101,10 +98,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 30
})
it('should render indeterminate progress bar when usage is below threshold', () => {
it('should render the redacted placeholder when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
expect(queryPlaceholder()).toBeInTheDocument()
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
})
it('should display "< 50 / total" format when below threshold', () => {
@@ -121,11 +119,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 100
})
it('should render normal progress bar when usage >= threshold', () => {
it('should render the Meter when usage >= threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(queryPlaceholder()).toBeNull()
})
it('should display actual usage when above threshold', () => {
@@ -142,10 +140,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 30
})
it('should render indeterminate progress bar when usage is below threshold', () => {
it('should render the redacted placeholder when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
expect(queryPlaceholder()).toBeInTheDocument()
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
})
it('should display "< 50 / total" format when below threshold', () => {
@@ -163,11 +162,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceUsage = 100
})
it('should render normal progress bar when usage >= threshold', () => {
it('should render the Meter when usage >= threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(queryPlaceholder()).toBeNull()
})
it('should display actual usage when above threshold', () => {
@@ -179,23 +178,26 @@ describe('VectorSpaceInfo', () => {
})
describe('Pro/Team Plan Usage States', () => {
const renderAndGetBarClass = (usage: number) => {
const findToneClass = (usage: number) => {
mockPlanType = Plan.professional
mockVectorSpaceUsage = usage
const { unmount } = render(<VectorSpaceInfo />)
const className = screen.getByTestId('billing-progress-bar').className
const { container, unmount } = render(<VectorSpaceInfo />)
const indicator = container.querySelector(
'[class*="bg-components-progress-"]:not([class*="progress-bar-bg"])',
)
const className = indicator?.className ?? ''
unmount()
return className
}
it('should show distinct progress bar styling at different usage levels', () => {
const normalClass = renderAndGetBarClass(100)
const warningClass = renderAndGetBarClass(4100)
const errorClass = renderAndGetBarClass(5200)
it('should apply neutral / warning / error tone at distinct usage levels', () => {
const normalClass = findToneClass(100)
const warningClass = findToneClass(4100)
const errorClass = findToneClass(5200)
expect(normalClass).not.toBe(warningClass)
expect(warningClass).not.toBe(errorClass)
expect(normalClass).not.toBe(errorClass)
expect(normalClass).toContain('bg-components-progress-bar-progress-solid')
expect(warningClass).toContain('bg-components-progress-warning-progress')
expect(errorClass).toContain('bg-components-progress-error-progress')
})
})
@@ -214,16 +216,11 @@ describe('VectorSpaceInfo', () => {
expect(screen.getByText('102400MB')).toBeInTheDocument()
})
it('should render indeterminate progress bar when usage is below threshold', () => {
it('should render the redacted placeholder when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render indeterminate bar for enterprise below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
expect(queryPlaceholder()).toBeInTheDocument()
expect(screen.queryByRole('meter')).not.toBeInTheDocument()
})
it('should display "< 50 / total" format when below threshold', () => {
@@ -241,11 +238,11 @@ describe('VectorSpaceInfo', () => {
mockVectorSpaceTotal = 102400 // 100 GB
})
it('should render normal progress bar when usage >= threshold', () => {
it('should render the Meter when usage >= threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
expect(screen.getByRole('meter')).toBeInTheDocument()
expect(queryPlaceholder()).toBeNull()
})
it('should display actual usage when above threshold', () => {

View File

@@ -1,11 +1,12 @@
'use client'
import type { ComponentType, FC } from 'react'
import type { MeterTone } from '@langgenius/dify-ui/meter'
import type { ComponentType, FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { NUM_INFINITE } from '../config'
import ProgressBar from '../progress-bar'
type Props = {
className?: string
@@ -26,8 +27,6 @@ type Props = {
isSandboxPlan?: boolean
}
const WARNING_THRESHOLD = 80
const UsageInfo: FC<Props> = ({
className,
Icon,
@@ -47,20 +46,21 @@ const UsageInfo: FC<Props> = ({
}) => {
const { t } = useTranslation()
// Special display logic for usage below threshold (only in storage mode)
const isBelowThreshold = storageMode && usage < storageThreshold
// Sandbox at full capacity (usage >= threshold and it's sandbox plan)
const isSandboxFull = storageMode && isSandboxPlan && usage >= storageThreshold
const percent = usage / total * 100
const getProgressColor = () => {
if (percent >= 100)
return 'bg-components-progress-error-progress'
if (percent >= WARNING_THRESHOLD)
return 'bg-components-progress-warning-progress'
return 'bg-components-progress-bar-progress-solid'
}
const color = getProgressColor()
// Single source of truth: sandbox full is visually clamped to 100%; all other
// determinate cases show the real percent capped at 100. Tone derives from
// this, so we never need a separate tone override.
const rawPercent = total > 0 ? (usage / total) * 100 : 0
const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100)
const tone: MeterTone
= effectivePercent >= 100
? 'error'
: effectivePercent >= 80
? 'warning'
: 'neutral'
const isUnlimited = total === NUM_INFINITE
let totalDisplay: string | number = isUnlimited ? t('plansCommon.unlimited', { ns: 'billing' }) : total
if (!isUnlimited && unit && unitPosition === 'inline')
@@ -68,35 +68,26 @@ const UsageInfo: FC<Props> = ({
const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix'
const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('usagePage.resetsIn', { ns: 'billing', count: resetInDays }) : undefined)
const renderRightInfo = () => {
if (resetText) {
return (
const rightInfo: ReactNode = resetText
? (
<div className="ml-auto flex-1 text-right system-xs-regular text-text-tertiary">
{resetText}
</div>
)
}
if (showUnit) {
return (
<div className="ml-auto system-xs-medium text-text-tertiary">
{unit}
</div>
)
}
return null
}
: showUnit
? (
<div className="ml-auto system-xs-medium text-text-tertiary">
{unit}
</div>
)
: null
// Render usage display
const renderUsageDisplay = () => {
// Storage mode: special display logic
const usageDisplay: ReactNode = (() => {
if (storageMode) {
// Sandbox user at full capacity
if (isSandboxFull) {
return (
<div className="flex items-center gap-1">
<span>
{storageThreshold}
</span>
<span>{storageThreshold}</span>
<span className="system-md-regular text-text-quaternary">/</span>
<span>
{storageThreshold}
@@ -106,7 +97,6 @@ const UsageInfo: FC<Props> = ({
</div>
)
}
// Usage below threshold - show "< 50 MB" or "< 50 / 5GB"
if (isBelowThreshold) {
return (
<div className="flex items-center gap-1">
@@ -125,7 +115,6 @@ const UsageInfo: FC<Props> = ({
</div>
)
}
// Pro/Team users with usage >= threshold - show actual usage
return (
<div className="flex items-center gap-1">
<span>{usage}</span>
@@ -135,7 +124,6 @@ const UsageInfo: FC<Props> = ({
)
}
// Default display (storageMode = false)
return (
<div className="flex items-center gap-1">
<span>{usage}</span>
@@ -143,9 +131,32 @@ const UsageInfo: FC<Props> = ({
<span>{totalDisplay}</span>
</div>
)
}
})()
const renderWithTooltip = (children: React.ReactNode) => {
const bar: ReactNode = isBelowThreshold
? (
// Decorative "< N MB" placeholder — not a meter, not a progressbar.
<div
aria-hidden="true"
className="overflow-hidden rounded-md bg-components-progress-bar-bg"
>
<div
className={cn(
'h-1 rounded-md bg-progress-bar-indeterminate-stripe',
isSandboxPlan ? 'w-full' : 'w-[30px]',
)}
/>
</div>
)
: (
<MeterRoot value={effectivePercent} max={100}>
<MeterTrack>
<MeterIndicator tone={tone} />
</MeterTrack>
</MeterRoot>
)
const wrapWithStorageTooltip = (children: ReactNode) => {
if (storageMode && storageTooltip) {
return (
<Tooltip
@@ -159,23 +170,6 @@ const UsageInfo: FC<Props> = ({
return children
}
// Render progress bar with optional tooltip wrapper
const renderProgressBar = () => {
const progressBar = (
<ProgressBar
percent={isBelowThreshold ? 0 : percent}
color={isSandboxFull ? 'bg-components-progress-error-progress' : color}
indeterminate={isBelowThreshold}
indeterminateFull={isBelowThreshold && isSandboxPlan}
/>
)
return renderWithTooltip(progressBar)
}
const renderUsageWithTooltip = () => {
return renderWithTooltip(renderUsageDisplay())
}
return (
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
{!hideIcon && Icon && (
@@ -194,10 +188,10 @@ const UsageInfo: FC<Props> = ({
)}
</div>
<div className="flex items-center gap-1 system-md-semibold text-text-primary">
{renderUsageWithTooltip()}
{renderRightInfo()}
{wrapWithStorageTooltip(usageDisplay)}
{rightInfo}
</div>
{renderProgressBar()}
{wrapWithStorageTooltip(bar)}
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { MeterIndicator, MeterLabel, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter'
import { Trans, useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContextSelector } from '@/context/modal-context'
@@ -21,7 +22,9 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
: 'modelProvider.card.creditsExhaustedDescription'
const usedCredits = totalCredits - credits
const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100
const hasTotal = totalCredits > 0
const meterValue = hasTotal ? Math.min(usedCredits, totalCredits) : 1
const meterMax = hasTotal ? totalCredits : 1
return (
<div className="mx-2 mt-0.5 mb-1 rounded-lg bg-background-section-burn p-3">
@@ -45,11 +48,11 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
/>
</div>
</div>
<div className="mt-3 flex flex-col gap-1">
<MeterRoot value={meterValue} max={meterMax} className="mt-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="system-xs-medium text-text-tertiary">
<MeterLabel className="system-xs-medium text-text-tertiary">
{t('modelProvider.card.usageLabel', { ns: 'common' })}
</span>
</MeterLabel>
<div className="flex items-center gap-0.5 system-xs-regular text-text-tertiary">
<CreditsCoin className="h-3 w-3" />
<span>
@@ -59,13 +62,10 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
</span>
</div>
</div>
<div className="h-1 overflow-hidden rounded-md bg-components-progress-error-bg">
<div
className="h-full rounded-l-[6px] bg-components-progress-error-progress"
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
<MeterTrack className="bg-components-progress-error-bg">
<MeterIndicator tone="error" />
</MeterTrack>
</MeterRoot>
</div>
)
}