Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions frontend/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@ import '@testing-library/jest-dom';
import { expect } from '@jest/globals';
import type { Plugin } from 'pretty-format';

// ResizeObserver polyfill
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};

// ScrollIntoView mock
window.HTMLElement.prototype.scrollIntoView = jest.fn();

// PointerEvent mock
class MockPointerEvent extends Event {
button: number;
ctrlKey: boolean;
pointerType: string;

constructor(type: string, props: PointerEventInit) {
super(type, props);
this.button = props.button || 0;
this.ctrlKey = props.ctrlKey || false;
this.pointerType = props.pointerType || 'mouse';
}
}
window.PointerEvent = MockPointerEvent as any;
window.HTMLElement.prototype.hasPointerCapture = jest.fn();
window.HTMLElement.prototype.releasePointerCapture = jest.fn();

let isSerializing = false;

const radixSnapshotSerializer: Plugin = {
Expand Down
114 changes: 114 additions & 0 deletions frontend/src/components/ui/__tests__/date-time-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DateTimePicker } from '../date-time-picker';
import '@testing-library/jest-dom';

describe('DateTimePicker', () => {
it('renders without crashing', () => {
const mockOnDateTimeChange = jest.fn();
render(
<DateTimePicker
date={undefined}
onDateTimeChange={mockOnDateTimeChange}
/>
);
expect(
screen.getByRole('button', { name: /calender-button/i })
).toBeInTheDocument();
});

it('opens and closes the popover when the trigger button is clicked', async () => {
const user = userEvent.setup();
const mockOnDateTimeChange = jest.fn();
render(
<DateTimePicker
date={undefined}
onDateTimeChange={mockOnDateTimeChange}
/>
);

const triggerButton = screen.getByRole('button', {
name: /calender-button/i,
});

// Open popover
await user.click(triggerButton);
expect(screen.getByRole('dialog')).toBeInTheDocument(); // Popover content is a dialog
expect(screen.getByText(/February 2026/)).toBeInTheDocument(); // Check for specific content inside the calendar

// Close popover using Escape key
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument(); // Check for absence of specific content
});
});

it('allows selecting a date from the calendar', async () => {
const user = userEvent.setup();
const mockOnDateTimeChange = jest.fn();
render(
<DateTimePicker
date={undefined}
onDateTimeChange={mockOnDateTimeChange}
/>
);

await user.click(screen.getByRole('button', { name: /calender-button/i })); // Open popover

// Find a date in the current month (e.g., the 15th)
const dateToSelect = screen.getByRole('gridcell', { name: '15' });
await user.click(dateToSelect);

// Expect the popover to close after selecting a date
await waitFor(() => {
expect(screen.queryByText(/February 2026/)).not.toBeInTheDocument();
});

// Check if onDateTimeChange was called with the correct date (year, month, and day)
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(1);
const calledDate = mockOnDateTimeChange.mock.calls[0][0];
expect(calledDate).toBeInstanceOf(Date);
expect(calledDate.getDate()).toBe(15);
expect(calledDate.getMonth()).toBe(new Date().getMonth()); // Assuming current month for simplicity
expect(calledDate.getFullYear()).toBe(new Date().getFullYear()); // Assuming current year for simplicity
expect(calledDate.getHours()).toBe(0); // Should reset time to 00:00:00
expect(mockOnDateTimeChange.mock.calls[0][1]).toBe(false); // hasTime should be false
});

it('allows selecting an hour, minute, and AM/PM', async () => {
const user = userEvent.setup();
const mockOnDateTimeChange = jest.fn();
const initialDate = new Date(2024, 0, 15, 10, 30); // Jan 15, 2024, 10:30 AM
render(
<DateTimePicker
date={initialDate}
onDateTimeChange={mockOnDateTimeChange}
/>
);

await user.click(screen.getByRole('button', { name: /calender-button/i })); // Open popover

// Verify time selection elements are present
expect(screen.getByText('AM')).toBeInTheDocument();
expect(screen.getByText('PM')).toBeInTheDocument();

// Select an hour (e.g., 2 PM)
await user.click(screen.getByRole('button', { name: '2' })); // Select hour 2
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(1); // One call for hour selection
let calledDate = mockOnDateTimeChange.mock.calls[0][0];
expect(calledDate.getHours()).toBe(2); // Should be 2 AM initially before PM is clicked

await user.click(screen.getByRole('button', { name: 'PM' })); // Select PM
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(2); // Second call for AM/PM selection
calledDate = mockOnDateTimeChange.mock.calls[1][0];
expect(calledDate.getHours()).toBe(14); // 2 PM
expect(mockOnDateTimeChange.mock.calls[1][1]).toBe(true); // hasTime should be true

// Select a minute (e.g., 45 minutes)
await user.click(screen.getByRole('button', { name: '45' }));
expect(mockOnDateTimeChange).toHaveBeenCalledTimes(3); // Third call for minute selection
calledDate = mockOnDateTimeChange.mock.calls[2][0];
expect(calledDate.getMinutes()).toBe(45);
expect(mockOnDateTimeChange.mock.calls[2][1]).toBe(true); // hasTime should be true
});
});
229 changes: 229 additions & 0 deletions frontend/src/components/ui/__tests__/multi-select-filter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MultiSelectFilter } from '../multi-select';
import '@testing-library/jest-dom';

describe('MultiSelectFilter', () => {
const mockOptions = ['Option A', 'Option B', 'Option C'];
const mockOnSelectionChange = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

it('renders with title and placeholder when no options selected', () => {
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
/>
);

expect(screen.getByText('Test Filter')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument(); // The button serves as a combobox trigger
});

it('renders with selected values', () => {
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={['Option A', 'Option C']}
onSelectionChange={mockOnSelectionChange}
/>
);

// Since selected values are visually indicated by checkboxes in the popover (which is closed),
// and potentially by a badge or count on the button (depending on implementation),
// let's check if the component renders without error first.
// Inspecting the component, it doesn't seem to show selected count on the button in this version, just the title.
// So we rely on the internal logic validation in interaction tests or if we open the popover.
// For this render test, ensuring it mounts with props is the baseline.
expect(screen.getByRole('combobox')).toBeInTheDocument();
});

it('applies custom className', () => {
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
className="custom-class"
/>
);

expect(screen.getByRole('combobox')).toHaveClass('custom-class');
});

it('renders icon when provided', () => {
const icon = <span data-testid="test-icon">Icon</span>;
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
icon={icon}
/>
);

expect(screen.getByTestId('test-icon')).toBeInTheDocument();
});

it('displays completion stats correctly', async () => {
const user = userEvent.setup();
const completionStats = {
'Option A': { completed: 5, total: 10, percentage: 50 },
};

render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
completionStats={completionStats}
/>
);

// Open popover to see options and stats
await user.click(screen.getByRole('combobox'));

expect(screen.getByText('5/10 tasks, 50%')).toBeInTheDocument();
});

it('opens and closes the popover', async () => {
const user = userEvent.setup();
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
/>
);

const button = screen.getByRole('combobox');

// Open
await user.click(button);
expect(screen.getByText('All Test Filter')).toBeInTheDocument();

// Close by clicking button again
await user.click(button);
expect(screen.queryByText('All Test Filter')).not.toBeInTheDocument();

// Open again
await user.click(button);
expect(screen.getByText('All Test Filter')).toBeInTheDocument();

// Close by Escape
await user.keyboard('{Escape}');
expect(screen.queryByText('All Test Filter')).not.toBeInTheDocument();
});

it('selects and deselects options', async () => {
const user = userEvent.setup();
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={['Option A']}
onSelectionChange={mockOnSelectionChange}
/>
);

const button = screen.getByRole('combobox');
await user.click(button);

// Select unselected option
await user.click(screen.getByText('Option B'));
expect(mockOnSelectionChange).toHaveBeenCalledWith([
'Option A',
'Option B',
]);

// Deselect selected option
await user.click(screen.getByText('Option A'));
expect(mockOnSelectionChange).toHaveBeenCalledWith([]);
});

it('clears selection when "All" option is clicked', async () => {
const user = userEvent.setup();
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={['Option A']}
onSelectionChange={mockOnSelectionChange}
/>
);

const button = screen.getByRole('combobox');
await user.click(button);

await user.click(screen.getByText('All Test Filter'));
expect(mockOnSelectionChange).toHaveBeenCalledWith([]);
});

it('filters options based on search input', async () => {
const user = userEvent.setup();
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
/>
);

await user.click(screen.getByRole('combobox'));
const searchInput = screen.getByPlaceholderText('Search test filter...');

await user.type(searchInput, 'Option A');

expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.queryByText('Option B')).not.toBeInTheDocument();
});

it('displays "No results found" for no matches', async () => {
const user = userEvent.setup();
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
/>
);

await user.click(screen.getByRole('combobox'));
const searchInput = screen.getByPlaceholderText('Search test filter...');

await user.type(searchInput, 'Non-existent Option');

expect(screen.getByText('No results found.')).toBeInTheDocument();
});

it('search is case-insensitive', async () => {
const user = userEvent.setup();
render(
<MultiSelectFilter
title="Test Filter"
options={mockOptions}
selectedValues={[]}
onSelectionChange={mockOnSelectionChange}
/>
);

await user.click(screen.getByRole('combobox'));
const searchInput = screen.getByPlaceholderText('Search test filter...');

await user.type(searchInput, 'option a');

expect(screen.getByText('Option A')).toBeInTheDocument();
});
});
4 changes: 4 additions & 0 deletions frontend/src/components/ui/date-time-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export const DateTimePicker = React.forwardRef<

isInternalUpdate.current = true;
onDateTimeChange(newDate, false);
setIsOpen((prev) => {
console.log('Closing popover, prev was:', prev);
return false;
});
} else {
setInternalDate(undefined);
setHasTime(false);
Expand Down
Loading
Loading