Skip to content

Commit 9a2cfad

Browse files
frankbriaTest User
andauthored
feat(ui): add PR management UI components and Dashboard integration (#285)
* feat(ui): add PR management UI components and Dashboard integration This commit implements a complete pull request management interface: Components: - PRList: Displays PRs with status badges, filtering, CI/review status - PRCreationDialog: Form dialog for creating new PRs - PRMergeDialog: Confirmation dialog with merge method selection Infrastructure: - Added PR types (PullRequest, PRStatus, MergeMethod, etc.) - Added pullRequestsApi client methods (list, create, merge, close) - Added DashboardTab type for 'pull-requests' - Added shadcn/ui components: Textarea, Label, Checkbox, RadioGroup Dashboard Integration: - New "Pull Requests" tab between Metrics and Context - WebSocket integration for real-time PR status updates - PR creation and merge dialogs wired to Dashboard state Tests: - PRList.test.tsx: 21 tests covering rendering, filtering, actions - PRCreationDialog.test.tsx: Form validation and submission tests - PRMergeDialog.test.tsx: Merge method selection and confirmation tests Dependencies: - @radix-ui/react-label, @radix-ui/react-checkbox, @radix-ui/react-radio-group * fix(tests): add Hugeicons mocks and fix test timing issues - Add global @hugeicons/react mock to jest.setup.js with all 37 icons - Fix loading state tests in PRCreationDialog and PRMergeDialog by using deferred promises to keep loading visible during assertions - Update Dashboard.test.tsx with complete Hugeicons mock - Update integration tests with complete Hugeicons mocks - Fix PRMergeDialog test to handle multiple "main" text matches * fix(pr): improve SWR caching, dialog state reset, and Nova colors - PRList: Use array SWR key with filter for automatic revalidation, remove manual mutate() useEffect since SWR handles filter changes - PRMergeDialog: Add useEffect to reset form state when dialog opens (mergeMethod, deleteBranch, error, isSubmitting) - pullRequest.ts + PRList: Update PR_STATUS_COLORS to use Nova design system variables (bg-success/10, bg-primary/10, bg-destructive/10) * fix(types): add PR WebSocket event types and fix agent pluralization - Add pr_created, pr_merged, pr_closed to WebSocketMessageType union - Update PRList.tsx to use WebSocketMessage type instead of any - Fix Dashboard.tsx pluralization: "1 agent active" vs "N agents active" - Update test assertion to match corrected grammar --------- Co-authored-by: Test User <test@example.com>
1 parent 49387a6 commit 9a2cfad

22 files changed

+2671
-88
lines changed

web-ui/__tests__/components/Dashboard.test.tsx

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,63 @@ import * as api from '@/lib/api';
1111
import * as websocket from '@/lib/websocket';
1212
import * as agentAssignment from '@/api/agentAssignment';
1313

14-
// Mock Hugeicons (used by PhaseProgress, Dashboard, and SessionStatus components)
15-
jest.mock('@hugeicons/react', () => ({
16-
// PhaseProgress icons
17-
Search01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="search-icon" />,
18-
TaskEdit01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="task-edit-icon" />,
19-
Wrench01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="wrench-icon" />,
20-
CheckmarkCircle01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="checkmark-circle-icon" />,
21-
Award01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="award-icon" />,
22-
RocketIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="rocket-icon" />,
23-
HelpCircleIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="help-icon" />,
24-
Idea01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="idea-icon" />,
25-
// Dashboard section header icons
26-
UserGroupIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="user-group-icon" />,
27-
Loading03Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="loading-icon" />,
28-
WorkHistoryIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="work-history-icon" />,
29-
AnalyticsUpIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="analytics-icon" />,
30-
ClipboardIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="clipboard-icon" />,
31-
CheckListIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="checklist-icon" />,
32-
Target02Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="target-icon" />,
33-
FloppyDiskIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="floppy-icon" />,
34-
// Activity item icons
35-
TestTube01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="test-tube-icon" />,
36-
Alert02Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="alert-icon" />,
37-
CheckmarkSquare01Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="checkmark-square-icon" />,
38-
BotIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="bot-icon" />,
39-
Logout02Icon: ({ className }: { className?: string }) => <svg className={className} data-testid="logout-icon" />,
40-
GitCommitIcon: ({ className }: { className?: string }) => <svg className={className} data-testid="git-commit-icon" />,
41-
}));
14+
// Mock Hugeicons (used by PhaseProgress, Dashboard, SessionStatus, and UI components)
15+
// This mock must include ALL icons used by Dashboard and its children
16+
jest.mock('@hugeicons/react', () => {
17+
const createMockIcon = (name: string) => {
18+
const Icon = ({ className }: { className?: string }) => (
19+
<svg className={className} data-testid={name} />
20+
);
21+
Icon.displayName = name;
22+
return Icon;
23+
};
24+
25+
return {
26+
// PhaseProgress icons
27+
Search01Icon: createMockIcon('search-icon'),
28+
TaskEdit01Icon: createMockIcon('task-edit-icon'),
29+
Wrench01Icon: createMockIcon('wrench-icon'),
30+
Award01Icon: createMockIcon('award-icon'),
31+
RocketIcon: createMockIcon('rocket-icon'),
32+
HelpCircleIcon: createMockIcon('help-icon'),
33+
Idea01Icon: createMockIcon('idea-icon'),
34+
// Dashboard section header icons
35+
UserGroupIcon: createMockIcon('user-group-icon'),
36+
Loading03Icon: createMockIcon('loading-icon'),
37+
WorkHistoryIcon: createMockIcon('work-history-icon'),
38+
AnalyticsUpIcon: createMockIcon('analytics-icon'),
39+
ClipboardIcon: createMockIcon('clipboard-icon'),
40+
CheckListIcon: createMockIcon('checklist-icon'),
41+
Target02Icon: createMockIcon('target-icon'),
42+
FloppyDiskIcon: createMockIcon('floppy-icon'),
43+
GitPullRequestIcon: createMockIcon('git-pull-request-icon'),
44+
// Activity item icons
45+
TestTube01Icon: createMockIcon('test-tube-icon'),
46+
Alert02Icon: createMockIcon('alert-icon'),
47+
CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'),
48+
CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'),
49+
BotIcon: createMockIcon('bot-icon'),
50+
Logout02Icon: createMockIcon('logout-icon'),
51+
GitCommitIcon: createMockIcon('git-commit-icon'),
52+
// UI component icons (select, checkbox, dialog, radio-group)
53+
Tick01Icon: createMockIcon('tick-icon'),
54+
ArrowDown01Icon: createMockIcon('arrow-down-icon'),
55+
ArrowUp01Icon: createMockIcon('arrow-up-icon'),
56+
Cancel01Icon: createMockIcon('cancel-icon'),
57+
CircleIcon: createMockIcon('circle-icon'),
58+
// PR component icons
59+
AlertCircleIcon: createMockIcon('alert-circle-icon'),
60+
GitBranchIcon: createMockIcon('git-branch-icon'),
61+
ArrowRight01Icon: createMockIcon('arrow-right-icon'),
62+
Link01Icon: createMockIcon('link-icon'),
63+
Add01Icon: createMockIcon('add-icon'),
64+
Cancel02Icon: createMockIcon('cancel02-icon'),
65+
Time01Icon: createMockIcon('time-icon'),
66+
// Other icons
67+
Download01Icon: createMockIcon('download-icon'),
68+
AlertDiamondIcon: createMockIcon('alert-diamond-icon'),
69+
};
70+
});
4271

4372
// Create a shared mock WebSocket client that will be used across all tests
4473
const sharedMockWsClient = {
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/**
2+
* Tests for PRCreationDialog Component
3+
* TDD: Tests written first to define expected behavior
4+
*/
5+
6+
import { render, screen, waitFor } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
import PRCreationDialog from '@/components/pr/PRCreationDialog';
9+
10+
// Mock the API
11+
jest.mock('@/lib/api', () => ({
12+
pullRequestsApi: {
13+
create: jest.fn().mockResolvedValue({
14+
data: {
15+
pr_id: 1,
16+
pr_number: 42,
17+
pr_url: 'https://github.com/org/repo/pull/42',
18+
status: 'open',
19+
},
20+
}),
21+
},
22+
}));
23+
24+
// Mock git API for branch list
25+
jest.mock('@/api/git', () => ({
26+
gitApi: {
27+
getBranches: jest.fn().mockResolvedValue({
28+
data: {
29+
branches: [
30+
{ id: 1, branch_name: 'feature/auth', status: 'active' },
31+
{ id: 2, branch_name: 'feature/dashboard', status: 'active' },
32+
{ id: 3, branch_name: 'main', status: 'active' },
33+
],
34+
},
35+
}),
36+
},
37+
}));
38+
39+
describe('PRCreationDialog', () => {
40+
const defaultProps = {
41+
projectId: 1,
42+
isOpen: true,
43+
onClose: jest.fn(),
44+
onSuccess: jest.fn(),
45+
defaultBranch: 'feature/auth',
46+
defaultTitle: 'Add authentication feature',
47+
defaultDescription: 'Implements OAuth 2.0 login flow',
48+
};
49+
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
});
53+
54+
describe('Rendering', () => {
55+
it('should render the dialog when open', () => {
56+
render(<PRCreationDialog {...defaultProps} />);
57+
58+
expect(screen.getByRole('dialog')).toBeInTheDocument();
59+
expect(screen.getByText(/create pull request/i)).toBeInTheDocument();
60+
});
61+
62+
it('should not render when closed', () => {
63+
render(<PRCreationDialog {...defaultProps} isOpen={false} />);
64+
65+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
66+
});
67+
68+
it('should pre-fill form with default values', () => {
69+
render(<PRCreationDialog {...defaultProps} />);
70+
71+
expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument();
72+
expect(screen.getByDisplayValue('Implements OAuth 2.0 login flow')).toBeInTheDocument();
73+
});
74+
});
75+
76+
describe('Form Fields', () => {
77+
it('should have title input field', () => {
78+
render(<PRCreationDialog {...defaultProps} />);
79+
80+
const titleInput = screen.getByLabelText(/title/i);
81+
expect(titleInput).toBeInTheDocument();
82+
expect(titleInput).toHaveAttribute('type', 'text');
83+
});
84+
85+
it('should have description textarea', () => {
86+
render(<PRCreationDialog {...defaultProps} />);
87+
88+
const descriptionInput = screen.getByLabelText(/description/i);
89+
expect(descriptionInput).toBeInTheDocument();
90+
expect(descriptionInput.tagName.toLowerCase()).toBe('textarea');
91+
});
92+
93+
it('should have branch selector', () => {
94+
render(<PRCreationDialog {...defaultProps} />);
95+
96+
expect(screen.getByLabelText(/source branch/i)).toBeInTheDocument();
97+
});
98+
99+
it('should have base branch selector with main as default', () => {
100+
render(<PRCreationDialog {...defaultProps} />);
101+
102+
const baseBranchSelect = screen.getByLabelText(/target branch/i);
103+
expect(baseBranchSelect).toBeInTheDocument();
104+
});
105+
});
106+
107+
describe('Form Validation', () => {
108+
it('should require title', async () => {
109+
const user = userEvent.setup();
110+
render(<PRCreationDialog {...defaultProps} defaultTitle="" />);
111+
112+
const submitButton = screen.getByRole('button', { name: /create/i });
113+
await user.click(submitButton);
114+
115+
expect(screen.getByText(/title is required/i)).toBeInTheDocument();
116+
});
117+
118+
it('should require branch selection', async () => {
119+
const user = userEvent.setup();
120+
render(<PRCreationDialog {...defaultProps} defaultBranch="" />);
121+
122+
const submitButton = screen.getByRole('button', { name: /create/i });
123+
await user.click(submitButton);
124+
125+
expect(screen.getByText(/branch is required/i)).toBeInTheDocument();
126+
});
127+
});
128+
129+
describe('Form Submission', () => {
130+
it('should call API with form data on submit', async () => {
131+
const user = userEvent.setup();
132+
const { pullRequestsApi } = jest.requireMock('@/lib/api');
133+
134+
render(<PRCreationDialog {...defaultProps} />);
135+
136+
const submitButton = screen.getByRole('button', { name: /create/i });
137+
await user.click(submitButton);
138+
139+
await waitFor(() => {
140+
expect(pullRequestsApi.create).toHaveBeenCalledWith(1, {
141+
branch: 'feature/auth',
142+
title: 'Add authentication feature',
143+
body: 'Implements OAuth 2.0 login flow',
144+
base: 'main',
145+
});
146+
});
147+
});
148+
149+
it('should call onSuccess after successful creation', async () => {
150+
const user = userEvent.setup();
151+
render(<PRCreationDialog {...defaultProps} />);
152+
153+
const submitButton = screen.getByRole('button', { name: /create/i });
154+
await user.click(submitButton);
155+
156+
await waitFor(() => {
157+
expect(defaultProps.onSuccess).toHaveBeenCalled();
158+
});
159+
});
160+
161+
it('should close dialog after successful creation', async () => {
162+
const user = userEvent.setup();
163+
render(<PRCreationDialog {...defaultProps} />);
164+
165+
const submitButton = screen.getByRole('button', { name: /create/i });
166+
await user.click(submitButton);
167+
168+
await waitFor(() => {
169+
expect(defaultProps.onClose).toHaveBeenCalled();
170+
});
171+
});
172+
173+
it('should show loading state during submission', async () => {
174+
// Make the API call take time to show loading state
175+
const { pullRequestsApi } = jest.requireMock('@/lib/api');
176+
let resolvePromise: (value: unknown) => void;
177+
pullRequestsApi.create.mockImplementationOnce(
178+
() =>
179+
new Promise((resolve) => {
180+
resolvePromise = resolve;
181+
})
182+
);
183+
184+
const user = userEvent.setup();
185+
render(<PRCreationDialog {...defaultProps} />);
186+
187+
const submitButton = screen.getByRole('button', { name: /create/i });
188+
await user.click(submitButton);
189+
190+
// Button should show loading state
191+
await waitFor(() => {
192+
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument();
193+
});
194+
195+
// Cleanup: resolve the promise
196+
resolvePromise!({
197+
data: { pr_id: 1, pr_number: 42, pr_url: 'https://github.com/org/repo/pull/42', status: 'open' },
198+
});
199+
});
200+
});
201+
202+
describe('Error Handling', () => {
203+
it('should display error message on API failure', async () => {
204+
const user = userEvent.setup();
205+
const { pullRequestsApi } = jest.requireMock('@/lib/api');
206+
pullRequestsApi.create.mockRejectedValueOnce(new Error('Branch already has an open PR'));
207+
208+
render(<PRCreationDialog {...defaultProps} />);
209+
210+
const submitButton = screen.getByRole('button', { name: /create/i });
211+
await user.click(submitButton);
212+
213+
await waitFor(() => {
214+
expect(screen.getByText(/branch already has an open pr/i)).toBeInTheDocument();
215+
});
216+
});
217+
});
218+
219+
describe('Cancel Action', () => {
220+
it('should call onClose when cancel button is clicked', async () => {
221+
const user = userEvent.setup();
222+
render(<PRCreationDialog {...defaultProps} />);
223+
224+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
225+
await user.click(cancelButton);
226+
227+
expect(defaultProps.onClose).toHaveBeenCalled();
228+
});
229+
230+
it('should reset form when reopened', async () => {
231+
const user = userEvent.setup();
232+
const { rerender } = render(<PRCreationDialog {...defaultProps} />);
233+
234+
// Modify title
235+
const titleInput = screen.getByLabelText(/title/i);
236+
await user.clear(titleInput);
237+
await user.type(titleInput, 'Modified title');
238+
239+
// Close and reopen
240+
rerender(<PRCreationDialog {...defaultProps} isOpen={false} />);
241+
rerender(<PRCreationDialog {...defaultProps} isOpen={true} />);
242+
243+
// Should have original default value
244+
expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument();
245+
});
246+
});
247+
});

0 commit comments

Comments
 (0)