mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
fix(account-flow): restore grouped quota hover details
This commit is contained in:
@@ -3,9 +3,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||||
|
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils';
|
import {
|
||||||
|
cn,
|
||||||
|
formatQuotaPercent,
|
||||||
|
getProviderMinQuota,
|
||||||
|
getProviderResetTime,
|
||||||
|
getQuotaFailureInfo,
|
||||||
|
} from '@/lib/utils';
|
||||||
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useAccountQuota,
|
useAccountQuota,
|
||||||
@@ -207,36 +214,45 @@ export function AccountCard({
|
|||||||
hasGroupedVariants && showQuota
|
hasGroupedVariants && showQuota
|
||||||
? (account.variants ?? []).map((variant, index) => {
|
? (account.variants ?? []).map((variant, index) => {
|
||||||
const quotaQuery = variantQuotaQueries[index];
|
const quotaQuery = variantQuotaQueries[index];
|
||||||
const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data);
|
const quota = quotaQuery?.data;
|
||||||
|
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||||
|
const resetTime = getProviderResetTime(account.provider, quota);
|
||||||
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||||
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
|
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
|
||||||
const failureInfo = getQuotaFailureInfo(quotaQuery?.data);
|
const failureInfo = getQuotaFailureInfo(quota);
|
||||||
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
|
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={variant.id} className="space-y-0.5">
|
<Tooltip key={variant.id}>
|
||||||
<div className="flex items-center justify-between gap-2 text-[8px]">
|
<TooltipTrigger asChild>
|
||||||
<span className="text-muted-foreground/80 truncate">{label}</span>
|
<div className="space-y-0.5 cursor-help">
|
||||||
<span className="font-mono text-foreground/80 shrink-0">
|
<div className="flex items-center justify-between gap-2 text-[8px]">
|
||||||
{quotaQuery?.isLoading
|
<span className="text-muted-foreground/80 truncate">{label}</span>
|
||||||
? t('accountCard.quotaLoading')
|
<span className="font-mono text-foreground/80 shrink-0">
|
||||||
: quotaValue !== null
|
{quotaQuery?.isLoading
|
||||||
? `${quotaLabel}%`
|
? t('accountCard.quotaLoading')
|
||||||
: failureInfo?.label || t('accountCard.quotaUnavailable')}
|
: quotaValue !== null
|
||||||
</span>
|
? `${quotaLabel}%`
|
||||||
</div>
|
: failureInfo?.label || t('accountCard.quotaUnavailable')}
|
||||||
{quotaValue !== null && (
|
</span>
|
||||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
</div>
|
||||||
<div
|
{quotaValue !== null && (
|
||||||
className={cn(
|
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||||
'h-full rounded-full transition-all',
|
<div
|
||||||
getCompactQuotaColor(quotaValue)
|
className={cn(
|
||||||
)}
|
'h-full rounded-full transition-all',
|
||||||
style={{ width: `${quotaValue}%` }}
|
getCompactQuotaColor(quotaValue)
|
||||||
/>
|
)}
|
||||||
|
style={{ width: `${quotaValue}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TooltipTrigger>
|
||||||
</div>
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||||
|
import { AccountCard } from '@/components/account/flow-viz/account-card';
|
||||||
|
import type { AccountData } from '@/components/account/flow-viz/types';
|
||||||
|
import type { CodexQuotaResult } from '@/lib/api-client';
|
||||||
|
import { useAccountQuota, useAccountQuotas } from '@/hooks/use-cliproxy-stats';
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-cliproxy-stats', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/hooks/use-cliproxy-stats')>(
|
||||||
|
'@/hooks/use-cliproxy-stats'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useAccountQuota: vi.fn(),
|
||||||
|
useAccountQuotas: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedUseAccountQuota = vi.mocked(useAccountQuota);
|
||||||
|
const mockedUseAccountQuotas = vi.mocked(useAccountQuotas);
|
||||||
|
|
||||||
|
function makeCodexQuota(planType: 'plus' | 'team', fiveHour: number, weekly: number) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
planType,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
windows: [
|
||||||
|
{
|
||||||
|
label: 'Primary',
|
||||||
|
usedPercent: 100 - fiveHour,
|
||||||
|
remainingPercent: fiveHour,
|
||||||
|
resetAfterSeconds: 60 * 60,
|
||||||
|
resetAt: '2026-04-04T08:44:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Secondary',
|
||||||
|
usedPercent: 100 - weekly,
|
||||||
|
remainingPercent: weekly,
|
||||||
|
resetAfterSeconds: 7 * 24 * 60 * 60,
|
||||||
|
resetAt: '2026-04-08T10:20:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coreUsage: {
|
||||||
|
fiveHour: {
|
||||||
|
label: 'Primary',
|
||||||
|
remainingPercent: fiveHour,
|
||||||
|
resetAfterSeconds: 60 * 60,
|
||||||
|
resetAt: '2026-04-04T08:44:00Z',
|
||||||
|
},
|
||||||
|
weekly: {
|
||||||
|
label: 'Secondary',
|
||||||
|
remainingPercent: weekly,
|
||||||
|
resetAfterSeconds: 7 * 24 * 60 * 60,
|
||||||
|
resetAt: '2026-04-08T10:20:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies CodexQuotaResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAccount: AccountData = {
|
||||||
|
id: 'codex:user@example.com',
|
||||||
|
email: 'user@example.com',
|
||||||
|
tokenFile: 'codex-user.json',
|
||||||
|
provider: 'codex',
|
||||||
|
successCount: 9,
|
||||||
|
failureCount: 1,
|
||||||
|
color: '#1e6091',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'business@example.com',
|
||||||
|
email: 'user@example.com',
|
||||||
|
tokenFile: 'codex-business.json',
|
||||||
|
isDefault: false,
|
||||||
|
successCount: 5,
|
||||||
|
failureCount: 0,
|
||||||
|
audience: 'business',
|
||||||
|
audienceLabel: 'Business',
|
||||||
|
detailLabel: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal@example.com',
|
||||||
|
email: 'user@example.com',
|
||||||
|
tokenFile: 'codex-personal.json',
|
||||||
|
isDefault: true,
|
||||||
|
successCount: 4,
|
||||||
|
failureCount: 1,
|
||||||
|
audience: 'personal',
|
||||||
|
audienceLabel: 'Personal',
|
||||||
|
detailLabel: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AccountCard grouped quota tooltip', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedUseAccountQuota.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
} as ReturnType<typeof useAccountQuota>);
|
||||||
|
|
||||||
|
mockedUseAccountQuotas.mockReturnValue([
|
||||||
|
{
|
||||||
|
data: makeCodexQuota('team', 95, 81),
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: makeCodexQuota('plus', 64, 42),
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
] as ReturnType<typeof useAccountQuotas>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows provider quota tooltip content for each grouped personal/business row on hover', async () => {
|
||||||
|
render(
|
||||||
|
<AccountCard
|
||||||
|
account={groupedAccount}
|
||||||
|
zone="left"
|
||||||
|
originalIndex={0}
|
||||||
|
isHovered={false}
|
||||||
|
isDragging={false}
|
||||||
|
offset={{ x: 0, y: 0 }}
|
||||||
|
showDetails={false}
|
||||||
|
privacyMode={false}
|
||||||
|
onMouseEnter={() => undefined}
|
||||||
|
onMouseLeave={() => undefined}
|
||||||
|
onPointerDown={() => undefined}
|
||||||
|
onPointerMove={() => undefined}
|
||||||
|
onPointerUp={() => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.hover(screen.getByText('Business'));
|
||||||
|
expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await userEvent.hover(screen.getByText('Personal'));
|
||||||
|
expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user