507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import LabelSelector from '../LabelSelector';
|
|
import { type LabelData } from '../Label';
|
|
import { renderWithProviders } from '../../../test/test-utils';
|
|
import {
|
|
testDataBuilders
|
|
} from '../../../test/label-test-utils';
|
|
|
|
const mockLabels = testDataBuilders.createTypicalLabelSet();
|
|
|
|
const renderLabelSelector = (props: Partial<React.ComponentProps<typeof LabelSelector>> = {}) => {
|
|
const defaultProps = {
|
|
selectedLabels: [],
|
|
availableLabels: mockLabels,
|
|
onLabelsChange: vi.fn(),
|
|
...props,
|
|
};
|
|
|
|
return renderWithProviders(<LabelSelector {...defaultProps} />);
|
|
};
|
|
|
|
describe('LabelSelector Component', () => {
|
|
let user: ReturnType<typeof userEvent.setup>;
|
|
|
|
beforeEach(() => {
|
|
user = userEvent.setup();
|
|
});
|
|
|
|
describe('Basic Rendering', () => {
|
|
test('should render autocomplete input', () => {
|
|
renderLabelSelector();
|
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
});
|
|
|
|
test('should show placeholder text', () => {
|
|
renderLabelSelector();
|
|
expect(screen.getByPlaceholderText('Search or create labels...')).toBeInTheDocument();
|
|
});
|
|
|
|
test('should show custom placeholder', () => {
|
|
renderLabelSelector({ placeholder: 'Custom placeholder' });
|
|
expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument();
|
|
});
|
|
|
|
test('should render with selected labels', () => {
|
|
const selectedLabels = [mockLabels[0]];
|
|
renderLabelSelector({ selectedLabels });
|
|
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Label Selection', () => {
|
|
test('should call onLabelsChange when label is selected', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
renderLabelSelector({ onLabelsChange });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
// Wait for options to appear and click on one
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Important'));
|
|
|
|
expect(onLabelsChange).toHaveBeenCalledWith([mockLabels[0]]);
|
|
});
|
|
|
|
test('should filter out already selected labels from options', async () => {
|
|
const selectedLabels = [mockLabels[0]]; // Important is selected
|
|
renderLabelSelector({ selectedLabels });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
expect(screen.getByText('Project Alpha')).toBeInTheDocument();
|
|
});
|
|
|
|
// Important should not appear in the dropdown options (but may appear in selected tags)
|
|
// We need to check specifically in the dropdown, not in the entire document
|
|
const dropdownOptions = screen.getByRole('listbox');
|
|
expect(dropdownOptions).toBeInTheDocument();
|
|
|
|
// Check that Important is not in the dropdown options
|
|
const optionsList = screen.getAllByRole('option');
|
|
const optionTexts = optionsList.map(option => option.textContent);
|
|
expect(optionTexts).not.toContain('Important');
|
|
});
|
|
|
|
test('should support single selection mode', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
renderLabelSelector({
|
|
onLabelsChange,
|
|
multiple: false
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Important'));
|
|
|
|
expect(onLabelsChange).toHaveBeenCalledWith([mockLabels[0]]);
|
|
});
|
|
|
|
test('should support multiple selection mode', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
const selectedLabels = [mockLabels[0]];
|
|
|
|
renderLabelSelector({
|
|
selectedLabels,
|
|
onLabelsChange,
|
|
multiple: true
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Work'));
|
|
|
|
expect(onLabelsChange).toHaveBeenCalledWith([mockLabels[0], mockLabels[1]]);
|
|
});
|
|
});
|
|
|
|
describe('Label Removal', () => {
|
|
test('should remove label when delete button is clicked', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
// Use only non-system labels since system labels don't have delete buttons
|
|
const selectedLabels = [mockLabels[3]]; // Project Alpha (non-system)
|
|
|
|
renderLabelSelector({
|
|
selectedLabels,
|
|
onLabelsChange
|
|
});
|
|
|
|
// Find the chip with the delete button
|
|
const personalProjectChip = screen.getByText('Project Alpha').closest('.MuiChip-root');
|
|
expect(personalProjectChip).toBeInTheDocument();
|
|
|
|
// Find the delete button within that specific chip
|
|
const deleteButton = personalProjectChip?.querySelector('[data-testid="CloseIcon"]');
|
|
expect(deleteButton).toBeInTheDocument();
|
|
|
|
if (deleteButton) {
|
|
await user.click(deleteButton as Element);
|
|
}
|
|
|
|
expect(onLabelsChange).toHaveBeenCalledWith([]);
|
|
});
|
|
|
|
test('should not show delete buttons when disabled', () => {
|
|
const selectedLabels = [mockLabels[3]]; // Non-system label
|
|
|
|
renderLabelSelector({
|
|
selectedLabels,
|
|
disabled: true
|
|
});
|
|
|
|
expect(screen.queryByTestId('CloseIcon')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Label Grouping', () => {
|
|
test('should group system and user labels', async () => {
|
|
renderLabelSelector();
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
// Check that labels appear in the dropdown
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
expect(screen.getByText('Project Alpha')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test('should show only system labels when no user labels exist', async () => {
|
|
const systemOnlyLabels = mockLabels.filter(label => label.is_system);
|
|
renderLabelSelector({ availableLabels: systemOnlyLabels });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
expect(screen.queryByText('Project Alpha')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Search Functionality', () => {
|
|
test('should filter labels based on search input', async () => {
|
|
renderLabelSelector();
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'work');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
expect(screen.queryByText('Important')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Project Alpha')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test('should filter by description as well as name', async () => {
|
|
renderLabelSelector();
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'priority');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test('should show no options text when no matches found', async () => {
|
|
renderLabelSelector();
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'nonexistent');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('No labels match "nonexistent"')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Create New Label', () => {
|
|
test('should show create button when input has new text', async () => {
|
|
const onCreateLabel = vi.fn().mockResolvedValue({
|
|
id: 'new-label',
|
|
name: 'New Label',
|
|
color: '#0969da',
|
|
is_system: false,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
document_count: 0,
|
|
source_count: 0,
|
|
});
|
|
|
|
renderLabelSelector({
|
|
onCreateLabel,
|
|
showCreateButton: true
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'New Label');
|
|
|
|
await waitFor(() => {
|
|
// Look for the create buttons - there should be multiple
|
|
expect(screen.getAllByText('Create label "New Label"').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
test('should not show create button when onCreateLabel is not provided', async () => {
|
|
renderLabelSelector({ showCreateButton: true });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'New Label');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Create label "New Label"')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test('should not show create button when showCreateButton is false', async () => {
|
|
const onCreateLabel = vi.fn();
|
|
|
|
renderLabelSelector({
|
|
onCreateLabel,
|
|
showCreateButton: false
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'New Label');
|
|
|
|
// Should not show create button
|
|
expect(screen.queryByTitle('Create label "New Label"')).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('should not show create button for existing label names', async () => {
|
|
const onCreateLabel = vi.fn();
|
|
|
|
renderLabelSelector({
|
|
onCreateLabel,
|
|
showCreateButton: true
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'Important'); // Existing label name
|
|
|
|
// Should not show create button for existing names
|
|
expect(screen.queryByText('Create "Important"')).not.toBeInTheDocument();
|
|
});
|
|
|
|
test('should call onCreateLabel when create button is clicked', async () => {
|
|
const onCreateLabel = vi.fn().mockResolvedValue({
|
|
id: 'new-label',
|
|
name: 'New Label',
|
|
color: '#0969da',
|
|
is_system: false,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
document_count: 0,
|
|
source_count: 0,
|
|
});
|
|
|
|
const onLabelsChange = vi.fn();
|
|
|
|
renderLabelSelector({
|
|
onCreateLabel,
|
|
onLabelsChange,
|
|
showCreateButton: true
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'New Label');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('Create label "New Label"').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
const createButtons = screen.getAllByText('Create label "New Label"');
|
|
await user.click(createButtons[0]);
|
|
|
|
// Wait for dialog to open
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Create New Label')).toBeInTheDocument();
|
|
});
|
|
|
|
// Submit the form (the name is already pre-filled)
|
|
const createButton = screen.getByRole('button', { name: 'Create' });
|
|
await user.click(createButton);
|
|
|
|
await waitFor(() => {
|
|
expect(onCreateLabel).toHaveBeenCalledWith(expect.objectContaining({
|
|
name: 'New Label',
|
|
description: undefined,
|
|
color: '#0969da',
|
|
background_color: undefined,
|
|
icon: undefined,
|
|
}));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Max Tags Limit', () => {
|
|
test('should respect maxTags limit', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
const selectedLabels = [mockLabels[0], mockLabels[1]]; // 2 labels selected
|
|
|
|
renderLabelSelector({
|
|
selectedLabels,
|
|
onLabelsChange,
|
|
maxTags: 2
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Project Alpha')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Project Alpha'));
|
|
|
|
// Should not add the third label due to maxTags limit
|
|
expect(onLabelsChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should allow adding labels when under the limit', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
const selectedLabels = [mockLabels[0]]; // 1 label selected
|
|
|
|
renderLabelSelector({
|
|
selectedLabels,
|
|
onLabelsChange,
|
|
maxTags: 2
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText('Work'));
|
|
|
|
// Should add the second label as we're under the limit
|
|
expect(onLabelsChange).toHaveBeenCalledWith([mockLabels[0], mockLabels[1]]);
|
|
});
|
|
});
|
|
|
|
describe('Disabled State', () => {
|
|
test('should disable input when disabled prop is true', () => {
|
|
renderLabelSelector({ disabled: true });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
expect(input).toBeDisabled();
|
|
});
|
|
|
|
test('should not show create button when disabled', async () => {
|
|
const onCreateLabel = vi.fn();
|
|
|
|
renderLabelSelector({
|
|
onCreateLabel,
|
|
disabled: true,
|
|
showCreateButton: true
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
// Cannot type when disabled
|
|
expect(input).toBeDisabled();
|
|
});
|
|
});
|
|
|
|
describe('Size Variants', () => {
|
|
test('should render with small size', () => {
|
|
renderLabelSelector({ size: 'small' });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
// The size class is applied to the OutlinedInput root element
|
|
const inputContainer = input.closest('.MuiOutlinedInput-root');
|
|
expect(inputContainer).toHaveClass('MuiInputBase-sizeSmall');
|
|
});
|
|
|
|
test('should render with medium size by default', () => {
|
|
renderLabelSelector();
|
|
|
|
const input = screen.getByRole('combobox');
|
|
expect(input.parentElement?.parentElement).not.toHaveClass('MuiInputBase-sizeSmall');
|
|
});
|
|
});
|
|
|
|
describe('Keyboard Navigation', () => {
|
|
test('should support keyboard navigation through options', async () => {
|
|
const onLabelsChange = vi.fn();
|
|
renderLabelSelector({ onLabelsChange });
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.click(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Important')).toBeInTheDocument();
|
|
});
|
|
|
|
// Navigate with arrow keys and select with Enter
|
|
await user.keyboard('{ArrowDown}');
|
|
await user.keyboard('{Enter}');
|
|
|
|
expect(onLabelsChange).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
test('should handle create label error gracefully', async () => {
|
|
const onCreateLabel = vi.fn().mockRejectedValue(new Error('Create failed'));
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
renderLabelSelector({
|
|
onCreateLabel,
|
|
showCreateButton: true
|
|
});
|
|
|
|
const input = screen.getByRole('combobox');
|
|
await user.type(input, 'New Label');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('Create label "New Label"').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
const createButtons = screen.getAllByText('Create label "New Label"');
|
|
await user.click(createButtons[0]);
|
|
|
|
// Wait for dialog to open
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Create New Label')).toBeInTheDocument();
|
|
});
|
|
|
|
// Submit the form to trigger the error
|
|
const createButton = screen.getByRole('button', { name: 'Create' });
|
|
await user.click(createButton);
|
|
|
|
await waitFor(() => {
|
|
expect(consoleError).toHaveBeenCalledWith('Failed to create label:', expect.any(Error));
|
|
});
|
|
|
|
consoleError.mockRestore();
|
|
});
|
|
});
|
|
}); |