Skip to content

Commit 526e868

Browse files
fix(ui): auto-close date-time-picker on selection and add tests
1 parent 54bfa37 commit 526e868

4 files changed

Lines changed: 368 additions & 0 deletions

File tree

frontend/jest.setup.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@ import '@testing-library/jest-dom';
22
import { expect } from '@jest/globals';
33
import type { Plugin } from 'pretty-format';
44

5+
// ResizeObserver polyfill
6+
global.ResizeObserver = class ResizeObserver {
7+
observe() {}
8+
unobserve() {}
9+
disconnect() {}
10+
};
11+
12+
// ScrollIntoView mock
13+
window.HTMLElement.prototype.scrollIntoView = jest.fn();
14+
15+
// PointerEvent mock
16+
class MockPointerEvent extends Event {
17+
button: number;
18+
ctrlKey: boolean;
19+
pointerType: string;
20+
21+
constructor(type: string, props: PointerEventInit) {
22+
super(type, props);
23+
this.button = props.button || 0;
24+
this.ctrlKey = props.ctrlKey || false;
25+
this.pointerType = props.pointerType || 'mouse';
26+
}
27+
}
28+
window.PointerEvent = MockPointerEvent as any;
29+
window.HTMLElement.prototype.hasPointerCapture = jest.fn();
30+
window.HTMLElement.prototype.releasePointerCapture = jest.fn();
31+
532
let isSerializing = false;
633

734
const radixSnapshotSerializer: Plugin = {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { DateTimePicker } from '../ui/date-time-picker';
4+
import '@testing-library/jest-dom';
5+
6+
describe('DateTimePicker', () => {
7+
it('renders without crashing', () => {
8+
const mockOnDateTimeChange = jest.fn();
9+
render(
10+
<DateTimePicker
11+
date={undefined}
12+
onDateTimeChange={mockOnDateTimeChange}
13+
/>
14+
);
15+
expect(
16+
screen.getByRole('button', { name: /calender-button/i })
17+
).toBeInTheDocument();
18+
});
19+
20+
it('opens and closes the popover when the trigger button is clicked', async () => {
21+
const user = userEvent.setup();
22+
const mockOnDateTimeChange = jest.fn();
23+
render(
24+
<DateTimePicker
25+
date={undefined}
26+
onDateTimeChange={mockOnDateTimeChange}
27+
/>
28+
);
29+
30+
const triggerButton = screen.getByRole('button', {
31+
name: /calender-button/i,
32+
});
33+
34+
// Open popover
35+
await user.click(triggerButton);
36+
expect(screen.getByRole('dialog')).toBeInTheDocument(); // Popover content is a dialog
37+
expect(screen.getByText(/February 2026/)).toBeInTheDocument(); // Check for specific content inside the calendar
38+
39+
// Close popover using Escape key
40+
fireEvent.keyDown(document, { key: 'Escape' });
41+
expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument(); // Check for absence of specific content
42+
});
43+
44+
it('allows selecting a date from the calendar', async () => {
45+
const user = userEvent.setup();
46+
const mockOnDateTimeChange = jest.fn();
47+
render(
48+
<DateTimePicker
49+
date={undefined}
50+
onDateTimeChange={mockOnDateTimeChange}
51+
/>
52+
);
53+
54+
await user.click(screen.getByRole('button', { name: /calender-button/i })); // Open popover
55+
56+
// Find a date in the current month (e.g., the 15th)
57+
const dateToSelect = screen.getByRole('gridcell', { name: '15' });
58+
await user.click(dateToSelect);
59+
60+
// Expect the popover to close after selecting a date
61+
expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument();
62+
63+
// Check if onDateTimeChange was called with the correct date (year, month, and day)
64+
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(1);
65+
const calledDate = mockOnDateTimeChange.mock.calls[0][0];
66+
expect(calledDate).toBeInstanceOf(Date);
67+
expect(calledDate.getDate()).toBe(15);
68+
expect(calledDate.getMonth()).toBe(new Date().getMonth()); // Assuming current month for simplicity
69+
expect(calledDate.getFullYear()).toBe(new Date().getFullYear()); // Assuming current year for simplicity
70+
expect(calledDate.getHours()).toBe(0); // Should reset time to 00:00:00
71+
expect(mockOnDateTimeChange.mock.calls[0][1]).toBe(false); // hasTime should be false
72+
});
73+
74+
it('allows selecting an hour, minute, and AM/PM', async () => {
75+
const user = userEvent.setup();
76+
const mockOnDateTimeChange = jest.fn();
77+
const initialDate = new Date(2024, 0, 15, 10, 30); // Jan 15, 2024, 10:30 AM
78+
render(
79+
<DateTimePicker
80+
date={initialDate}
81+
onDateTimeChange={mockOnDateTimeChange}
82+
/>
83+
);
84+
85+
await user.click(screen.getByRole('button', { name: /calender-button/i })); // Open popover
86+
87+
// Verify time selection elements are present
88+
expect(screen.getByText('AM')).toBeInTheDocument();
89+
expect(screen.getByText('PM')).toBeInTheDocument();
90+
91+
// Select an hour (e.g., 2 PM)
92+
await user.click(screen.getByRole('button', { name: '2' })); // Select hour 2
93+
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(1); // One call for hour selection
94+
let calledDate = mockOnDateTimeChange.mock.calls[0][0];
95+
expect(calledDate.getHours()).toBe(2); // Should be 2 AM initially before PM is clicked
96+
97+
await user.click(screen.getByRole('button', { name: 'PM' })); // Select PM
98+
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(2); // Second call for AM/PM selection
99+
calledDate = mockOnDateTimeChange.mock.calls[1][0];
100+
expect(calledDate.getHours()).toBe(14); // 2 PM
101+
expect(mockOnDateTimeChange.mock.calls[1][1]).toBe(true); // hasTime should be true
102+
103+
// Select a minute (e.g., 45 minutes)
104+
await user.click(screen.getByRole('button', { name: '45' }));
105+
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(3); // Third call for minute selection
106+
calledDate = mockOnDateTimeChange.mock.calls[2][0];
107+
expect(calledDate.getMinutes()).toBe(45);
108+
expect(mockOnDateTimeChange.mock.calls[2][1]).toBe(true); // hasTime should be true
109+
});
110+
});

frontend/src/components/ui/date-time-picker.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@ export const DateTimePicker = React.forwardRef<
5858

5959
isInternalUpdate.current = true;
6060
onDateTimeChange(newDate, false);
61+
setIsOpen(false);
6162
} else {
6263
setInternalDate(undefined);
6364
setHasTime(false);
6465
isInternalUpdate.current = true;
6566
onDateTimeChange(undefined, false);
67+
setIsOpen(false);
6668
}
6769
};
6870

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { MultiSelectFilter } from '../ui/multi-select';
4+
import '@testing-library/jest-dom';
5+
6+
describe('MultiSelectFilter', () => {
7+
const mockOptions = ['Option A', 'Option B', 'Option C'];
8+
const mockOnSelectionChange = jest.fn();
9+
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
it('renders with title and placeholder when no options selected', () => {
15+
render(
16+
<MultiSelectFilter
17+
title="Test Filter"
18+
options={mockOptions}
19+
selectedValues={[]}
20+
onSelectionChange={mockOnSelectionChange}
21+
/>
22+
);
23+
24+
expect(screen.getByText('Test Filter')).toBeInTheDocument();
25+
expect(screen.getByRole('combobox')).toBeInTheDocument(); // The button serves as a combobox trigger
26+
});
27+
28+
it('renders with selected values', () => {
29+
render(
30+
<MultiSelectFilter
31+
title="Test Filter"
32+
options={mockOptions}
33+
selectedValues={['Option A', 'Option C']}
34+
onSelectionChange={mockOnSelectionChange}
35+
/>
36+
);
37+
38+
// Since selected values are visually indicated by checkboxes in the popover (which is closed),
39+
// and potentially by a badge or count on the button (depending on implementation),
40+
// let's check if the component renders without error first.
41+
// Inspecting the component, it doesn't seem to show selected count on the button in this version, just the title.
42+
// So we rely on the internal logic validation in interaction tests or if we open the popover.
43+
// For this render test, ensuring it mounts with props is the baseline.
44+
expect(screen.getByRole('combobox')).toBeInTheDocument();
45+
});
46+
47+
it('applies custom className', () => {
48+
render(
49+
<MultiSelectFilter
50+
title="Test Filter"
51+
options={mockOptions}
52+
selectedValues={[]}
53+
onSelectionChange={mockOnSelectionChange}
54+
className="custom-class"
55+
/>
56+
);
57+
58+
expect(screen.getByRole('combobox')).toHaveClass('custom-class');
59+
});
60+
61+
it('renders icon when provided', () => {
62+
const icon = <span data-testid="test-icon">Icon</span>;
63+
render(
64+
<MultiSelectFilter
65+
title="Test Filter"
66+
options={mockOptions}
67+
selectedValues={[]}
68+
onSelectionChange={mockOnSelectionChange}
69+
icon={icon}
70+
/>
71+
);
72+
73+
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
74+
});
75+
76+
it('displays completion stats correctly', async () => {
77+
const user = userEvent.setup();
78+
const completionStats = {
79+
'Option A': { completed: 5, total: 10, percentage: 50 },
80+
};
81+
82+
render(
83+
<MultiSelectFilter
84+
title="Test Filter"
85+
options={mockOptions}
86+
selectedValues={[]}
87+
onSelectionChange={mockOnSelectionChange}
88+
completionStats={completionStats}
89+
/>
90+
);
91+
92+
// Open popover to see options and stats
93+
await user.click(screen.getByRole('combobox'));
94+
95+
expect(screen.getByText('5/10 tasks, 50%')).toBeInTheDocument();
96+
});
97+
98+
it('opens and closes the popover', async () => {
99+
const user = userEvent.setup();
100+
render(
101+
<MultiSelectFilter
102+
title="Test Filter"
103+
options={mockOptions}
104+
selectedValues={[]}
105+
onSelectionChange={mockOnSelectionChange}
106+
/>
107+
);
108+
109+
const button = screen.getByRole('combobox');
110+
111+
// Open
112+
await user.click(button);
113+
expect(screen.getByText('All Test Filter')).toBeInTheDocument();
114+
115+
// Close by clicking button again
116+
await user.click(button);
117+
expect(screen.queryByText('All Test Filter')).not.toBeInTheDocument();
118+
119+
// Open again
120+
await user.click(button);
121+
expect(screen.getByText('All Test Filter')).toBeInTheDocument();
122+
123+
// Close by Escape
124+
await user.keyboard('{Escape}');
125+
expect(screen.queryByText('All Test Filter')).not.toBeInTheDocument();
126+
});
127+
128+
it('selects and deselects options', async () => {
129+
const user = userEvent.setup();
130+
render(
131+
<MultiSelectFilter
132+
title="Test Filter"
133+
options={mockOptions}
134+
selectedValues={['Option A']}
135+
onSelectionChange={mockOnSelectionChange}
136+
/>
137+
);
138+
139+
const button = screen.getByRole('combobox');
140+
await user.click(button);
141+
142+
// Select unselected option
143+
await user.click(screen.getByText('Option B'));
144+
expect(mockOnSelectionChange).toHaveBeenCalledWith([
145+
'Option A',
146+
'Option B',
147+
]);
148+
149+
// Deselect selected option
150+
await user.click(screen.getByText('Option A'));
151+
expect(mockOnSelectionChange).toHaveBeenCalledWith([]);
152+
});
153+
154+
it('clears selection when "All" option is clicked', async () => {
155+
const user = userEvent.setup();
156+
render(
157+
<MultiSelectFilter
158+
title="Test Filter"
159+
options={mockOptions}
160+
selectedValues={['Option A']}
161+
onSelectionChange={mockOnSelectionChange}
162+
/>
163+
);
164+
165+
const button = screen.getByRole('combobox');
166+
await user.click(button);
167+
168+
await user.click(screen.getByText('All Test Filter'));
169+
expect(mockOnSelectionChange).toHaveBeenCalledWith([]);
170+
});
171+
172+
it('filters options based on search input', async () => {
173+
const user = userEvent.setup();
174+
render(
175+
<MultiSelectFilter
176+
title="Test Filter"
177+
options={mockOptions}
178+
selectedValues={[]}
179+
onSelectionChange={mockOnSelectionChange}
180+
/>
181+
);
182+
183+
await user.click(screen.getByRole('combobox'));
184+
const searchInput = screen.getByPlaceholderText('Search test filter...');
185+
186+
await user.type(searchInput, 'Option A');
187+
188+
expect(screen.getByText('Option A')).toBeInTheDocument();
189+
expect(screen.queryByText('Option B')).not.toBeInTheDocument();
190+
});
191+
192+
it('displays "No results found" for no matches', async () => {
193+
const user = userEvent.setup();
194+
render(
195+
<MultiSelectFilter
196+
title="Test Filter"
197+
options={mockOptions}
198+
selectedValues={[]}
199+
onSelectionChange={mockOnSelectionChange}
200+
/>
201+
);
202+
203+
await user.click(screen.getByRole('combobox'));
204+
const searchInput = screen.getByPlaceholderText('Search test filter...');
205+
206+
await user.type(searchInput, 'Non-existent Option');
207+
208+
expect(screen.getByText('No results found.')).toBeInTheDocument();
209+
});
210+
211+
it('search is case-insensitive', async () => {
212+
const user = userEvent.setup();
213+
render(
214+
<MultiSelectFilter
215+
title="Test Filter"
216+
options={mockOptions}
217+
selectedValues={[]}
218+
onSelectionChange={mockOnSelectionChange}
219+
/>
220+
);
221+
222+
await user.click(screen.getByRole('combobox'));
223+
const searchInput = screen.getByPlaceholderText('Search test filter...');
224+
225+
await user.type(searchInput, 'option a');
226+
227+
expect(screen.getByText('Option A')).toBeInTheDocument();
228+
});
229+
});

0 commit comments

Comments
 (0)