feat(enrichment): WP-7 UI components — LabelSyncSettings, DependencyList, MetricsDashboard
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
35aaf892ec
commit
ea16cd29fd
@@ -0,0 +1,119 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { IssueDependencies } from '../../../../shared/types/dependencies';
|
||||
import { hasDependencies, totalDependencyCount } from '../../../../shared/types/dependencies';
|
||||
|
||||
interface DependencyListProps {
|
||||
dependencies: IssueDependencies;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function DependencyList({
|
||||
dependencies,
|
||||
isLoading,
|
||||
error,
|
||||
onRefresh,
|
||||
}: DependencyListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const hasData = hasDependencies(dependencies);
|
||||
const total = totalDependencyCount(dependencies);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic" role="status">
|
||||
{t('dependencies.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-destructive" role="alert">{error}</p>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{t('dependencies.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t('dependencies.none')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" role="region" aria-label={t('dependencies.title')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{t('dependencies.title')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{dependencies.tracks.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{t('dependencies.tracks')}
|
||||
</span>
|
||||
<ul className="space-y-0.5">
|
||||
{dependencies.tracks.map((dep) => (
|
||||
<li
|
||||
key={`track-${dep.issueNumber}-${dep.repo ?? 'local'}`}
|
||||
className="flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
dep.state === 'open' ? 'bg-green-500' : 'bg-purple-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-foreground">
|
||||
{dep.repo ? `${dep.repo}#${dep.issueNumber}` : `#${dep.issueNumber}`}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">{dep.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dependencies.trackedBy.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{t('dependencies.trackedBy')}
|
||||
</span>
|
||||
<ul className="space-y-0.5">
|
||||
{dependencies.trackedBy.map((dep) => (
|
||||
<li
|
||||
key={`trackedBy-${dep.issueNumber}-${dep.repo ?? 'local'}`}
|
||||
className="flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
dep.state === 'open' ? 'bg-green-500' : 'bg-purple-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-foreground">
|
||||
{dep.repo ? `${dep.repo}#${dep.issueNumber}` : `#${dep.issueNumber}`}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">{dep.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LabelSyncSettingsProps {
|
||||
enabled: boolean;
|
||||
isSyncing: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
error: string | null;
|
||||
onEnable: () => void;
|
||||
onDisable: (cleanup: boolean) => void;
|
||||
}
|
||||
|
||||
export function LabelSyncSettings({
|
||||
enabled,
|
||||
isSyncing,
|
||||
lastSyncedAt,
|
||||
error,
|
||||
onEnable,
|
||||
onDisable,
|
||||
}: LabelSyncSettingsProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<div className="space-y-3" role="region" aria-label={t('labelSync.settings')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t('labelSync.title')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('labelSync.description')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
enabled
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
}`}
|
||||
onClick={() => enabled ? onDisable(false) : onEnable()}
|
||||
disabled={isSyncing}
|
||||
aria-busy={isSyncing}
|
||||
>
|
||||
{isSyncing
|
||||
? t('labelSync.syncing')
|
||||
: enabled
|
||||
? t('labelSync.disable')
|
||||
: t('labelSync.enable')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{enabled && lastSyncedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('labelSync.lastSynced', { date: new Date(lastSyncedAt).toLocaleDateString() })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-destructive hover:underline"
|
||||
onClick={() => onDisable(true)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{t('labelSync.disableAndCleanup')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TriageMetrics, MetricsTimeWindow } from '../../../../shared/types/metrics';
|
||||
import { formatDuration } from '../../../../shared/types/metrics';
|
||||
import {
|
||||
WORKFLOW_STATE_COLORS,
|
||||
WORKFLOW_STATE_LABELS,
|
||||
} from '../../../../shared/constants/enrichment';
|
||||
import type { WorkflowState } from '../../../../shared/types/enrichment';
|
||||
|
||||
interface MetricsDashboardProps {
|
||||
metrics: TriageMetrics;
|
||||
timeWindow: MetricsTimeWindow;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onTimeWindowChange: (window: MetricsTimeWindow) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const TIME_WINDOWS: { value: MetricsTimeWindow; label: string }[] = [
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
];
|
||||
|
||||
const STATE_ORDER: WorkflowState[] = ['new', 'triage', 'ready', 'in_progress', 'review', 'done', 'blocked'];
|
||||
|
||||
export function MetricsDashboard({
|
||||
metrics,
|
||||
timeWindow,
|
||||
isLoading,
|
||||
error,
|
||||
onTimeWindowChange,
|
||||
onRefresh,
|
||||
}: MetricsDashboardProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const totalIssues = STATE_ORDER.reduce((sum, s) => sum + (metrics.stateCounts[s] ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" role="region" aria-label={t('metrics.title')}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t('metrics.title')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded border border-border overflow-hidden" role="radiogroup" aria-label={t('metrics.timeWindow')}>
|
||||
{TIME_WINDOWS.map((tw) => (
|
||||
<button
|
||||
key={tw.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={timeWindow === tw.value}
|
||||
className={`px-2 py-0.5 text-xs transition-colors ${
|
||||
timeWindow === tw.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => onTimeWindowChange(tw.value)}
|
||||
>
|
||||
{tw.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{isLoading ? t('metrics.computing') : t('metrics.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" role="alert">{error}</p>
|
||||
)}
|
||||
|
||||
{/* State Distribution */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t('metrics.stateDistribution')}
|
||||
</h4>
|
||||
<div className="flex h-3 rounded-full overflow-hidden bg-muted">
|
||||
{STATE_ORDER.map((state) => {
|
||||
const count = metrics.stateCounts[state] ?? 0;
|
||||
const pct = totalIssues > 0 ? (count / totalIssues) * 100 : 0;
|
||||
if (pct === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={state}
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: WORKFLOW_STATE_COLORS[state],
|
||||
}}
|
||||
title={`${WORKFLOW_STATE_LABELS[state]}: ${count}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{STATE_ORDER.map((state) => {
|
||||
const count = metrics.stateCounts[state] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div key={state} className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: WORKFLOW_STATE_COLORS[state] }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{WORKFLOW_STATE_LABELS[state]}</span>
|
||||
<span className="text-foreground font-medium">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completeness Distribution */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t('metrics.completeness')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(['low', 'medium', 'high', 'excellent'] as const).map((cat) => (
|
||||
<div key={cat} className="text-center">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{metrics.completenessDistribution[cat]}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">
|
||||
{t(`metrics.completeness_${cat}`)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="text-center p-2 bg-card rounded border border-border">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{metrics.totalTransitions}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('metrics.transitions')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-card rounded border border-border">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{metrics.avgBacklogAge > 0 ? formatDuration(metrics.avgBacklogAge) : '—'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('metrics.avgBacklog')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-card rounded border border-border">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{metrics.weeklyThroughput.length > 0
|
||||
? metrics.weeklyThroughput[metrics.weeklyThroughput.length - 1].count
|
||||
: 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('metrics.thisWeek')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { DependencyList } from '../DependencyList';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DependencyList', () => {
|
||||
const emptyDeps = { tracks: [], trackedBy: [] };
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(<DependencyList dependencies={emptyDeps} isLoading error={null} />);
|
||||
expect(screen.getByRole('status').textContent).toBe('dependencies.loading');
|
||||
});
|
||||
|
||||
it('shows error with retry button', () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<DependencyList
|
||||
dependencies={emptyDeps}
|
||||
isLoading={false}
|
||||
error="API unavailable"
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').textContent).toBe('API unavailable');
|
||||
fireEvent.click(screen.getByText('dependencies.retry'));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows empty state when no dependencies', () => {
|
||||
render(<DependencyList dependencies={emptyDeps} isLoading={false} error={null} />);
|
||||
expect(screen.getByText('dependencies.none')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders tracks', () => {
|
||||
const deps = {
|
||||
tracks: [
|
||||
{ issueNumber: 10, title: 'Sub-task A', state: 'open' as const },
|
||||
{ issueNumber: 11, title: 'Sub-task B', state: 'closed' as const },
|
||||
],
|
||||
trackedBy: [],
|
||||
};
|
||||
render(<DependencyList dependencies={deps} isLoading={false} error={null} />);
|
||||
expect(screen.getByText('#10')).toBeDefined();
|
||||
expect(screen.getByText('#11')).toBeDefined();
|
||||
expect(screen.getByText('Sub-task A')).toBeDefined();
|
||||
expect(screen.getByText('Sub-task B')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders trackedBy', () => {
|
||||
const deps = {
|
||||
tracks: [],
|
||||
trackedBy: [
|
||||
{ issueNumber: 5, title: 'Parent Issue', state: 'open' as const },
|
||||
],
|
||||
};
|
||||
render(<DependencyList dependencies={deps} isLoading={false} error={null} />);
|
||||
expect(screen.getByText('#5')).toBeDefined();
|
||||
expect(screen.getByText('Parent Issue')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows cross-repo reference', () => {
|
||||
const deps = {
|
||||
tracks: [
|
||||
{ issueNumber: 10, title: 'Cross-repo', state: 'open' as const, repo: 'org/other' },
|
||||
],
|
||||
trackedBy: [],
|
||||
};
|
||||
render(<DependencyList dependencies={deps} isLoading={false} error={null} />);
|
||||
expect(screen.getByText('org/other#10')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows total count', () => {
|
||||
const deps = {
|
||||
tracks: [{ issueNumber: 1, title: 'A', state: 'open' as const }],
|
||||
trackedBy: [{ issueNumber: 2, title: 'B', state: 'open' as const }],
|
||||
};
|
||||
render(<DependencyList dependencies={deps} isLoading={false} error={null} />);
|
||||
expect(screen.getByText('2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('has accessible region role', () => {
|
||||
const deps = {
|
||||
tracks: [{ issueNumber: 1, title: 'A', state: 'open' as const }],
|
||||
trackedBy: [],
|
||||
};
|
||||
render(<DependencyList dependencies={deps} isLoading={false} error={null} />);
|
||||
expect(screen.getByRole('region')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows state indicator dot with correct color', () => {
|
||||
const deps = {
|
||||
tracks: [
|
||||
{ issueNumber: 10, title: 'Open', state: 'open' as const },
|
||||
{ issueNumber: 11, title: 'Closed', state: 'closed' as const },
|
||||
],
|
||||
trackedBy: [],
|
||||
};
|
||||
const { container } = render(
|
||||
<DependencyList dependencies={deps} isLoading={false} error={null} />,
|
||||
);
|
||||
const dots = container.querySelectorAll('.rounded-full');
|
||||
expect(dots.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { LabelSyncSettings } from '../LabelSyncSettings';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('LabelSyncSettings', () => {
|
||||
const defaultProps = {
|
||||
enabled: false,
|
||||
isSyncing: false,
|
||||
lastSyncedAt: null,
|
||||
error: null,
|
||||
onEnable: vi.fn(),
|
||||
onDisable: vi.fn(),
|
||||
};
|
||||
|
||||
it('renders enable button when disabled', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} />);
|
||||
expect(screen.getByText('labelSync.enable')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders disable button when enabled', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} enabled />);
|
||||
expect(screen.getByText('labelSync.disable')).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls onEnable when enable clicked', () => {
|
||||
const onEnable = vi.fn();
|
||||
render(<LabelSyncSettings {...defaultProps} onEnable={onEnable} />);
|
||||
fireEvent.click(screen.getByText('labelSync.enable'));
|
||||
expect(onEnable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onDisable(false) when disable clicked', () => {
|
||||
const onDisable = vi.fn();
|
||||
render(<LabelSyncSettings {...defaultProps} enabled onDisable={onDisable} />);
|
||||
fireEvent.click(screen.getByText('labelSync.disable'));
|
||||
expect(onDisable).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows syncing state', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} isSyncing />);
|
||||
expect(screen.getByText('labelSync.syncing')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows last synced date when enabled', () => {
|
||||
render(
|
||||
<LabelSyncSettings
|
||||
{...defaultProps}
|
||||
enabled
|
||||
lastSyncedAt="2026-01-15T00:00:00Z"
|
||||
/>,
|
||||
);
|
||||
// Translation key is used directly in tests
|
||||
expect(screen.getByText(/labelSync\.lastSynced/)).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows cleanup option when enabled', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} enabled />);
|
||||
expect(screen.getByText('labelSync.disableAndCleanup')).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls onDisable(true) when cleanup clicked', () => {
|
||||
const onDisable = vi.fn();
|
||||
render(<LabelSyncSettings {...defaultProps} enabled onDisable={onDisable} />);
|
||||
fireEvent.click(screen.getByText('labelSync.disableAndCleanup'));
|
||||
expect(onDisable).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} error="Rate limited" />);
|
||||
expect(screen.getByRole('alert').textContent).toBe('Rate limited');
|
||||
});
|
||||
|
||||
it('has accessible region role', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} />);
|
||||
expect(screen.getByRole('region')).toBeDefined();
|
||||
});
|
||||
|
||||
it('disables buttons while syncing', () => {
|
||||
render(<LabelSyncSettings {...defaultProps} isSyncing />);
|
||||
const button = screen.getByText('labelSync.syncing');
|
||||
expect(button.getAttribute('disabled')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MetricsDashboard } from '../MetricsDashboard';
|
||||
import { createEmptyMetrics } from '../../../../../shared/types/metrics';
|
||||
import type { TriageMetrics } from '../../../../../shared/types/metrics';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeMetrics(overrides?: Partial<TriageMetrics>): TriageMetrics {
|
||||
return { ...createEmptyMetrics(), ...overrides };
|
||||
}
|
||||
|
||||
describe('MetricsDashboard', () => {
|
||||
const defaultProps = {
|
||||
metrics: makeMetrics(),
|
||||
timeWindow: '30d' as const,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
onTimeWindowChange: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
it('renders title', () => {
|
||||
render(<MetricsDashboard {...defaultProps} />);
|
||||
expect(screen.getByText('metrics.title')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders time window radio buttons', () => {
|
||||
render(<MetricsDashboard {...defaultProps} />);
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect(radios).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('highlights current time window', () => {
|
||||
render(<MetricsDashboard {...defaultProps} timeWindow="7d" />);
|
||||
const radio7d = screen.getAllByRole('radio').find((el) => el.textContent === '7 days');
|
||||
expect(radio7d?.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('calls onTimeWindowChange', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MetricsDashboard {...defaultProps} onTimeWindowChange={onChange} />);
|
||||
fireEvent.click(screen.getByText('All time'));
|
||||
expect(onChange).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it('shows refresh button', () => {
|
||||
render(<MetricsDashboard {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('metrics.refresh'));
|
||||
expect(defaultProps.onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows computing state', () => {
|
||||
render(<MetricsDashboard {...defaultProps} isLoading />);
|
||||
expect(screen.getByText('metrics.computing')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(<MetricsDashboard {...defaultProps} error="Failed" />);
|
||||
expect(screen.getByRole('alert').textContent).toBe('Failed');
|
||||
});
|
||||
|
||||
it('renders state counts', () => {
|
||||
const metrics = makeMetrics({
|
||||
stateCounts: { new: 5, triage: 3, ready: 2, in_progress: 1, review: 0, done: 4, blocked: 0 },
|
||||
});
|
||||
render(<MetricsDashboard {...defaultProps} metrics={metrics} />);
|
||||
// State count badges should show values
|
||||
expect(screen.getByText('5')).toBeDefined(); // new count
|
||||
expect(screen.getByText('3')).toBeDefined(); // triage count
|
||||
expect(screen.getByText('4')).toBeDefined(); // done count
|
||||
});
|
||||
|
||||
it('renders completeness distribution', () => {
|
||||
const metrics = makeMetrics({
|
||||
completenessDistribution: { low: 2, medium: 3, high: 4, excellent: 1 },
|
||||
});
|
||||
render(<MetricsDashboard {...defaultProps} metrics={metrics} />);
|
||||
expect(screen.getByText('2')).toBeDefined(); // low
|
||||
expect(screen.getByText('3')).toBeDefined(); // medium
|
||||
expect(screen.getByText('4')).toBeDefined(); // high
|
||||
});
|
||||
|
||||
it('renders total transitions', () => {
|
||||
const metrics = makeMetrics({ totalTransitions: 42 });
|
||||
render(<MetricsDashboard {...defaultProps} metrics={metrics} />);
|
||||
expect(screen.getByText('42')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows dash for zero backlog age', () => {
|
||||
render(<MetricsDashboard {...defaultProps} />);
|
||||
expect(screen.getByText('—')).toBeDefined();
|
||||
});
|
||||
|
||||
it('formats backlog age', () => {
|
||||
const metrics = makeMetrics({ avgBacklogAge: 86_400_000 }); // 1 day
|
||||
render(<MetricsDashboard {...defaultProps} metrics={metrics} />);
|
||||
expect(screen.getByText('1d')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows this week throughput', () => {
|
||||
const metrics = makeMetrics({
|
||||
weeklyThroughput: [
|
||||
{ week: '2026-02-03', count: 5 },
|
||||
{ week: '2026-02-10', count: 8 },
|
||||
],
|
||||
});
|
||||
render(<MetricsDashboard {...defaultProps} metrics={metrics} />);
|
||||
expect(screen.getByText('8')).toBeDefined(); // latest week
|
||||
});
|
||||
|
||||
it('has accessible region role', () => {
|
||||
render(<MetricsDashboard {...defaultProps} />);
|
||||
expect(screen.getByRole('region')).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user