diff --git a/.tmp/proof/issue-141-scorecard-empty.png b/.tmp/proof/issue-141-scorecard-empty.png new file mode 100644 index 0000000..dd8cd73 Binary files /dev/null and b/.tmp/proof/issue-141-scorecard-empty.png differ diff --git a/.tmp/proof/issue-141-scorecard-populated.png b/.tmp/proof/issue-141-scorecard-populated.png new file mode 100644 index 0000000..1022851 Binary files /dev/null and b/.tmp/proof/issue-141-scorecard-populated.png differ diff --git a/packages/app/src/components/scorecard/ScorecardPage.test.tsx b/packages/app/src/components/scorecard/ScorecardPage.test.tsx new file mode 100644 index 0000000..cd3918f --- /dev/null +++ b/packages/app/src/components/scorecard/ScorecardPage.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { ScorecardPage } from './ScorecardPage'; + +describe('ScorecardPage', () => { + const getEntities = jest.fn(); + const demoService = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'demo-service', description: 'A demo service' }, + spec: { type: 'service', lifecycle: 'experimental', owner: 'guest' }, + }; + + const renderPage = () => + renderInTestApp( + + + , + ); + + beforeEach(() => { + getEntities.mockReset(); + }); + + it('shows a catalog link instead of invalid metrics when the catalog is empty', async () => { + getEntities.mockResolvedValue({ items: [] }); + + await renderPage(); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'No catalog entities to score', + ); + expect(screen.getByRole('alert')).toHaveTextContent( + 'Scorecards use components, APIs and systems from the catalog.', + ); + expect( + screen.getByRole('link', { name: 'Browse the catalog' }), + ).toHaveAttribute('href', '/catalog'); + expect(screen.queryByText('Average Score')).not.toBeInTheDocument(); + expect(screen.queryByText('Category Averages')).not.toBeInTheDocument(); + expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect( + screen.queryByText('No entities found matching your filters.'), + ).not.toBeInTheDocument(); + }); + + it('renders metrics and scorecards when catalog entities are available', async () => { + getEntities.mockResolvedValue({ items: [demoService] }); + + await renderPage(); + + expect(screen.getByText('demo-service')).toBeInTheDocument(); + expect(screen.getByText('Average Score')).toBeInTheDocument(); + expect(screen.getByText('Across 1 entities')).toBeInTheDocument(); + expect(screen.getByText('Category Averages')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect( + screen.queryByText('No catalog entities to score'), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); + }); + + it('keeps catalog metrics when search filters have no matches', async () => { + getEntities.mockResolvedValue({ items: [demoService] }); + + await renderPage(); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'no-matching-service' }, + }); + + expect( + screen.getByText('No entities found matching your filters.'), + ).toBeInTheDocument(); + expect(screen.getByText('Average Score')).toBeInTheDocument(); + expect(screen.queryByText('demo-service')).not.toBeInTheDocument(); + expect( + screen.queryByText('No catalog entities to score'), + ).not.toBeInTheDocument(); + }); + + it('waits for the catalog before showing an empty state', async () => { + getEntities.mockReturnValue(new Promise(() => {})); + + await renderPage(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect( + screen.queryByText('No catalog entities to score'), + ).not.toBeInTheDocument(); + }); + + it('reports catalog failures instead of showing an empty state', async () => { + getEntities.mockRejectedValue(new Error('Catalog unavailable')); + + await renderPage(); + + expect( + screen.getByText('Error loading entities: Catalog unavailable'), + ).toBeInTheDocument(); + expect( + screen.queryByText('No catalog entities to score'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Average Score')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/scorecard/ScorecardPage.tsx b/packages/app/src/components/scorecard/ScorecardPage.tsx index b800c4e..357afa4 100644 --- a/packages/app/src/components/scorecard/ScorecardPage.tsx +++ b/packages/app/src/components/scorecard/ScorecardPage.tsx @@ -9,8 +9,10 @@ import InputLabel from '@mui/material/InputLabel'; import Select from '@mui/material/Select'; 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 { styled } from '@mui/material/styles'; -import { Page, Header, Content } from '@backstage/core-components'; +import { Page, Header, Content, Link } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; @@ -171,6 +173,25 @@ export const ScorecardPage = () => { ); } + if (entities.length === 0) { + return ( + +
+ + + No catalog entities to score + Scorecards use components, APIs and systems from the catalog. Scores + will appear once these entities are available.{' '} + Browse the catalog + + + + ); + } + return (