diff --git a/.tmp/proof/issue-143-explore-empty.png b/.tmp/proof/issue-143-explore-empty.png new file mode 100644 index 0000000..e541c9c Binary files /dev/null and b/.tmp/proof/issue-143-explore-empty.png differ diff --git a/.tmp/proof/issue-143-explore-mobile.png b/.tmp/proof/issue-143-explore-mobile.png new file mode 100644 index 0000000..8fa8ad9 Binary files /dev/null and b/.tmp/proof/issue-143-explore-mobile.png differ diff --git a/.tmp/proof/issue-143-explore-restored.png b/.tmp/proof/issue-143-explore-restored.png new file mode 100644 index 0000000..2073d6e Binary files /dev/null and b/.tmp/proof/issue-143-explore-restored.png differ diff --git a/packages/app/src/components/explore/CategoryFilter.tsx b/packages/app/src/components/explore/CategoryFilter.tsx index 92c2c34..fad59ef 100644 --- a/packages/app/src/components/explore/CategoryFilter.tsx +++ b/packages/app/src/components/explore/CategoryFilter.tsx @@ -21,6 +21,8 @@ export const CategoryFilter = ({ return ( onCategoryChange('all')} variant={selectedCategory === 'all' ? 'filled' : 'outlined'} color={selectedCategory === 'all' ? 'primary' : 'default'} @@ -45,6 +48,7 @@ export const CategoryFilter = ({ return ( { + it('labels search and exposes category selection and the tool count', async () => { + await renderInTestApp(); + + expect( + screen.getByRole('textbox', { name: 'Search tools' }), + ).toBeInTheDocument(); + const categories = within( + screen.getByRole('group', { name: 'Tool categories' }), + ); + expect( + categories.getByRole('button', { name: /^All \(/, pressed: true }), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + `${defaultTools.length} tools available`, + ); + + fireEvent.click( + categories.getByRole('button', { name: /^Development \(/ }), + ); + + expect( + categories.getByRole('button', { + name: /^Development \(/, + pressed: true, + }), + ).toBeInTheDocument(); + expect( + categories.getByRole('button', { name: /^All \(/, pressed: false }), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('4 tools available'); + expect(screen.getAllByRole('link', { name: 'Visit' })).toHaveLength(4); + expect(screen.queryByText('AWS Console')).not.toBeInTheDocument(); + }); + + it('combines search and category filters and restores all tools with focus', async () => { + await renderInTestApp(); + const search = screen.getByRole('textbox', { name: 'Search tools' }); + + fireEvent.change(search, { target: { value: 'github' } }); + expect(screen.getByRole('status')).toHaveTextContent('2 tools available'); + + fireEvent.click(screen.getByRole('button', { name: /^Security \(/ })); + + expect(screen.getByRole('status')).toHaveTextContent('0 tools available'); + expect(screen.getAllByText(/No tools found/)).toHaveLength(1); + expect(screen.getByText(/No tools found/)).toHaveTextContent( + 'No tools found matching "github" in Security', + ); + expect( + screen.queryByRole('link', { name: /Visit|Open/ }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })); + + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + expect( + screen.getByRole('button', { name: /^All \(/, pressed: true }), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + `${defaultTools.length} tools available`, + ); + expect(screen.getAllByRole('link', { name: /Visit|Open/ })).toHaveLength( + defaultTools.length, + ); + expect(screen.queryByText(/No tools found/)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Clear filters' }), + ).not.toBeInTheDocument(); + }); + + it('offers a single recoverable empty state across all categories', async () => { + await renderInTestApp(); + const search = screen.getByRole('textbox', { name: 'Search tools' }); + + fireEvent.change(search, { target: { value: 'no-such-tool' } }); + + expect(screen.getAllByText(/No tools found/)).toHaveLength(1); + expect(screen.getByText(/No tools found/)).toHaveTextContent( + 'No tools found matching "no-such-tool"', + ); + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })); + + expect(search).toHaveFocus(); + expect(screen.getAllByRole('link', { name: /Visit|Open/ })).toHaveLength( + defaultTools.length, + ); + }); + + it.each([ + ['GITHUB', 'GitHub', 2], + ['browser-based', 'VS Code Web', 1], + ['scm', 'GitLab', 2], + ])( + 'matches names, descriptions and tags for %s', + async (term, name, count) => { + await renderInTestApp(); + + fireEvent.change(screen.getByRole('textbox', { name: 'Search tools' }), { + target: { value: term }, + }); + + expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + `${count} tool${count === 1 ? '' : 's'} available`, + ); + expect(screen.getAllByRole('link', { name: 'Visit' })).toHaveLength( + count, + ); + }, + ); +}); diff --git a/packages/app/src/components/explore/ExplorePage.tsx b/packages/app/src/components/explore/ExplorePage.tsx index 942ef4f..850e3f1 100644 --- a/packages/app/src/components/explore/ExplorePage.tsx +++ b/packages/app/src/components/explore/ExplorePage.tsx @@ -1,8 +1,9 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef } from 'react'; 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 Button from '@mui/material/Button'; import { styled } from '@mui/material/styles'; import { Page, Header, Content } from '@backstage/core-components'; import SearchIcon from '@mui/icons-material/Search'; @@ -25,6 +26,7 @@ const SearchContainer = styled(Box)(({ theme }) => ({ })); export const ExplorePage = () => { + const searchInputRef = useRef(null); const [searchTerm, setSearchTerm] = useState(''); const [selectedCategory, setSelectedCategory] = useState< ToolCategory | 'all' @@ -121,11 +123,13 @@ export const ExplorePage = () => { setSearchTerm(e.target.value)} size="small" - sx={{ minWidth: 300, maxWidth: 400 }} + sx={{ width: '100%', maxWidth: 400 }} InputProps={{ startAdornment: ( @@ -134,7 +138,7 @@ export const ExplorePage = () => { ), }} /> - + {filteredTools.length} tool{filteredTools.length !== 1 ? 's' : ''}{' '} available @@ -147,39 +151,51 @@ export const ExplorePage = () => { categoryCounts={categoryCounts} /> - {selectedCategory === 'all' ? ( - // Show grouped by category - Object.entries(groupedTools) - .sort(([a], [b]) => - categoryLabels[a as ToolCategory].localeCompare( - categoryLabels[b as ToolCategory], - ), - ) - .map(([category, tools]) => ( - - - {categoryLabels[category as ToolCategory]} - - - - )) - ) : ( - // Show flat list for selected category - - )} - {filteredTools.length === 0 && ( - No tools found matching "{searchTerm}" + No tools found matching "{searchTerm}" {selectedCategory !== 'all' && ` in ${categoryLabels[selectedCategory]}`} + )} + + {filteredTools.length > 0 && + (selectedCategory === 'all' ? ( + // Show grouped by category + Object.entries(groupedTools) + .sort(([a], [b]) => + categoryLabels[a as ToolCategory].localeCompare( + categoryLabels[b as ToolCategory], + ), + ) + .map(([category, tools]) => ( + + + {categoryLabels[category as ToolCategory]} + + + + )) + ) : ( + // Show flat list for selected category + + ))} );