Skip to content

Commit 511f505

Browse files
authored
feat(web-ui): task status tooltips and valid action guidance (#481)
Closes #481 - Status badge tooltips (meaning + next steps) on TaskCard and TaskDetailModal - Single TooltipProvider per TaskCard for performance - 'What's next?' guidance panel for DONE/BLOCKED/FAILED/MERGED states - 'Last changed' date in modal metadata (en-US locale) - Consistent STATUS_INFO constants shared across all components - 22 unit tests with Radix UI tooltip mocking
1 parent ab198d1 commit 511f505

5 files changed

Lines changed: 369 additions & 27 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { TaskCard } from '@/components/tasks/TaskCard';
4+
import { STATUS_INFO } from '@/lib/taskStatusInfo';
5+
import type { Task } from '@/types';
6+
7+
jest.mock('next/link', () => {
8+
const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
9+
<a href={href}>{children}</a>
10+
);
11+
MockLink.displayName = 'MockLink';
12+
return MockLink;
13+
});
14+
15+
// Radix UI tooltips use portals and pointer events that don't work in jsdom.
16+
// Replace with a simple always-visible version to test content.
17+
jest.mock('@/components/ui/tooltip', () => ({
18+
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
19+
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
20+
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
21+
TooltipContent: ({ children }: { children: React.ReactNode }) => (
22+
<div role="tooltip">{children}</div>
23+
),
24+
}));
25+
26+
const baseTask: Task = {
27+
id: 'task-1',
28+
title: 'Test Task',
29+
description: 'A test task description',
30+
status: 'BACKLOG',
31+
priority: 0,
32+
depends_on: [],
33+
};
34+
35+
const defaultProps = {
36+
task: baseTask,
37+
selectionMode: false,
38+
selected: false,
39+
onToggleSelect: jest.fn(),
40+
onClick: jest.fn(),
41+
onExecute: jest.fn(),
42+
onMarkReady: jest.fn(),
43+
};
44+
45+
describe('TaskCard status badge tooltip', () => {
46+
it('renders the status badge label', () => {
47+
render(<TaskCard {...defaultProps} />);
48+
expect(screen.getByText('Backlog')).toBeInTheDocument();
49+
});
50+
51+
it('renders tooltip with BACKLOG meaning', () => {
52+
render(<TaskCard {...defaultProps} />);
53+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning);
54+
});
55+
56+
it('renders tooltip with BACKLOG next steps', () => {
57+
render(<TaskCard {...defaultProps} />);
58+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.nextSteps);
59+
});
60+
61+
it('renders tooltip with READY meaning', () => {
62+
const task = { ...baseTask, status: 'READY' as const };
63+
render(<TaskCard {...defaultProps} task={task} />);
64+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.READY.meaning);
65+
});
66+
67+
it('renders tooltip with FAILED meaning', () => {
68+
const task = { ...baseTask, status: 'FAILED' as const };
69+
render(<TaskCard {...defaultProps} task={task} onReset={jest.fn()} />);
70+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.FAILED.meaning);
71+
});
72+
73+
it('renders tooltip with IN_PROGRESS meaning', () => {
74+
const task = { ...baseTask, status: 'IN_PROGRESS' as const };
75+
render(<TaskCard {...defaultProps} task={task} onStop={jest.fn()} />);
76+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.IN_PROGRESS.meaning);
77+
});
78+
79+
it('renders tooltip with DONE meaning', () => {
80+
const task = { ...baseTask, status: 'DONE' as const };
81+
render(<TaskCard {...defaultProps} task={task} />);
82+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning);
83+
});
84+
85+
it('renders tooltip with BLOCKED meaning', () => {
86+
const task = { ...baseTask, status: 'BLOCKED' as const };
87+
render(<TaskCard {...defaultProps} task={task} />);
88+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BLOCKED.meaning);
89+
});
90+
91+
it('renders tooltip with MERGED meaning', () => {
92+
const task = { ...baseTask, status: 'MERGED' as const };
93+
render(<TaskCard {...defaultProps} task={task} />);
94+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.MERGED.meaning);
95+
});
96+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React from 'react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import { TaskDetailModal } from '@/components/tasks/TaskDetailModal';
4+
import { STATUS_INFO } from '@/lib/taskStatusInfo';
5+
import type { Task } from '@/types';
6+
7+
// Radix UI tooltips use portals and pointer events that don't work in jsdom.
8+
jest.mock('@/components/ui/tooltip', () => ({
9+
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
10+
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
11+
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
12+
TooltipContent: ({ children }: { children: React.ReactNode }) => (
13+
<div role="tooltip">{children}</div>
14+
),
15+
}));
16+
17+
// ── Mocks ────────────────────────────────────────────────────────────────
18+
19+
jest.mock('swr', () => ({
20+
__esModule: true,
21+
default: jest.fn(() => ({ data: { tasks: [] }, isLoading: false, error: null })),
22+
}));
23+
24+
jest.mock('next/navigation', () => ({
25+
useRouter: () => ({ push: jest.fn() }),
26+
}));
27+
28+
jest.mock('next/link', () => {
29+
const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => (
30+
<a href={href}>{children}</a>
31+
);
32+
MockLink.displayName = 'MockLink';
33+
return MockLink;
34+
});
35+
36+
jest.mock('@/lib/api', () => ({
37+
tasksApi: {
38+
getOne: jest.fn(),
39+
getAll: jest.fn(),
40+
updateStatus: jest.fn(),
41+
},
42+
}));
43+
44+
jest.mock('@/hooks/useRequirementsLookup', () => ({
45+
useRequirementsLookup: () => ({ requirementsMap: new Map(), isLoading: false }),
46+
}));
47+
48+
import { tasksApi } from '@/lib/api';
49+
50+
const makeTask = (overrides: Partial<Task> = {}): Task => ({
51+
id: 'task-1',
52+
title: 'Test Task',
53+
description: 'A description',
54+
status: 'BACKLOG',
55+
priority: 0,
56+
depends_on: [],
57+
...overrides,
58+
});
59+
60+
const defaultProps = {
61+
taskId: 'task-1',
62+
workspacePath: '/ws',
63+
open: true,
64+
onClose: jest.fn(),
65+
onExecute: jest.fn(),
66+
onStatusChange: jest.fn(),
67+
};
68+
69+
function renderModal(taskOverrides: Partial<Task> = {}) {
70+
const task = makeTask(taskOverrides);
71+
(tasksApi.getOne as jest.Mock).mockResolvedValue(task);
72+
return render(<TaskDetailModal {...defaultProps} />);
73+
}
74+
75+
describe('TaskDetailModal status badge tooltip', () => {
76+
it('renders tooltip with BACKLOG meaning', async () => {
77+
renderModal({ status: 'BACKLOG' });
78+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
79+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.BACKLOG.meaning);
80+
});
81+
82+
it('renders tooltip with DONE meaning', async () => {
83+
renderModal({ status: 'DONE' });
84+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
85+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.DONE.meaning);
86+
});
87+
88+
it('renders tooltip with FAILED meaning', async () => {
89+
renderModal({ status: 'FAILED' });
90+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
91+
expect(screen.getByRole('tooltip')).toHaveTextContent(STATUS_INFO.FAILED.meaning);
92+
});
93+
});
94+
95+
describe('TaskDetailModal valid transition guidance', () => {
96+
it('shows "Mark Ready" button for BACKLOG status', async () => {
97+
renderModal({ status: 'BACKLOG' });
98+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
99+
expect(screen.getByRole('button', { name: /mark ready/i })).toBeInTheDocument();
100+
});
101+
102+
it('shows "Execute" button for READY status', async () => {
103+
renderModal({ status: 'READY' });
104+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
105+
expect(screen.getByRole('button', { name: /execute/i })).toBeInTheDocument();
106+
});
107+
108+
it('shows next-step guidance for DONE status (no action button but guidance visible)', async () => {
109+
renderModal({ status: 'DONE' });
110+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
111+
expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
112+
expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.DONE.nextSteps);
113+
});
114+
115+
it('shows next-step guidance for BLOCKED status', async () => {
116+
renderModal({ status: 'BLOCKED' });
117+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
118+
expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
119+
expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.BLOCKED.nextSteps);
120+
});
121+
122+
it('shows next-step guidance for MERGED status', async () => {
123+
renderModal({ status: 'MERGED' });
124+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
125+
expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
126+
expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.MERGED.nextSteps);
127+
});
128+
129+
it('shows next-step guidance for FAILED status via the alert panel', async () => {
130+
renderModal({ status: 'FAILED' });
131+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
132+
expect(screen.getByTestId('status-next-step')).toBeInTheDocument();
133+
expect(screen.getByTestId('status-next-step')).toHaveTextContent(STATUS_INFO.FAILED.nextSteps);
134+
});
135+
136+
it('does not show next-step guidance for BACKLOG (has action button)', async () => {
137+
renderModal({ status: 'BACKLOG' });
138+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
139+
expect(screen.queryByTestId('status-next-step')).not.toBeInTheDocument();
140+
});
141+
142+
it('does not show next-step guidance for READY (has action button)', async () => {
143+
renderModal({ status: 'READY' });
144+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
145+
expect(screen.queryByTestId('status-next-step')).not.toBeInTheDocument();
146+
});
147+
});
148+
149+
describe('TaskDetailModal last changed timestamp', () => {
150+
it('shows last changed date when updated_at is present', async () => {
151+
renderModal({ status: 'DONE', updated_at: '2026-01-15T10:30:00Z' });
152+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
153+
expect(screen.getByText(/last changed/i)).toBeInTheDocument();
154+
});
155+
156+
it('does not show last changed when updated_at is absent', async () => {
157+
renderModal({ status: 'BACKLOG' });
158+
await waitFor(() => expect(screen.getByText('Test Task')).toBeInTheDocument());
159+
expect(screen.queryByText(/last changed/i)).not.toBeInTheDocument();
160+
});
161+
});

web-ui/src/components/tasks/TaskCard.tsx

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
77
import { Button } from '@/components/ui/button';
88
import { Checkbox } from '@/components/ui/checkbox';
99
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
10+
import { STATUS_INFO } from '@/lib/taskStatusInfo';
1011
import type { Task, TaskStatus, ProofRequirement } from '@/types';
1112

1213
/** Map backend TaskStatus to badge variant name. */
@@ -80,6 +81,8 @@ export function TaskCard({
8081
aria-label={`View details for ${task.title}`}
8182
>
8283
<CardContent className="p-3">
84+
{/* Single TooltipProvider for the entire card to avoid per-tooltip provider overhead */}
85+
<TooltipProvider>
8386
{/* Top row: checkbox (if selection mode) + status badge */}
8487
<div className="mb-2 flex items-center justify-between gap-2">
8588
<div className="flex items-center gap-2">
@@ -91,26 +94,30 @@ export function TaskCard({
9194
aria-label={`Select ${task.title}`}
9295
/>
9396
)}
94-
<Badge
95-
variant={STATUS_BADGE_VARIANT[task.status] as never}
96-
>
97-
{STATUS_LABEL[task.status]}
98-
</Badge>
97+
<Tooltip>
98+
<TooltipTrigger asChild>
99+
<Badge variant={STATUS_BADGE_VARIANT[task.status] as never}>
100+
{STATUS_LABEL[task.status]}
101+
</Badge>
102+
</TooltipTrigger>
103+
<TooltipContent className="max-w-[220px] space-y-1">
104+
<p className="text-xs font-medium">{STATUS_INFO[task.status].meaning}</p>
105+
<p className="text-xs text-muted-foreground">{STATUS_INFO[task.status].nextSteps}</p>
106+
</TooltipContent>
107+
</Tooltip>
99108
</div>
100109
{task.depends_on.length > 0 && (
101-
<TooltipProvider>
102-
<Tooltip>
103-
<TooltipTrigger asChild>
104-
<span className="flex cursor-default items-center gap-1 text-xs text-muted-foreground">
105-
<LinkCircleIcon className="h-3.5 w-3.5" />
106-
{task.depends_on.length}
107-
</span>
108-
</TooltipTrigger>
109-
<TooltipContent>
110-
Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
111-
</TooltipContent>
112-
</Tooltip>
113-
</TooltipProvider>
110+
<Tooltip>
111+
<TooltipTrigger asChild>
112+
<span className="flex cursor-default items-center gap-1 text-xs text-muted-foreground">
113+
<LinkCircleIcon className="h-3.5 w-3.5" />
114+
{task.depends_on.length}
115+
</span>
116+
</TooltipTrigger>
117+
<TooltipContent>
118+
Depends on {task.depends_on.length} task{task.depends_on.length !== 1 ? 's' : ''}. This task will become READY when all dependencies complete.
119+
</TooltipContent>
120+
</Tooltip>
114121
)}
115122
</div>
116123

@@ -215,6 +222,7 @@ export function TaskCard({
215222
)}
216223
</div>
217224
)}
225+
</TooltipProvider>
218226
</CardContent>
219227
</Card>
220228
);

0 commit comments

Comments
 (0)