diff --git a/.tmp/proof/issue-145-scorecard-cleared.png b/.tmp/proof/issue-145-scorecard-cleared.png new file mode 100644 index 0000000..c589844 Binary files /dev/null and b/.tmp/proof/issue-145-scorecard-cleared.png differ diff --git a/.tmp/proof/issue-145-scorecard-empty.png b/.tmp/proof/issue-145-scorecard-empty.png new file mode 100644 index 0000000..e10f6ed Binary files /dev/null and b/.tmp/proof/issue-145-scorecard-empty.png differ diff --git a/packages/app/src/components/scorecard/ScorecardPage.test.tsx b/packages/app/src/components/scorecard/ScorecardPage.test.tsx index cd3918f..b91297b 100644 --- a/packages/app/src/components/scorecard/ScorecardPage.test.tsx +++ b/packages/app/src/components/scorecard/ScorecardPage.test.tsx @@ -11,6 +11,12 @@ describe('ScorecardPage', () => { metadata: { name: 'demo-service', description: 'A demo service' }, spec: { type: 'service', lifecycle: 'experimental', owner: 'guest' }, }; + const demoApi = { + ...demoService, + kind: 'API', + metadata: { name: 'demo-api', description: 'Public endpoint' }, + spec: { ...demoService.spec, type: 'openapi' }, + }; const renderPage = () => renderInTestApp( @@ -81,6 +87,84 @@ describe('ScorecardPage', () => { ).not.toBeInTheDocument(); }); + it('labels every filter and announces the matching entity count', async () => { + getEntities.mockResolvedValue({ items: [demoService, demoApi] }); + + await renderPage(); + + expect( + screen.getByRole('textbox', { name: 'Search entities' }), + ).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Kind' })).toHaveTextContent( + 'All', + ); + expect(screen.getByRole('combobox', { name: 'Type' })).toHaveTextContent( + 'All', + ); + expect(screen.getByRole('status')).toHaveTextContent('2 entities found'); + expect( + screen.queryByRole('button', { name: 'Clear filters' }), + ).not.toBeInTheDocument(); + }); + + it('combines all filters and clears them with focus even when no entities match', async () => { + getEntities.mockResolvedValue({ items: [demoService, demoApi] }); + + await renderPage(); + const search = screen.getByRole('textbox', { name: 'Search entities' }); + + fireEvent.change(search, { target: { value: 'demo' } }); + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Kind' })); + fireEvent.click(screen.getByRole('option', { name: 'Component' })); + + expect(screen.getByRole('status')).toHaveTextContent('1 entity found'); + expect(screen.getByText('demo-service')).toBeInTheDocument(); + expect(screen.queryByText('demo-api')).not.toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Type' })); + fireEvent.click(screen.getByRole('option', { name: 'openapi' })); + + expect(screen.getByRole('status')).toHaveTextContent('0 entities found'); + expect( + screen.getByText('No entities found matching your filters.'), + ).toBeInTheDocument(); + expect(screen.getByText('Across 2 entities')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })); + + expect(search).toHaveValue(''); + expect(search).toHaveFocus(); + expect(screen.getByRole('combobox', { name: 'Kind' })).toHaveTextContent( + 'All', + ); + expect(screen.getByRole('combobox', { name: 'Type' })).toHaveTextContent( + 'All', + ); + expect(screen.getByRole('status')).toHaveTextContent('2 entities found'); + expect(screen.getByText('demo-service')).toBeInTheDocument(); + expect(screen.getByText('demo-api')).toBeInTheDocument(); + expect( + screen.queryByText('No entities found matching your filters.'), + ).not.toBeInTheDocument(); + }); + + it.each([' PUBLIC ', ' '])( + 'normalises search whitespace and case for %j', + async term => { + getEntities.mockResolvedValue({ items: [demoService, demoApi] }); + + await renderPage(); + fireEvent.change(screen.getByRole('textbox', { name: 'Search entities' }), { + target: { value: term }, + }); + + expect(screen.getByText('demo-api')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + term.trim() ? '1 entity found' : '2 entities found', + ); + }, + ); + it('waits for the catalog before showing an empty state', async () => { getEntities.mockReturnValue(new Promise(() => {})); diff --git a/packages/app/src/components/scorecard/ScorecardPage.tsx b/packages/app/src/components/scorecard/ScorecardPage.tsx index 357afa4..d0ce6ac 100644 --- a/packages/app/src/components/scorecard/ScorecardPage.tsx +++ b/packages/app/src/components/scorecard/ScorecardPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Grid from '@mui/material/Grid'; @@ -11,6 +11,7 @@ import MenuItem from '@mui/material/MenuItem'; import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; +import Button from '@mui/material/Button'; import { styled } from '@mui/material/styles'; import { Page, Header, Content, Link } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; @@ -78,6 +79,7 @@ const calculateEntityScore = (entity: Entity): EntityScore => { }; export const ScorecardPage = () => { + const searchInputRef = useRef(null); const catalogApi = useApi(catalogApiRef); const [entities, setEntities] = useState([]); const [loading, setLoading] = useState(true); @@ -111,16 +113,16 @@ export const ScorecardPage = () => { ...new Set(entities.map(e => e.spec?.type as string).filter(Boolean)), ]; + const searchQuery = searchTerm.trim().toLowerCase(); + const hasFilters = + searchTerm !== '' || kindFilter !== 'all' || typeFilter !== 'all'; + // Filter entities const filteredScores = entityScores.filter(es => { const matchesSearch = - searchTerm === '' || - es.entity.metadata.name - .toLowerCase() - .includes(searchTerm.toLowerCase()) || - es.entity.metadata.description - ?.toLowerCase() - .includes(searchTerm.toLowerCase()); + searchQuery === '' || + es.entity.metadata.name.toLowerCase().includes(searchQuery) || + es.entity.metadata.description?.toLowerCase().includes(searchQuery); const matchesKind = kindFilter === 'all' || es.entity.kind === kindFilter; const matchesType = typeFilter === 'all' || es.entity.spec?.type === typeFilter; @@ -226,7 +228,9 @@ export const ScorecardPage = () => { setSearchTerm(e.target.value)} size="small" @@ -240,8 +244,10 @@ export const ScorecardPage = () => { }} /> - Kind + Kind - Type + Type + {hasFilters && ( + + )} + + {sortedScores.length}{' '} + {sortedScores.length === 1 ? 'entity' : 'entities'} found + + {sortedScores.map(entityScore => (