Commit ea7cb0ba by CaIon

refactor(web): unify table cells and quota details

parent ebe4c368
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface TimestampCellProps {
timestamp: number
now?: number
format?: 'relative' | 'absolute'
locale?: string
justNowLabel: string
className?: string
}
export function TimestampCell(props: TimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground'>-</span>
}
const timestampMs = props.timestamp * 1000
const absoluteTime = formatTimestampToDate(props.timestamp)
if (props.format === 'absolute') {
return (
<time
dateTime={new Date(timestampMs).toISOString()}
className={cn('block whitespace-nowrap tabular-nums', props.className)}
>
{absoluteTime}
</time>
)
}
const now = props.now ?? Date.now()
const isJustNow = timestampMs <= now && now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export function ActivityTimeCell(props: {
createdAt: number
lastAt: number
lastLabel: string
lastClassName?: string
now?: number
format?: 'relative' | 'absolute'
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
return (
<div
data-table-text='secondary'
className={cn(
'grid min-w-0 gap-y-1 text-xs font-normal',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<TimestampCell
timestamp={props.createdAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{props.lastLabel}</span>
<TimestampCell
timestamp={props.lastAt}
now={props.now}
format={props.format}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
props.lastClassName ?? 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
)
}
......@@ -145,12 +145,9 @@ function SplitHeaderTableView<TData>({
props.bodyContainerClassName
)}
>
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums',
props.tableClassName
)}
<Table
withContainer={false}
className={props.tableClassName}
style={tableSizing.style}
>
{tableSizing.colgroup}
......@@ -162,7 +159,7 @@ function SplitHeaderTableView<TData>({
getColumnClassName={getColumnClassName}
/>
{renderTableBody(props, rows, colSpan, getColumnClassName)}
</table>
</Table>
</div>
</div>
)
......
......@@ -42,8 +42,8 @@ export function GroupMultiplierBadge(props: {
<Badge
variant='outline'
className={cn(
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-xs leading-none font-medium shadow-none',
!props.label && 'font-mono tabular-nums',
'relative h-5 min-w-12 rounded-full px-1.5 py-0 text-sm leading-none font-medium shadow-none',
!props.label && 'tabular-nums',
colorClassName,
props.className
)}
......@@ -122,7 +122,7 @@ export function GroupBadge(props: GroupBadgeProps) {
return (
<span
className={cn(
'inline-flex max-w-full min-w-0 items-center gap-2 text-xs',
'inline-flex max-w-full min-w-0 items-center gap-2 text-sm',
containerClassName
)}
>
......
......@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ComponentProps } from 'react'
import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/ui/button'
import {
......@@ -23,6 +25,21 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
export function MaskedValueTrigger(props: ComponentProps<typeof Button>) {
return (
<Button
variant='ghost'
size='sm'
{...props}
className={cn(
'text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent',
props.className
)}
/>
)
}
interface MaskedValueDisplayProps {
/** 弹层内标题,如 "Full API Key" / "Full Code" */
......@@ -44,15 +61,7 @@ export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return (
<div className='flex max-w-full min-w-0 items-center'>
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
<PopoverTrigger render={<MaskedValueTrigger />}>
<span className='truncate'>{props.maskedValue}</span>
</PopoverTrigger>
<PopoverContent
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Fragment, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
type QuotaDetailsPopoverProps = {
title: string
triggerLabel: string
details: ReadonlyArray<{ label: string; value: string }>
description?: string
children: ReactNode
afterTrigger?: ReactNode
className?: string
triggerClassName?: string
}
export function QuotaDetailsPopover(props: QuotaDetailsPopoverProps) {
return (
<Popover>
<div className={cn('w-full min-w-0', props.className)}>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={props.triggerLabel}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 py-0.5 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.triggerClassName
)}
/>
}
>
{props.children}
</PopoverTrigger>
{props.afterTrigger}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>{props.title}</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{props.details.map((detail) => (
<Fragment key={detail.label}>
<dt className='text-muted-foreground'>{detail.label}</dt>
<dd className='text-right break-all'>{detail.value}</dd>
</Fragment>
))}
</dl>
{props.description && (
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.description}
</p>
)}
</PopoverContent>
</Popover>
)
}
......@@ -22,17 +22,31 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
function Table({
className,
withContainer = true,
...props
}: React.ComponentProps<'table'> & { withContainer?: boolean }) {
const table = (
<table
data-slot='table'
className={cn(
'w-full caption-bottom text-sm tabular-nums [font-family:var(--font-body)] [&_td]:text-sm [&_td]:font-medium [&_th]:text-sm [&_:is(th,td)_*]:[font-family:inherit] [&_:is(th,td)_*]:[font-size:inherit] [&_:is(th,td)_*]:[font-weight:inherit]',
'[&_[data-table-text=secondary]]:text-xs [&_[data-table-text=secondary]]:font-normal',
className
)}
{...props}
/>
)
if (!withContainer) return table
return (
<div
data-slot='table-container'
className='relative w-full overflow-x-auto overflow-y-hidden'
>
<table
data-slot='table'
className={cn('w-full caption-bottom text-sm tabular-nums', className)}
{...props}
/>
{table}
</div>
)
}
......
......@@ -113,7 +113,7 @@ describe('API key group table cell', () => {
color,
border,
'rounded-full',
'font-mono',
'tabular-nums',
'h-5',
'min-w-12'
)
......
......@@ -166,7 +166,7 @@ describe('API key group combobox Auto effect', () => {
'h-5',
'min-w-12',
'rounded-full',
'font-mono',
'tabular-nums',
'border-muted-foreground/30'
)
expect(defaultRatio).not.toHaveTextContent('Ratio')
......
......@@ -508,7 +508,7 @@ it('keeps mobile quota readable and opens complete model and IP restrictions by
'font-normal'
)
expect(within(quota).getByText('4,490.16')).toHaveClass(
'font-mono',
'tabular-nums',
'text-sm',
'font-normal',
'text-right'
......
......@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import { Progress } from '@/components/ui/progress'
import { toIntlLocale } from '@/i18n/languages'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
......@@ -78,78 +72,45 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
? `${t('Unlimited')}; ${usageDescription}`
: `${remainingDescription}; ${usageDescription}`
const details = []
if (!props.apiKey.unlimited_quota) {
details.push({ label: t('Remaining'), value: formattedRemaining })
}
details.push({ label: t('Used amount'), value: formattedUsed })
if (!props.apiKey.unlimited_quota) {
details.push({ label: t('Current total quota'), value: formattedTotal })
}
if (hasProgress) {
details.push({
label: t('Remaining percentage'),
value: `${formattedPercentage}%`,
})
}
return (
<Popover>
<div
className={cn(
'w-full min-w-0',
props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5'
)}
>
<PopoverTrigger
render={
<Button
variant='ghost'
aria-label={
props.variant === 'card'
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
: triggerLabel
}
className={cn(
'h-auto w-full min-w-0 justify-start px-0 text-left font-normal hover:bg-transparent aria-expanded:bg-transparent',
props.variant === 'card' ? 'py-0' : 'py-0.5'
)}
/>
}
>
<span
data-slot='api-key-quota-values'
className={cn(
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-xs',
props.variant === 'card'
? 'grid-cols-[auto_minmax(0,1fr)]'
: 'grid-cols-2'
)}
>
{props.variant === 'card' && (
<span className='text-muted-foreground'>
{t('Remaining')}
<span className='ml-1'>({quotaUnit})</span>
</span>
)}
<span
className={cn(
'min-w-0 truncate',
props.variant === 'card'
? 'text-right text-sm leading-5 font-normal'
: 'text-left font-medium',
!props.apiKey.unlimited_quota && 'font-mono tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
>
{props.apiKey.unlimited_quota
? t('Unlimited')
: formattedRemaining}
</span>
{props.variant === 'card' && (
<span className='text-muted-foreground'>{t('Used amount')}</span>
)}
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right font-mono tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
>
{formattedUsed}
</span>
</span>
</PopoverTrigger>
{!props.apiKey.unlimited_quota && (
<QuotaDetailsPopover
title={`${t('Quota')} (${quotaUnit})`}
triggerLabel={
props.variant === 'card'
? `${t('Quota')} (${quotaUnit}); ${triggerLabel}`
: triggerLabel
}
details={details}
description={
props.apiKey.unlimited_quota
? t(
'This API key has no quota limit. Requests still require available wallet or subscription quota.'
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)
}
className={
props.variant === 'card' ? 'space-y-2.5' : 'max-w-45 space-y-1.5'
}
triggerClassName={props.variant === 'card' ? 'py-0' : undefined}
afterTrigger={
!props.apiKey.unlimited_quota && (
<Progress
value={percentage}
aria-label={t('Remaining percentage')}
......@@ -158,55 +119,53 @@ export function ApiKeyQuotaCell(props: ApiKeyQuotaCellProps) {
progressColor
)}
/>
)
}
>
<span
data-slot='api-key-quota-values'
className={cn(
'grid w-full min-w-0 items-baseline gap-x-2 gap-y-1 text-sm',
props.variant === 'card'
? 'grid-cols-[auto_minmax(0,1fr)]'
: 'grid-cols-2'
)}
</div>
<PopoverContent
align='start'
className='w-72 max-w-[calc(100vw-2rem)] gap-3 p-3'
>
<PopoverTitle>
{t('Quota')} ({quotaUnit})
</PopoverTitle>
<dl className='grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 text-sm tabular-nums'>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>{t('Remaining')}</dt>
<dd className='text-right font-mono break-all'>
{formattedRemaining}
</dd>
</>
)}
<dt className='text-muted-foreground'>{t('Used amount')}</dt>
<dd className='text-right font-mono break-all'>{formattedUsed}</dd>
{!props.apiKey.unlimited_quota && (
<>
<dt className='text-muted-foreground'>
{t('Current total quota')}
</dt>
<dd className='text-right font-mono break-all'>
{formattedTotal}
</dd>
</>
{props.variant === 'card' && (
<span className='text-muted-foreground'>
{t('Remaining')}
<span className='ml-1'>({quotaUnit})</span>
</span>
)}
<span
className={cn(
'min-w-0 truncate',
props.variant === 'card'
? 'text-right text-sm leading-5 font-normal'
: 'text-left font-medium',
!props.apiKey.unlimited_quota && 'tabular-nums',
!props.apiKey.unlimited_quota &&
remaining < 0 &&
'text-destructive',
remaining === 0 &&
!props.apiKey.unlimited_quota &&
'text-muted-foreground'
)}
{hasProgress && (
<>
<dt className='text-muted-foreground'>
{t('Remaining percentage')}
</dt>
<dd className='text-right font-mono'>{formattedPercentage}%</dd>
</>
>
{props.apiKey.unlimited_quota ? t('Unlimited') : formattedRemaining}
</span>
{props.variant === 'card' && (
<span className='text-muted-foreground'>{t('Used amount')}</span>
)}
<span
className={cn(
'text-muted-foreground min-w-0 truncate text-right tabular-nums',
props.variant === 'card' && 'text-sm leading-5 font-normal'
)}
</dl>
<p className='text-muted-foreground text-xs leading-relaxed'>
{props.apiKey.unlimited_quota
? t(
'This API key has no quota limit. Requests still require available wallet or subscription quota.'
)
: t(
'Total = used + remaining. It is not an initial allocation or a periodic budget; changing the remaining quota changes the total and percentage.'
)}
</p>
</PopoverContent>
</Popover>
>
{formattedUsed}
</span>
</span>
</QuotaDetailsPopover>
)
}
......@@ -18,108 +18,32 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { toIntlLocale } from '@/i18n/languages'
import { ActivityTimeCell } from '@/components/activity-time-cell'
import dayjs from '@/lib/dayjs'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import type { ApiKey } from '../types'
interface ApiKeyTimestampCellProps {
timestamp: number
now: number
locale?: string
justNowLabel: string
className?: string
}
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
if (!props.timestamp || props.timestamp === -1) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const timestampMs = props.timestamp * 1000
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
const relativeTime = isJustNow
? props.justNowLabel
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
const [relativePrefix, relativeNumber, relativeSuffix] = relativeTime.split(
/(\p{Number}+(?:[.,\u00a0\u202f]\p{Number}+)*)/u
)
const absoluteTime = formatTimestampToDate(props.timestamp)
return (
<Tooltip>
<TooltipTrigger
render={
<time
dateTime={new Date(timestampMs).toISOString()}
tabIndex={0}
className={cn('block truncate text-xs', props.className)}
/>
}
>
{relativePrefix}
{relativeNumber && (
<span className='font-mono tabular-nums'>{relativeNumber}</span>
)}
{relativeSuffix}
</TooltipTrigger>
<TooltipContent>
<span className='font-mono tabular-nums'>{absoluteTime}</span>
</TooltipContent>
</Tooltip>
)
}
export { TimestampCell as ApiKeyTimestampCell } from '@/components/activity-time-cell'
export function ApiKeyActivityCell(props: {
apiKey: ApiKey
now: number
layout?: 'rows' | 'columns'
}) {
const { t, i18n } = useTranslation()
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
const { t } = useTranslation()
const accessedTime = props.apiKey.accessed_time
const isStale =
accessedTime > 0 &&
accessedTime * 1000 < dayjs(props.now).subtract(3, 'month').valueOf()
return (
<div
className={cn(
'grid min-w-0 gap-y-1 text-xs',
props.layout === 'columns'
? 'grid-flow-col grid-cols-2 grid-rows-[auto_1fr] items-start gap-x-3'
: 'grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2'
)}
>
<span className='text-muted-foreground'>{t('Created')}</span>
<ApiKeyTimestampCell
timestamp={props.apiKey.created_time}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
<span className='text-muted-foreground'>{t('Last Used')}</span>
<ApiKeyTimestampCell
timestamp={accessedTime}
now={props.now}
locale={locale}
justNowLabel={t('Just now')}
className={cn(
isStale ? 'text-warning' : 'text-muted-foreground',
props.layout === 'columns' && 'whitespace-normal'
)}
/>
</div>
<ActivityTimeCell
createdAt={props.apiKey.created_time}
lastAt={accessedTime}
lastLabel={t('Last Used')}
lastClassName={isStale ? 'text-warning' : 'text-muted-foreground'}
now={props.now}
layout={props.layout}
/>
)
}
......@@ -21,6 +21,7 @@ import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { BadgeCell } from '@/components/data-table'
import { MaskedValueTrigger } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
......@@ -86,15 +87,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
return (
<div className='flex max-w-full min-w-0 items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
<PopoverTrigger render={<MaskedValueTrigger />}>
<span className='truncate'>{maskedKey}</span>
</PopoverTrigger>
<PopoverContent
......
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ColumnDef } from '@tanstack/react-table'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { MaskedValueDisplay } from '@/components/masked-value-display'
......@@ -32,7 +32,7 @@ import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types'
import type { Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
......@@ -135,7 +135,7 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
{
id: 'code',
accessorKey: 'key',
header: t('Code'),
header: t('Redemption Code'),
cell: function CodeCell({ row }) {
const redemption = row.original
const key = redemption.key
......
......@@ -42,8 +42,10 @@ import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import zh from '@/i18n/locales/zh.json'
import { api } from '@/lib/api'
import { formatTimestampToDate } from '@/lib/format'
import { useAuthStore } from '@/stores/auth-store'
import {
DEFAULT_CURRENCY_CONFIG,
......@@ -132,7 +134,7 @@ afterEach(() => {
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
})
it('shows labeled balance and cumulative usage in one sortable quota column', () => {
it('shows balance above secondary usage text and opens quota details on click', async () => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={1900} used={1100} />
......@@ -148,32 +150,58 @@ it('shows labeled balance and cumulative usage in one sortable quota column', ()
expect(
within(cells[0]).queryByText('Available Balance')
).not.toBeInTheDocument()
expect(screen.getByText('0.0038').parentElement).toHaveClass('text-left')
expect(within(cells[0]).getByText('0.0022')).toBeInTheDocument()
expect(screen.getByText('0.0038').parentElement).toHaveClass('grid-cols-1')
expect(screen.getByText('Used amount').parentElement).toHaveAttribute(
'data-table-text',
'secondary'
)
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText('0.006')).not.toBeInTheDocument()
const trigger = screen.getByRole('button', {
name: 'Available Balance 0.0038; Used amount 0.0022',
})
await userEvent.click(trigger)
const detail = await screen.findByRole('dialog', { name: 'Quota ($)' })
expect(within(detail).getByText('Available Balance')).toBeInTheDocument()
expect(within(detail).getByText('Total Used')).toBeInTheDocument()
expect(within(detail).getByText('0.0038')).toBeInTheDocument()
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
expect(within(detail).queryByRole('progressbar')).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
)
expect(trigger).toHaveFocus()
})
it.each([0, 500000])(
'shows usage for a zero balance only when used quota is nonzero (used=%s)',
(used) => {
async (used) => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={0} used={used} />
</I18nextProvider>
)
if (used === 0) {
expect(screen.getByRole('cell')).toHaveTextContent(/^No Quota$/)
expect(screen.getAllByRole('cell')[0]).toHaveTextContent(/^No Quota$/)
expect(screen.queryByText('Used amount')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'No Quota' }))
const detail = await screen.findByRole('dialog', { name: 'Quota ($)' })
expect(within(detail).getAllByText('0')).toHaveLength(2)
return
}
expect(screen.queryByText('No Quota')).not.toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('0')).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('1')).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('0')
).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('1')
).toBeInTheDocument()
}
)
it('preserves a negative balance and uses warning styling', () => {
it('preserves negative balances in details opened with the keyboard', async () => {
render(
<I18nextProvider i18n={i18n}>
<QuotaTable remaining={-500000} used={1000000} />
......@@ -181,6 +209,11 @@ it('preserves a negative balance and uses warning styling', () => {
)
expect(screen.getByText('-1')).toHaveClass('text-destructive')
expect(screen.getByText('2')).toBeInTheDocument()
await userEvent.tab()
await userEvent.keyboard('{Enter}')
const detail = await screen.findByRole('dialog')
expect(within(detail).getByText('-1')).toBeInTheDocument()
expect(within(detail).getByText('2')).toBeInTheDocument()
})
it('shows the custom symbol only in the column header', () => {
......@@ -200,20 +233,24 @@ it('shows the custom symbol only in the column header', () => {
expect(
screen.getByRole('columnheader', { name: 'Available Balance (🐱)' })
).toBeInTheDocument()
expect(screen.getByRole('cell')).not.toHaveTextContent('🐱')
for (const cell of screen.getAllByRole('cell')) {
expect(cell).not.toHaveTextContent('🐱')
}
expect(
within(screen.getByRole('cell')).getByText('0.0038')
within(screen.getAllByRole('cell')[0]).getByText('0.0038')
).toBeInTheDocument()
expect(
within(screen.getByRole('cell')).getByText('0.0022')
within(screen.getAllByRole('cell')[0]).getByText('0.0022')
).toBeInTheDocument()
})
function UsersPage() {
return (
<UsersProvider>
<UsersTable />
</UsersProvider>
<TooltipProvider>
<UsersProvider>
<UsersTable />
</UsersProvider>
</TooltipProvider>
)
}
......@@ -233,6 +270,8 @@ async function renderUsersList(emptyInvitation = false) {
quota: 1900,
used_quota: 1100,
request_count: 0,
created_at: Math.floor(Date.now() / 1000) - 86400,
last_login_at: Math.floor(Date.now() / 1000) - 20,
group: 'default',
aff_count: emptyInvitation ? 0 : 2,
aff_history_quota: emptyInvitation ? 0 : 500000,
......@@ -291,7 +330,7 @@ it('sends balance sorting to the server and keeps invitation details on two line
expect(screen.getByText(/Invited 2 users · Earnings:/)).toBeInTheDocument()
})
it('shows both labeled amounts on mobile cards in Chinese', async () => {
it('shows balance above usage on mobile cards in Chinese', async () => {
const originalMatchMedia = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...originalMatchMedia(query),
......@@ -306,16 +345,22 @@ it('shows both labeled amounts on mobile cards in Chinese', async () => {
expect(screen.getByText('0.0038')).toBeInTheDocument()
expect(screen.getByText('0.0022')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
await userEvent.click(
screen.getByRole('button', { name: /可用余额 0.0038/ })
)
const detail = await screen.findByRole('dialog', { name: '额度 ($)' })
expect(within(detail).getByText('累计已用')).toBeInTheDocument()
expect(within(detail).getByText('0.0022')).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
} finally {
await i18n.changeLanguage('en')
}
})
it('hides date columns by default and replaces empty invitation information with a dash', async () => {
it('combines creation and last login into one column with full dates visible directly', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.UTC(2026, 8, 8, 12))
await renderUsersList(true)
expect(
screen.queryByRole('columnheader', { name: /Created At/ })
).not.toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: /Time/ })).toBeInTheDocument()
expect(
screen.queryByRole('columnheader', { name: /Last Login/ })
).not.toBeInTheDocument()
......@@ -323,14 +368,23 @@ it('hides date columns by default and replaces empty invitation information with
name: /long-user-name-for-table-layout/,
})
expect(within(row).getByText('—')).toBeInTheDocument()
const times = within(row).getAllByRole('time')
expect(times).toHaveLength(2)
expect(times[0]).toHaveTextContent(
formatTimestampToDate(Math.floor(Date.now() / 1000) - 86400)
)
expect(times[1]).toHaveTextContent(
formatTimestampToDate(Math.floor(Date.now() / 1000) - 20)
)
expect(within(row).queryByText('Just now')).not.toBeInTheDocument()
expect(within(row).getByText('Created')).toBeInTheDocument()
expect(within(row).getByText('Last Login')).toBeInTheDocument()
expect(screen.queryByText('No Inviter')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'View' }))
await userEvent.click(
screen.getByRole('menuitemcheckbox', { name: 'Created At' })
)
await userEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Time' }))
expect(
screen.getByRole('columnheader', { name: /Created At/ })
).toBeInTheDocument()
screen.queryByRole('columnheader', { name: /Time/ })
).not.toBeInTheDocument()
})
it('updates the header unit and converted amounts together when currency settings change', () => {
......@@ -351,9 +405,15 @@ it('updates the header unit and converted amounts together when currency setting
expect(
screen.getByRole('columnheader', { name: 'Available Balance (¥)' })
).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('7')).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('14')).toBeInTheDocument()
expect(screen.getByRole('cell')).not.toHaveTextContent('¥')
expect(
within(screen.getAllByRole('cell')[0]).getByText('7')
).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('14')
).toBeInTheDocument()
for (const cell of screen.getAllByRole('cell')) {
expect(cell).not.toHaveTextContent('¥')
}
})
it('labels raw quota mode as tokens without introducing a currency symbol', () => {
......@@ -368,6 +428,10 @@ it('labels raw quota mode as tokens without introducing a currency symbol', () =
expect(
screen.getByRole('columnheader', { name: 'Available Balance (Tokens)' })
).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('100')).toBeInTheDocument()
expect(within(screen.getByRole('cell')).getByText('200')).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('100')
).toBeInTheDocument()
expect(
within(screen.getAllByRole('cell')[0]).getByText('200')
).toBeInTheDocument()
})
......@@ -18,8 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { QuotaDetailsPopover } from '@/components/quota-details-popover'
import { StatusBadge } from '@/components/status-badge'
import { formatQuotaWithCurrency } from '@/lib/currency'
import { formatQuotaWithCurrency, getCurrencyDisplay } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { useSystemConfigStore } from '@/stores/system-config-store'
......@@ -32,34 +33,55 @@ export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation()
useSystemConfigStore((state) => state.config.currency)
if (props.remaining === 0 && props.used === 0) {
return (
<StatusBadge
label={t('No Quota')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
}
const { meta: currency } = getCurrencyDisplay()
const quotaUnit = currency.kind === 'tokens' ? t('Tokens') : currency.symbol
const hasQuota = props.remaining !== 0 || props.used !== 0
const formattedRemaining = formatQuotaWithCurrency(props.remaining, {
showSymbol: false,
})
const formattedUsed = formatQuotaWithCurrency(props.used, {
showSymbol: false,
})
return (
<div className='min-w-0 space-y-1 text-left tabular-nums'>
<div
className={cn(
'font-mono text-sm font-semibold whitespace-nowrap',
props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
>
{formatQuotaWithCurrency(props.remaining, { showSymbol: false })}
</div>
<div className='text-muted-foreground flex items-baseline gap-1 text-xs whitespace-nowrap'>
<span>{t('Used amount')}</span>
<span className='font-mono'>
{formatQuotaWithCurrency(props.used, { showSymbol: false })}
<QuotaDetailsPopover
title={`${t('Quota')} (${quotaUnit})`}
triggerLabel={
hasQuota
? `${t('Available Balance')} ${formattedRemaining}; ${t('Used amount')} ${formattedUsed}`
: t('No Quota')
}
details={[
{ label: t('Available Balance'), value: formattedRemaining },
{ label: t('Total Used'), value: formattedUsed },
]}
>
{hasQuota ? (
<span className='grid min-w-0 grid-cols-1 gap-y-1 text-sm tabular-nums'>
<span
className={cn(
props.remaining < 0 && 'text-destructive',
props.remaining === 0 && 'text-muted-foreground'
)}
>
{formattedRemaining}
</span>
<span
data-table-text='secondary'
className='text-muted-foreground flex items-baseline gap-1 text-xs font-normal'
>
<span>{t('Used amount')}</span>
<span>{formattedUsed}</span>
</span>
</span>
</div>
</div>
) : (
<StatusBadge
label={t('No Quota')}
variant='neutral'
copyable={false}
className='-ml-1.5 font-normal'
/>
)}
</QuotaDetailsPopover>
)
}
......@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { ActivityTimeCell } from '@/components/activity-time-cell'
import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text'
......@@ -31,7 +32,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getCurrencyDisplay } from '@/lib/currency'
import { formatQuota, formatTimestamp } from '@/lib/format'
import { formatQuota } from '@/lib/format'
import { useSystemConfigStore } from '@/stores/system-config-store'
import {
......@@ -80,7 +81,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
return (
<TableId
value={row.getValue('id') as number}
className='w-[60px] text-sm'
className='w-[60px] [font-family:inherit] text-sm'
/>
)
},
......@@ -98,13 +99,19 @@ export function useUsersColumns(): ColumnDef<User>[] {
return (
<div className='flex min-w-[160px] flex-col gap-1'>
<div className='flex items-center gap-2'>
<LongText className='max-w-[140px] font-medium'>
<LongText className='max-w-[140px] text-sm font-normal'>
{username}
</LongText>
{remark && (
<Tooltip>
<TooltipTrigger
render={<StatusBadge variant='success' copyable={false} />}
render={
<StatusBadge
variant='success'
copyable={false}
className='font-normal'
/>
}
>
<LongText className='max-w-[80px]'>{remark}</LongText>
</TooltipTrigger>
......@@ -115,9 +122,12 @@ export function useUsersColumns(): ColumnDef<User>[] {
)}
</div>
{displayName && displayName !== username && (
<LongText className='text-muted-foreground max-w-[180px] text-xs'>
{displayName}
</LongText>
<div
data-table-text='secondary'
className='text-muted-foreground max-w-[180px] text-xs font-normal'
>
<LongText>{displayName}</LongText>
</div>
)}
</div>
)
......@@ -148,6 +158,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
label={t(statusConfig.labelKey)}
variant={isUserDeleted(user) ? 'neutral' : statusConfig.variant}
copyable={false}
className='font-normal'
/>
</TooltipTrigger>
<TooltipContent>
......@@ -184,7 +195,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
const group = row.getValue('group') as string
return (
<BadgeCell>
<GroupBadge group={group} />
<GroupBadge group={group} className='font-normal' />
</BadgeCell>
)
},
......@@ -230,7 +241,10 @@ export function useUsersColumns(): ColumnDef<User>[] {
}
return (
<div className='min-w-0 space-y-1 text-xs'>
<div
data-table-text='secondary'
className='min-w-0 space-y-1 text-xs font-normal'
>
{(affCount > 0 || affHistoryQuota !== 0) && (
<LongText>
{t('Invited {{count}} users', { count: affCount })} ·{' '}
......@@ -254,30 +268,17 @@ export function useUsersColumns(): ColumnDef<User>[] {
},
{
accessorKey: 'created_at',
header: t('Created At'),
cell: ({ row }) => {
const ts = row.getValue('created_at') as number | undefined
return (
<span className='text-muted-foreground text-sm'>
{ts ? formatTimestamp(ts) : '-'}
</span>
)
},
size: 180,
meta: { mobileHidden: true },
},
{
accessorKey: 'last_login_at',
header: t('Last Login'),
cell: ({ row }) => {
const ts = row.getValue('last_login_at') as number | undefined
return (
<span className='text-muted-foreground text-sm'>
{ts ? formatTimestamp(ts) : '-'}
</span>
)
},
size: 180,
header: t('Time'),
cell: ({ row }) => (
<ActivityTimeCell
createdAt={row.original.created_at ?? 0}
lastAt={row.original.last_login_at ?? 0}
lastLabel={t('Last Login')}
format='absolute'
/>
),
size: 260,
minSize: 240,
meta: { mobileHidden: true },
},
{
......
......@@ -174,7 +174,6 @@ export function UsersTable() {
data: users,
columns,
enableRowSelection: true,
initialColumnVisibility: { created_at: false, last_login_at: false },
columnFilters,
globalFilter,
pagination,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment