feat(enrichment): WP-7 UI components — LabelSyncSettings, DependencyList, MetricsDashboard

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Sondre Engebråten
2026-02-12 19:23:03 +01:00
co-authored by Claude Opus 4.6
parent 35aaf892ec
commit ea16cd29fd
6 changed files with 694 additions and 0 deletions
@@ -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>
);
}
@@ -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);
});
});
@@ -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();
});
});
@@ -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();
});
});