Skip to content
Merged
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
Binary file added .tmp/proof/issue-143-explore-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .tmp/proof/issue-143-explore-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .tmp/proof/issue-143-explore-restored.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/app/src/components/explore/CategoryFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const CategoryFilter = ({

return (
<Box
role="group"
aria-label="Tool categories"
sx={{
display: 'flex',
gap: 1.5,
Expand All @@ -30,6 +32,7 @@ export const CategoryFilter = ({
>
<Chip
label={`All${categoryCounts ? ` (${categoryCounts.all})` : ''}`}
aria-pressed={selectedCategory === 'all'}
onClick={() => onCategoryChange('all')}
variant={selectedCategory === 'all' ? 'filled' : 'outlined'}
color={selectedCategory === 'all' ? 'primary' : 'default'}
Expand All @@ -45,6 +48,7 @@ export const CategoryFilter = ({
return (
<Chip
key={category}
aria-pressed={isSelected}
label={`${categoryLabels[category]}${
count !== undefined ? ` (${count})` : ''
}`}
Expand Down
118 changes: 118 additions & 0 deletions packages/app/src/components/explore/ExplorePage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { fireEvent, screen, within } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { ExplorePage } from './ExplorePage';
import { defaultTools } from './toolsConfig';

describe('ExplorePage', () => {
it('labels search and exposes category selection and the tool count', async () => {
await renderInTestApp(<ExplorePage />);

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(<ExplorePage />);
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(<ExplorePage />);
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(<ExplorePage />);

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,
);
},
);
});
72 changes: 44 additions & 28 deletions packages/app/src/components/explore/ExplorePage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,6 +26,7 @@ const SearchContainer = styled(Box)(({ theme }) => ({
}));

export const ExplorePage = () => {
const searchInputRef = useRef<HTMLInputElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<
ToolCategory | 'all'
Expand Down Expand Up @@ -121,11 +123,13 @@ export const ExplorePage = () => {

<SearchContainer sx={{ mb: 4 }}>
<TextField
label="Search tools"
placeholder="Search tools..."
inputRef={searchInputRef}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
size="small"
sx={{ minWidth: 300, maxWidth: 400 }}
sx={{ width: '100%', maxWidth: 400 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
Expand All @@ -134,7 +138,7 @@ export const ExplorePage = () => {
),
}}
/>
<Typography variant="body2" color="text.secondary">
<Typography role="status" variant="body2" color="text.secondary">
{filteredTools.length} tool{filteredTools.length !== 1 ? 's' : ''}{' '}
available
</Typography>
Expand All @@ -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]) => (
<Box key={category} sx={{ mb: 5 }}>
<Typography
variant="h6"
sx={{ mb: 3, color: 'text.secondary' }}
>
{categoryLabels[category as ToolCategory]}
</Typography>
<ToolGrid tools={tools} />
</Box>
))
) : (
// Show flat list for selected category
<ToolGrid tools={filteredTools} />
)}

{filteredTools.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="text.secondary">
No tools found matching "{searchTerm}"
No tools found matching &quot;{searchTerm}&quot;
{selectedCategory !== 'all' &&
` in ${categoryLabels[selectedCategory]}`}
</Typography>
<Button
sx={{ mt: 2 }}
variant="outlined"
onClick={() => {
setSearchTerm('');
setSelectedCategory('all');
searchInputRef.current?.focus();
}}
>
Clear filters
</Button>
</Box>
)}

{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]) => (
<Box key={category} sx={{ mb: 5 }}>
<Typography
variant="h6"
sx={{ mb: 3, color: 'text.secondary' }}
>
{categoryLabels[category as ToolCategory]}
</Typography>
<ToolGrid tools={tools} />
</Box>
))
) : (
// Show flat list for selected category
<ToolGrid tools={filteredTools} />
))}
</Content>
</Page>
);
Expand Down
Loading