From ae9c4244d6230b628d286bb38a3223e521989bc8 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:13:54 +0800 Subject: [PATCH] feat(dify-ui): Meter primitive and billing adoption (#35380) --- packages/dify-ui/package.json | 4 + packages/dify-ui/src/context-menu/index.tsx | 1 - packages/dify-ui/src/dialog/index.tsx | 1 - .../src/meter/__tests__/index.spec.tsx | 128 ++++++++++++++++++ packages/dify-ui/src/meter/index.stories.tsx | 115 ++++++++++++++++ packages/dify-ui/src/meter/index.tsx | 88 ++++++++++++ packages/dify-ui/src/popover/index.tsx | 2 - packages/dify-ui/src/select/index.tsx | 3 - packages/dify-ui/src/slider/index.tsx | 5 - .../src/toast/__tests__/index.spec.tsx | 18 ++- .../billing/billing-integration.test.tsx | 24 ++-- .../__tests__/index.spec.tsx | 21 ++- .../billing/apps-full-in-dialog/index.tsx | 27 ++-- .../progress-bar/__tests__/index.spec.tsx | 58 -------- .../components/billing/progress-bar/index.tsx | 40 ------ .../usage-info/__tests__/index.spec.tsx | 45 +++--- .../__tests__/vector-space-info.spec.tsx | 81 ++++++----- .../components/billing/usage-info/index.tsx | 114 ++++++++-------- .../credits-exhausted-alert.tsx | 22 +-- 19 files changed, 505 insertions(+), 292 deletions(-) create mode 100644 packages/dify-ui/src/meter/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/meter/index.stories.tsx create mode 100644 packages/dify-ui/src/meter/index.tsx delete mode 100644 web/app/components/billing/progress-bar/__tests__/index.spec.tsx delete mode 100644 web/app/components/billing/progress-bar/index.tsx diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index b3430ab4ee..e1b7a3c1ef 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -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" diff --git a/packages/dify-ui/src/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx index fc2ae1d454..10b9f2c206 100644 --- a/packages/dify-ui/src/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -17,7 +17,6 @@ import { } from '../overlay-shared' import { parsePlacement } from '../placement' -/** @public */ export type { Placement } export const ContextMenu = BaseContextMenu.Root diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index c24acd5924..517d6cc9fc 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -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 diff --git a/packages/dify-ui/src/meter/__tests__/index.spec.tsx b/packages/dify-ui/src/meter/__tests__/index.spec.tsx new file mode 100644 index 0000000000..01dd0fa081 --- /dev/null +++ b/packages/dify-ui/src/meter/__tests__/index.spec.tsx @@ -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( + + + + + , + ) + + 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( + + + + + , + ) + + 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( + + + + + , + ) + + 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( + + + + + , + ) + + 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( + + + + + , + ) + + 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( + + + + + , + ) + + 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( + + + + + , + ) + + 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( + + Score + + + + + , + ) + + await expect.element(screen.getByText('Score')).toBeInTheDocument() + await expect.element(screen.getByText('42%')).toBeInTheDocument() + }) +}) diff --git a/packages/dify-ui/src/meter/index.stories.tsx b/packages/dify-ui/src/meter/index.stories.tsx new file mode 100644 index 0000000000..f1b8bb46f7 --- /dev/null +++ b/packages/dify-ui/src/meter/index.stories.tsx @@ -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 + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + 'value': 42, + 'aria-label': 'Quota used', + }, + render: args => ( +
+ + + + + +
+ ), +} + +export const Warning: Story = { + args: { + 'value': 85, + 'aria-label': 'Quota used', + }, + render: args => ( +
+ + + + + +
+ ), +} + +export const Error: Story = { + args: { + 'value': 100, + 'aria-label': 'Quota used', + }, + render: args => ( +
+ + + + + +
+ ), +} + +export const ComposedWithLabelAndValue: Story = { + args: { + 'value': 62, + 'aria-label': 'Storage used', + }, + render: args => ( +
+ +
+ Storage + +
+ + + +
+
+ ), +} + +export const PercentFormatted: Story = { + args: { + 'value': 0.73, + 'min': 0, + 'max': 1, + 'format': { style: 'percent', maximumFractionDigits: 0 }, + 'aria-label': 'Retrieval score', + }, + render: args => ( +
+ +
+ Score + +
+ + + +
+
+ ), +} diff --git a/packages/dify-ui/src/meter/index.tsx b/packages/dify-ui/src/meter/index.tsx new file mode 100644 index 0000000000..c7dd7a8b9c --- /dev/null +++ b/packages/dify-ui/src/meter/index.tsx @@ -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 ( + + ) +} + +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['tone']> + +export type MeterIndicatorProps = BaseMeter.Indicator.Props & { + tone?: MeterTone +} + +export function MeterIndicator({ className, tone, ...props }: MeterIndicatorProps) { + return ( + + ) +} + +const meterValueClassName = 'system-xs-regular text-text-tertiary tabular-nums' +export type MeterValueProps = BaseMeter.Value.Props + +export function MeterValue({ className, ...props }: MeterValueProps) { + return ( + + ) +} + +const meterLabelClassName = 'system-xs-medium text-text-tertiary' +export type MeterLabelProps = BaseMeter.Label.Props + +export function MeterLabel({ className, ...props }: MeterLabelProps) { + return ( + + ) +} diff --git a/packages/dify-ui/src/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx index 32d111a1b8..f6fcd5ed43 100644 --- a/packages/dify-ui/src/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -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 = { diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index 62478fad21..017093c584 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -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 diff --git a/packages/dify-ui/src/slider/index.tsx b/packages/dify-ui/src/slider/index.tsx index cfe1361598..eafcecf751 100644 --- a/packages/dify-ui/src/slider/index.tsx +++ b/packages/dify-ui/src/slider/index.tsx @@ -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 @@ -15,7 +14,6 @@ const sliderControlClassName = cn( type SliderControlProps = BaseSlider.Control.Props -/** @public */ export function SliderControl({ className, ...props }: SliderControlProps) { return ( 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() diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 64d358cbe6..90589ae1e4 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -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() + const { container } = render() - 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() - // 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() + const { container } = render() - // 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() }) }) diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index acee660f46..899908b32a 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -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() - const className = screen.getByTestId('billing-progress-bar').className + const { container, unmount } = render() + 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') }) }) }) diff --git a/web/app/components/billing/apps-full-in-dialog/index.tsx b/web/app/components/billing/apps-full-in-dialog/index.tsx index dc26c8e231..08036d055a 100644 --- a/web/app/components/billing/apps-full-in-dialog/index.tsx +++ b/web/app/components/billing/apps-full-in-dialog/index.tsx @@ -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 (
= ({ {total}
- + + + + + ) diff --git a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx b/web/app/components/billing/progress-bar/__tests__/index.spec.tsx deleted file mode 100644 index 4310fab19d..0000000000 --- a/web/app/components/billing/progress-bar/__tests__/index.spec.tsx +++ /dev/null @@ -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() - - const bar = screen.getByTestId('billing-progress-bar') - expect(bar.getAttribute('style')).toContain('width: 42%') - }) - - it('caps width at 100% when percent exceeds max', () => { - render() - - 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() - - 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() - - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should not render normal progress bar when indeterminate is true', () => { - render() - - 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( - , - ) - - const bar = screen.getByTestId('billing-progress-bar-indeterminate') - const partialClassName = bar.className - - rerender( - , - ) - - const fullClassName = screen.getByTestId('billing-progress-bar-indeterminate').className - expect(partialClassName).not.toBe(fullClassName) - }) - }) -}) diff --git a/web/app/components/billing/progress-bar/index.tsx b/web/app/components/billing/progress-bar/index.tsx deleted file mode 100644 index dfc2bf6fa9..0000000000 --- a/web/app/components/billing/progress-bar/index.tsx +++ /dev/null @@ -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 ( -
-
-
- ) - } - - return ( -
-
-
- ) -} - -export default ProgressBar diff --git a/web/app/components/billing/usage-info/__tests__/index.spec.tsx b/web/app/components/billing/usage-info/__tests__/index.spec.tsx index 3cbab5c662..073c2e7fe2 100644 --- a/web/app/components/billing/usage-info/__tests__/index.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/index.spec.tsx @@ -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( { />, ) - const normalBarClass = screen.getByTestId('billing-progress-bar').className + expect(container.querySelector('.bg-components-progress-bar-progress-solid')).toBeInTheDocument() rerender( { />, ) - const warningBarClass = screen.getByTestId('billing-progress-bar').className - expect(warningBarClass).not.toBe(normalBarClass) + expect(container.querySelector('.bg-components-progress-warning-progress')).toBeInTheDocument() rerender( { />, ) - 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( { />, ) - 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( { />, ) - const sandboxBarClass = screen.getByTestId('billing-progress-bar-indeterminate').className + const sandboxBarClass = container.querySelector('.bg-progress-bar-indeterminate-stripe')!.className rerender( { />, ) - 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( { />, ) - 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( { />, ) - 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', () => { diff --git a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx index 041845ab3b..43e132c00f 100644 --- a/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx +++ b/web/app/components/billing/usage-info/__tests__/vector-space-info.spec.tsx @@ -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() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should render indeterminate bar for sandbox users', () => { - render() - - 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() - 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() - 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() - 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() - 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() - 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() - const className = screen.getByTestId('billing-progress-bar').className + const { container, unmount } = render() + 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() - expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument() - }) - - it('should render indeterminate bar for enterprise below threshold', () => { - render() - - 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() - 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', () => { diff --git a/web/app/components/billing/usage-info/index.tsx b/web/app/components/billing/usage-info/index.tsx index a88523c4a5..68e889f320 100644 --- a/web/app/components/billing/usage-info/index.tsx +++ b/web/app/components/billing/usage-info/index.tsx @@ -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 = ({ className, Icon, @@ -47,20 +46,21 @@ const UsageInfo: FC = ({ }) => { 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 = ({ 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 + ? (
{resetText}
) - } - if (showUnit) { - return ( -
- {unit} -
- ) - } - return null - } + : showUnit + ? ( +
+ {unit} +
+ ) + : null - // Render usage display - const renderUsageDisplay = () => { - // Storage mode: special display logic + const usageDisplay: ReactNode = (() => { if (storageMode) { - // Sandbox user at full capacity if (isSandboxFull) { return (
- - {storageThreshold} - + {storageThreshold} / {storageThreshold} @@ -106,7 +97,6 @@ const UsageInfo: FC = ({
) } - // Usage below threshold - show "< 50 MB" or "< 50 / 5GB" if (isBelowThreshold) { return (
@@ -125,7 +115,6 @@ const UsageInfo: FC = ({
) } - // Pro/Team users with usage >= threshold - show actual usage return (
{usage} @@ -135,7 +124,6 @@ const UsageInfo: FC = ({ ) } - // Default display (storageMode = false) return (
{usage} @@ -143,9 +131,32 @@ const UsageInfo: FC = ({ {totalDisplay}
) - } + })() - const renderWithTooltip = (children: React.ReactNode) => { + const bar: ReactNode = isBelowThreshold + ? ( + // Decorative "< N MB" placeholder — not a meter, not a progressbar. + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx index 53abe1211d..69a7f488b3 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/credits-exhausted-alert.tsx @@ -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 (
@@ -45,11 +48,11 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha />
-
+
- + {t('modelProvider.card.usageLabel', { ns: 'common' })} - +
@@ -59,13 +62,10 @@ export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExha
-
-
-
-
+ + + +
) }