diff --git a/src/components/pluginList/PluginSearchBar.tsx b/src/components/pluginList/PluginSearchBar.tsx index 7171408..f170d2b 100644 --- a/src/components/pluginList/PluginSearchBar.tsx +++ b/src/components/pluginList/PluginSearchBar.tsx @@ -1,8 +1,6 @@ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; -import InputAdornment from '@mui/material/InputAdornment'; -import SearchIcon from '@mui/icons-material/Search'; +import { useRef } from 'react'; +import { Box, IconButton, InputAdornment, TextField, Typography } from '@mui/material'; +import { Clear, Search } from '@mui/icons-material'; import { alpha } from '@mui/material/styles'; import type { PluginStatusColor } from '../../types'; import { colors, statusColorMap, STATUS_CARD_DEFS } from '../../theme'; @@ -22,9 +20,17 @@ export default function PluginSearchBar({ onClearFilter, resultCount, }: PluginSearchBarProps) { + const inputRef = useRef(null); + + const handleClear = () => { + onSearchChange(''); + inputRef.current?.focus(); + }; + return ( - + ), + endAdornment: search ? ( + + e.preventDefault()} + aria-label="Clear search input" + edge="end" + sx={{ color: colors.text.muted, p: 0.5 }} + > + + + + ) : null, }, }} sx={{ diff --git a/src/components/recipeList/RecipeSearchBar.tsx b/src/components/recipeList/RecipeSearchBar.tsx index 1d11fc8..7f3f9a6 100644 --- a/src/components/recipeList/RecipeSearchBar.tsx +++ b/src/components/recipeList/RecipeSearchBar.tsx @@ -1,9 +1,10 @@ -import { Box, Typography, TextField, InputAdornment } from '@mui/material'; +import { useRef } from 'react'; +import { Box, IconButton, InputAdornment, TextField, Typography } from '@mui/material'; import { alpha } from '@mui/material/styles'; +import { Clear, SearchOutlined } from '@mui/icons-material'; import type { RateTier } from '../../util/recipeStatus'; import { RATE_CARD_DEFS } from '../../util/recipeStatus'; import { colors, rateTierColorMap } from '../../theme'; -import { SearchOutlined } from '@mui/icons-material'; interface RecipeSearchBarProps { search: string; @@ -20,9 +21,17 @@ export default function RecipeSearchBar({ onClearFilter, resultCount, }: RecipeSearchBarProps) { + const inputRef = useRef(null); + + const handleClear = () => { + onSearchChange(''); + inputRef.current?.focus(); + }; + return ( ), + endAdornment: search ? ( + + e.preventDefault()} + aria-label="Clear search input" + edge="end" + sx={{ color: colors.text.muted, p: 0.5 }} + > + + + + ) : null, }, }} sx={{ diff --git a/tests/components/pluginList/PluginSearchBar.test.tsx b/tests/components/pluginList/PluginSearchBar.test.tsx new file mode 100644 index 0000000..d513465 --- /dev/null +++ b/tests/components/pluginList/PluginSearchBar.test.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import PluginSearchBar from '../../../src/components/pluginList/PluginSearchBar'; + +describe('PluginSearchBar', () => { + it('renders search input with placeholder and no clear button when search is empty', () => { + render( + {}} + statusFilter="all" + onClearFilter={() => {}} + resultCount={42} + /> + ); + expect(screen.getByPlaceholderText('Search a plugin…')).toBeDefined(); + expect(screen.queryByRole('button', { name: 'Clear search input' })).toBeNull(); + console.log(` PluginSearchBar : search input with placeholder rendered, clear button absent when empty`); + }); + + it('displays result count correctly', () => { + render( + {}} + statusFilter="all" + onClearFilter={() => {}} + resultCount={42} + /> + ); + expect(screen.getByText('42 results')).toBeDefined(); + console.log(` PluginSearchBar : "42 results" displayed`); + }); + + it('displays singular "result" when count is 1', () => { + render( + {}} + statusFilter="all" + onClearFilter={() => {}} + resultCount={1} + /> + ); + expect(screen.getByText('1 result')).toBeDefined(); + console.log(` PluginSearchBar : "1 result" (singular) displayed`); + }); + + it('calls onSearchChange when typing in search input', () => { + const onChange = vi.fn(); + render( + {}} + resultCount={0} + /> + ); + fireEvent.change(screen.getByPlaceholderText('Search a plugin…'), { + target: { value: 'git' }, + }); + expect(onChange).toHaveBeenCalledWith('git'); + console.log(` PluginSearchBar : typing "git" triggers onSearchChange`); + }); + + it('shows status filter chip when statusFilter is not "all"', () => { + render( + {}} + statusFilter="green" + onClearFilter={() => {}} + resultCount={10} + /> + ); + expect(screen.getByText('All Passed')).toBeDefined(); + expect(screen.getByText('✕')).toBeDefined(); + console.log(` PluginSearchBar : "All Passed" status chip rendered`); + }); + + it('calls onClearFilter when status filter chip dismiss is clicked', () => { + const onClear = vi.fn(); + render( + {}} statusFilter="red" onClearFilter={onClear} resultCount={5} /> + ); + fireEvent.click(screen.getByText('✕').closest('button')!); + expect(onClear).toHaveBeenCalled(); + console.log(` PluginSearchBar : clicking status filter dismiss calls onClearFilter`); + }); + + it('renders search clear button when search is non-empty and triggers onSearchChange("") and restores focus when clicked', () => { + const onSearchChange = vi.fn(); + render( + {}} + resultCount={5} + /> + ); + + const input = screen.getByPlaceholderText('Search a plugin…'); + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + expect(clearButton).toBeDefined(); + + fireEvent.click(clearButton); + expect(onSearchChange).toHaveBeenCalledWith(''); + expect(document.activeElement).toBe(input); + console.log(` PluginSearchBar : clicking clear input button triggers onSearchChange("") and restores focus`); + }); + + it('prevents default on mouse down on clear button so input focus is preserved', () => { + render( + {}} + statusFilter="all" + onClearFilter={() => {}} + resultCount={5} + /> + ); + + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + const event = new MouseEvent('mousedown', { cancelable: true, bubbles: true }); + clearButton.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + console.log(` PluginSearchBar : mouse down on clear button prevents default`); + }); + + it('can activate clear button using keyboard and restores focus to search input', () => { + const onSearchChange = vi.fn(); + render( + {}} + resultCount={5} + /> + ); + + const input = screen.getByPlaceholderText('Search a plugin…'); + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + + clearButton.focus(); + expect(document.activeElement).toBe(clearButton); + + fireEvent.click(clearButton); + expect(onSearchChange).toHaveBeenCalledWith(''); + expect(document.activeElement).toBe(input); + console.log(` PluginSearchBar : keyboard activation restores focus to input`); + }); + + it('clearing search restores unfiltered results in a stateful search bar', () => { + function StatefulPluginSearchBar() { + const [search, setSearch] = useState('git'); + const plugins = ['git-plugin', 'github-branch-source', 'workflow-job']; + const filtered = plugins.filter((p) => p.includes(search)); + return ( +
+ {}} + resultCount={filtered.length} + /> +
{filtered.join(', ')}
+
+ ); + } + + render(); + expect(screen.getByTestId('results').textContent).toBe('git-plugin, github-branch-source'); + + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + fireEvent.click(clearButton); + + expect(screen.getByTestId('results').textContent).toBe('git-plugin, github-branch-source, workflow-job'); + expect(screen.queryByRole('button', { name: 'Clear search input' })).toBeNull(); + console.log(` PluginSearchBar : clearing search restores unfiltered results`); + }); +}); diff --git a/tests/components/recipeList/RecipeSearchBar.test.tsx b/tests/components/recipeList/RecipeSearchBar.test.tsx index 42ae59d..3b88510 100644 --- a/tests/components/recipeList/RecipeSearchBar.test.tsx +++ b/tests/components/recipeList/RecipeSearchBar.test.tsx @@ -1,15 +1,16 @@ -import React from 'react'; +import React, { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import RecipeSearchBar from '../../../src/components/recipeList/RecipeSearchBar'; describe('RecipeSearchBar', () => { - it('renders search input with placeholder', () => { + it('renders search input with placeholder and no clear button when search is empty', () => { render( {}} tierFilter="all" onClearFilter={() => {}} resultCount={20} /> ); expect(screen.getByPlaceholderText('Search a recipe…')).toBeDefined(); - console.log(` RecipeSearchBar : search input with placeholder rendered`); + expect(screen.queryByRole('button', { name: 'Clear search input' })).toBeNull(); + console.log(` RecipeSearchBar : search input with placeholder rendered, clear button absent when empty`); }); it('displays result count', () => { @@ -78,4 +79,98 @@ describe('RecipeSearchBar', () => { expect(onClear).toHaveBeenCalled(); console.log(` RecipeSearchBar : clicking dismiss calls onClearFilter`); }); + + it('renders search clear button when search is non-empty and triggers onSearchChange("") and restores focus when clicked', () => { + const onSearchChange = vi.fn(); + render( + {}} + resultCount={5} + /> + ); + + const input = screen.getByPlaceholderText('Search a recipe…'); + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + expect(clearButton).toBeDefined(); + + fireEvent.click(clearButton); + expect(onSearchChange).toHaveBeenCalledWith(''); + expect(document.activeElement).toBe(input); + console.log(` RecipeSearchBar : clicking clear input button triggers onSearchChange("") and restores focus`); + }); + + it('prevents default on mouse down on clear button so input focus is preserved', () => { + render( + {}} + tierFilter="all" + onClearFilter={() => {}} + resultCount={5} + /> + ); + + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + const event = new MouseEvent('mousedown', { cancelable: true, bubbles: true }); + clearButton.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + console.log(` RecipeSearchBar : mouse down on clear button prevents default`); + }); + + it('can activate clear button using keyboard and restores focus to search input', () => { + const onSearchChange = vi.fn(); + render( + {}} + resultCount={5} + /> + ); + + const input = screen.getByPlaceholderText('Search a recipe…'); + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + + clearButton.focus(); + expect(document.activeElement).toBe(clearButton); + + fireEvent.click(clearButton); + expect(onSearchChange).toHaveBeenCalledWith(''); + expect(document.activeElement).toBe(input); + console.log(` RecipeSearchBar : keyboard activation restores focus to input`); + }); + + it('clearing search restores unfiltered results in a stateful search bar', () => { + function StatefulRecipeSearchBar() { + const [search, setSearch] = useState('Setup'); + const recipes = ['SetupDependabot', 'SetupJenkinsfile', 'MigrateToJUnit5']; + const filtered = recipes.filter((r) => r.includes(search)); + return ( +
+ {}} + resultCount={filtered.length} + /> +
{filtered.join(', ')}
+
+ ); + } + + render(); + expect(screen.getByTestId('results').textContent).toBe('SetupDependabot, SetupJenkinsfile'); + + const clearButton = screen.getByRole('button', { name: 'Clear search input' }); + fireEvent.click(clearButton); + + expect(screen.getByTestId('results').textContent).toBe('SetupDependabot, SetupJenkinsfile, MigrateToJUnit5'); + expect(screen.queryByRole('button', { name: 'Clear search input' })).toBeNull(); + console.log(` RecipeSearchBar : clearing search restores unfiltered results`); + }); }); diff --git a/tests/pages/PluginList.integration.test.tsx b/tests/pages/PluginList.integration.test.tsx index 9f80b1f..b839e35 100644 --- a/tests/pages/PluginList.integration.test.tsx +++ b/tests/pages/PluginList.integration.test.tsx @@ -57,7 +57,7 @@ function countByStatus(plugins: PluginReport[]): Record { +describe('PluginList integration (real report.json)', { timeout: 30000 }, () => { let realReport: ReportJson; let expectedPlugins: PluginReport[]; let expectedCounts: Record; @@ -98,7 +98,7 @@ describe('PluginList integration (real report.json)', () => { }); } - it('renders the correct total plugin count from real data', async () => { + it('renders the correct total plugin count from real data', { timeout: 30000 }, async () => { await renderAndWait(); expect(screen.getByText('Plugins')).toBeDefined();