diff --git a/frontend/__tests__/e2e/pages/ProjectsHealthDashboardMetrics.spec.ts b/frontend/__tests__/e2e/pages/ProjectsHealthDashboardMetrics.spec.ts index 999beea379..7003db0639 100644 --- a/frontend/__tests__/e2e/pages/ProjectsHealthDashboardMetrics.spec.ts +++ b/frontend/__tests__/e2e/pages/ProjectsHealthDashboardMetrics.spec.ts @@ -21,19 +21,59 @@ test.describe('Projects Health Dashboard Metrics', () => { await mockDashboardCookies(page, mockHealthMetricsData, true) await page.goto('/projects/dashboard/metrics') const firstMetric = mockHealthMetricsData.projectHealthMetrics[0] - await expect(page.getByText(firstMetric.projectName)).toBeVisible({ timeout: 10000 }) - await expect(page.getByText(firstMetric.starsCount.toString())).toBeVisible() - await expect(page.getByText(firstMetric.forksCount.toString())).toBeVisible() - await expect(page.getByText(firstMetric.contributorsCount.toString())).toBeVisible() + const metricsLink = page + .getByRole('link') + .filter({ + has: page.getByText(firstMetric.projectName), + }) + .first() + + await expect(metricsLink).toBeVisible({ timeout: 10000 }) + await expect( + page + .getByRole('link') + .filter({ + has: page.getByText(firstMetric.starsCount.toString()), + }) + .first() + ).toBeVisible() + await expect( + page + .getByRole('link') + .filter({ + has: page.getByText(firstMetric.forksCount.toString()), + }) + .first() + ).toBeVisible() + await expect( + page + .getByRole('link') + .filter({ + has: page.getByText(firstMetric.contributorsCount.toString()), + }) + .first() + ).toBeVisible() + await expect( + page + .getByRole('link') + .filter({ + has: page.getByText( + new Date(firstMetric.createdAt).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + ), + }) + .first() + ).toBeVisible() await expect( - page.getByText( - new Date(firstMetric.createdAt).toLocaleString('default', { - month: 'short', - day: 'numeric', - year: 'numeric', + page + .getByRole('link') + .filter({ + has: page.getByText(firstMetric.score.toString()), }) - ) + .first() ).toBeVisible() - await expect(page.getByText(firstMetric.score.toString())).toBeVisible() }) }) diff --git a/frontend/__tests__/unit/components/MetricsCard.test.tsx b/frontend/__tests__/unit/components/MetricsCard.test.tsx index af24f38b7c..7ea113a9da 100644 --- a/frontend/__tests__/unit/components/MetricsCard.test.tsx +++ b/frontend/__tests__/unit/components/MetricsCard.test.tsx @@ -39,12 +39,12 @@ describe('MetricsCard component', () => { const metric = makeMetric() render() - expect(screen.getByText('Test Project')).toBeInTheDocument() - expect(screen.getByText('42')).toBeInTheDocument() - expect(screen.getByText('13')).toBeInTheDocument() - expect(screen.getByText('5')).toBeInTheDocument() - expect(screen.getByText('Mar 25, 2023')).toBeInTheDocument() - expect(screen.getByText('80')).toBeInTheDocument() + expect(screen.getAllByText('Test Project')[0]).toBeInTheDocument() + expect(screen.getAllByText('42')[0]).toBeInTheDocument() + expect(screen.getAllByText('13')[0]).toBeInTheDocument() + expect(screen.getAllByText('5')[0]).toBeInTheDocument() + expect(screen.getAllByText('Mar 25, 2023')[0]).toBeInTheDocument() + expect(screen.getByText(/Score:/)).toBeInTheDocument() const link = screen.getByRole('link') expect(link).toHaveAttribute('href', '/projects/dashboard/metrics/test-project') @@ -53,30 +53,32 @@ describe('MetricsCard component', () => { it('renders "No name" placeholder when projectName is empty', () => { const metric = makeMetric({ projectName: '' }) render() - expect(screen.getByText('No name')).toBeInTheDocument() + expect(screen.getAllByText('No name')[0]).toBeInTheDocument() }) it('applies correct styling class depending on score thresholds', () => { const cases: Array<[number, string]> = [ - [90, 'text-green-900'], - [75, 'text-green-900'], - [60, 'text-orange-900'], - [50, 'text-orange-900'], - [30, 'text-red-900'], + [90, 'bg-green-500'], + [75, 'bg-green-500'], + [60, 'bg-orange-500'], + [50, 'bg-orange-500'], + [30, 'bg-red-500'], ] for (const [score, expectedClass] of cases) { const metric = makeMetric({ score }) - render() - const scoreEl = screen.getByText(score.toString()).closest('div') + const { unmount } = render() + const scoreText = screen.getByText(/Score:/) + const scoreEl = scoreText.closest('div') expect(scoreEl).toHaveClass(expectedClass) + unmount() } }) it('updates displayed values and link when metric props change via rerender', () => { const { rerender } = render() - expect(screen.getByText('Test Project')).toBeInTheDocument() - expect(screen.getByText('80')).toBeInTheDocument() + expect(screen.getAllByText('Test Project')[0]).toBeInTheDocument() + expect(screen.getByText(/Score:/).textContent).toContain('80') const updated = makeMetric({ projectKey: 'another', @@ -89,12 +91,12 @@ describe('MetricsCard component', () => { }) rerender() - expect(screen.getByText('Another Project')).toBeInTheDocument() - expect(screen.getByText('99')).toBeInTheDocument() - expect(screen.getByText('20')).toBeInTheDocument() - expect(screen.getByText('7')).toBeInTheDocument() - expect(screen.getByText('Jan 1, 2024')).toBeInTheDocument() - expect(screen.getByText('55')).toBeInTheDocument() + expect(screen.getAllByText('Another Project')[0]).toBeInTheDocument() + expect(screen.getAllByText('99')[0]).toBeInTheDocument() + expect(screen.getAllByText('20')[0]).toBeInTheDocument() + expect(screen.getAllByText('7')[0]).toBeInTheDocument() + expect(screen.getAllByText('Jan 1, 2024')[0]).toBeInTheDocument() + expect(screen.getByText(/Score:/).textContent).toContain('55') expect(screen.getByRole('link')).toHaveAttribute('href', '/projects/dashboard/metrics/another') }) }) diff --git a/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx b/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx index 346c6599fd..aff5b21169 100644 --- a/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx @@ -91,15 +91,6 @@ describe('MetricsPage', () => { }) } - const expectAllHeadersVisible = async () => { - const headers = ['Project Name', 'Stars', 'Forks', 'Contributors', 'Health Checked At', 'Score'] - await waitFor(() => { - for (const header of headers) { - expect(screen.getAllByText(header).length).toBeGreaterThan(0) - } - }) - } - const testFilterOptions = async () => { const filterOptions = [ 'Incubator', @@ -119,14 +110,6 @@ describe('MetricsPage', () => { } } - const testSortableColumns = async () => { - const sortableColumns = ['Stars', 'Forks', 'Contributors', 'Health Checked At', 'Score'] - for (const column of sortableColumns) { - const sortButton = screen.getByTitle(`Sort by ${column}`) - expect(sortButton).toBeInTheDocument() - } - } - const testFilterSections = async () => { const filterSectionsLabels = ['Project Level', 'Project Health', 'Reset Filters'] for (const label of filterSectionsLabels) { @@ -165,99 +148,33 @@ describe('MetricsPage', () => { expect(true).toBe(true) }) - test('renders metrics table headers', async () => { - render() - await expectAllHeadersVisible() - - expect(true).toBe(true) - }) - - test('renders filter dropdown and sortable column headers', async () => { + test('renders filter dropdown', async () => { render() await waitFor(async () => { await testFilterSections() await testFilterOptions() - await testSortableColumns() }) expect(true).toBe(true) }) - test('SortableColumnHeader applies correct alignment classes', async () => { - render() - const sortButton = await screen.findByTitle('Sort by Stars') - const wrapperDiv = sortButton.closest('div') - expect(wrapperDiv).not.toBeNull() - expect(wrapperDiv).toHaveClass('justify-center') - expect(sortButton).toHaveClass('text-center') - }) - - test('handles sorting state and URL updates', async () => { - const mockReplace = jest.fn() - const { useRouter, useSearchParams } = jest.requireMock('next/navigation') - ;(useRouter as jest.Mock).mockReturnValue({ - push: jest.fn(), - replace: mockReplace, - }) - - // Test unsorted -> descending - ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()) - const { rerender } = render() - - const sortButton = screen.getByTitle('Sort by Stars') - fireEvent.click(sortButton) - - await waitFor(() => { - const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1][0] - const url = new URL(lastCall, 'http://localhost') - expect(url.searchParams.get('order')).toBe('-stars') - }) - - // Test descending -> ascending - mockReplace.mockClear() - ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams('order=-stars')) - rerender() - - const sortButtonDesc = screen.getByTitle('Sort by Stars') - fireEvent.click(sortButtonDesc) - - await waitFor(() => { - const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1][0] - const url = new URL(lastCall, 'http://localhost') - expect(url.searchParams.get('order')).toBe('stars') - }) - - // Test ascending -> unsorted (removes order param, defaults to -score) - mockReplace.mockClear() - ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams('order=stars')) - rerender() - - const sortButtonAsc = screen.getByTitle('Sort by Stars') - fireEvent.click(sortButtonAsc) - - await waitFor(() => { - const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1][0] - const url = new URL(lastCall, 'http://localhost') - expect(url.searchParams.get('order')).toBeNull() - }) - }) const testMetricsDataDisplay = async () => { const metrics = mockHealthMetricsData.projectHealthMetrics for (const metric of metrics) { - expect(screen.getByText(metric.projectName)).toBeInTheDocument() - expect(screen.getByText(metric.starsCount.toString())).toBeInTheDocument() - expect(screen.getByText(metric.forksCount.toString())).toBeInTheDocument() - expect(screen.getByText(metric.contributorsCount.toString())).toBeInTheDocument() + expect(screen.getAllByText(metric.projectName)[0]).toBeInTheDocument() + expect(screen.getAllByText(metric.starsCount.toString())[0]).toBeInTheDocument() + expect(screen.getAllByText(metric.forksCount.toString())[0]).toBeInTheDocument() + expect(screen.getAllByText(metric.contributorsCount.toString())[0]).toBeInTheDocument() expect( - screen.getByText( + screen.getAllByText( new Date(metric.createdAt).toLocaleString('default', { month: 'short', day: 'numeric', year: 'numeric', }) - ) + )[0] ).toBeInTheDocument() - expect(screen.getByText(metric.score.toString())).toBeInTheDocument() + expect(screen.getByText(new RegExp(`Score:.*${metric.score}`))).toBeInTheDocument() } } diff --git a/frontend/src/app/projects/dashboard/metrics/page.tsx b/frontend/src/app/projects/dashboard/metrics/page.tsx index 5a6531209b..08d2bca077 100644 --- a/frontend/src/app/projects/dashboard/metrics/page.tsx +++ b/frontend/src/app/projects/dashboard/metrics/page.tsx @@ -3,9 +3,7 @@ import { useQuery } from '@apollo/client/react' import { Pagination } from '@heroui/react' import { useSearchParams, useRouter } from 'next/navigation' import { FC, useState, useEffect } from 'react' -import type { IconType } from 'react-icons' -import { FaFilter, FaSort, FaSortUp, FaSortDown } from 'react-icons/fa6' -import { IconWrapper } from 'wrappers/IconWrapper' +import { FaFilter, FaArrowDownWideShort, FaArrowUpWideShort } from 'react-icons/fa6' import { handleAppError } from 'app/global-error' import { Ordering } from 'types/__generated__/graphql' import { GetProjectHealthMetricsDocument } from 'types/__generated__/projectsHealthDashboardQueries.generated' @@ -18,30 +16,42 @@ import ProjectsDashboardDropDown from 'components/ProjectsDashboardDropDown' const PAGINATION_LIMIT = 10 -const FIELD_MAPPING = { - contributors: 'contributorsCount', - createdAt: 'createdAt', - forks: 'forksCount', - score: 'score', - stars: 'starsCount', +const ORDERING_MAP = { + scoreDesc: { field: 'score', direction: Ordering.Desc }, + scoreAsc: { field: 'score', direction: Ordering.Asc }, + starsDesc: { field: 'starsCount', direction: Ordering.Desc }, + starsAsc: { field: 'starsCount', direction: Ordering.Asc }, + forksDesc: { field: 'forksCount', direction: Ordering.Desc }, + forksAsc: { field: 'forksCount', direction: Ordering.Asc }, + contributorsDesc: { field: 'contributorsCount', direction: Ordering.Desc }, + contributorsAsc: { field: 'contributorsCount', direction: Ordering.Asc }, + createdAtDesc: { field: 'createdAt', direction: Ordering.Desc }, + createdAtAsc: { field: 'createdAt', direction: Ordering.Asc }, } as const -type OrderKey = keyof typeof FIELD_MAPPING +type OrderingKey = keyof typeof ORDERING_MAP + +const SORT_FIELDS = [ + { label: 'Score (High → Low)', key: 'scoreDesc' }, + { label: 'Score (Low → High)', key: 'scoreAsc' }, + { label: 'Stars (High → Low)', key: 'starsDesc' }, + { label: 'Stars (Low → High)', key: 'starsAsc' }, + { label: 'Forks (High → Low)', key: 'forksDesc' }, + { label: 'Forks (Low → High)', key: 'forksAsc' }, + { label: 'Contributors (High → Low)', key: 'contributorsDesc' }, + { label: 'Contributors (Low → High)', key: 'contributorsAsc' }, + { label: 'Last checked (Newest)', key: 'createdAtDesc' }, + { label: 'Last checked (Oldest)', key: 'createdAtAsc' }, +] as const const parseOrderParam = (orderParam: string | null) => { - if (!orderParam) { - return { field: 'score', direction: Ordering.Desc, urlKey: '-score' } + if (!orderParam || !Object.hasOwn(ORDERING_MAP, orderParam)) { + return { field: 'score', direction: Ordering.Desc, urlKey: 'scoreDesc' } } - const isDescending = orderParam.startsWith('-') - const fieldKey = isDescending ? orderParam.slice(1) : orderParam - const isValidKey = fieldKey in FIELD_MAPPING - const normalizedKey = isValidKey ? fieldKey : 'score' - const graphqlField = FIELD_MAPPING[normalizedKey as OrderKey] - const direction = isDescending ? Ordering.Desc : Ordering.Asc - const normalizedUrlKey = direction === Ordering.Desc ? `-${normalizedKey}` : normalizedKey + const { field, direction } = ORDERING_MAP[orderParam as OrderingKey] - return { field: graphqlField, direction, urlKey: normalizedUrlKey } + return { field, direction, urlKey: orderParam } } const buildGraphQLOrdering = (field: string, direction: Ordering) => { @@ -57,71 +67,6 @@ const buildOrderingWithTieBreaker = (primaryOrdering: Record) project_Name: Ordering.Asc, }, ] -const SortableColumnHeader: FC<{ - label: string - fieldKey: OrderKey - currentOrderKey: string - onSort: (orderKey: string | null) => void - align?: 'left' | 'center' | 'right' -}> = ({ label, fieldKey, currentOrderKey, onSort, align = 'left' }) => { - const isActiveSortDesc = currentOrderKey === `-${fieldKey}` - const isActiveSortAsc = currentOrderKey === fieldKey - const isActive = isActiveSortDesc || isActiveSortAsc - - const handleClick = () => { - if (!isActive) { - onSort(`-${fieldKey}`) - } else if (isActiveSortDesc) { - onSort(fieldKey) - } else { - onSort(null) - } - } - - const justifyMap = { - left: 'justify-start', - center: 'justify-center', - right: 'justify-end', - } - - const alignmentClass = justifyMap[align] || justifyMap.left - - const textAlignMap = { - left: 'text-left', - center: 'text-center', - right: 'text-right', - } - - const textAlignClass = textAlignMap[align] || textAlignMap.left - - let iconType: IconType - if (isActiveSortDesc) { - iconType = FaSortDown - } else if (isActiveSortAsc) { - iconType = FaSortUp - } else { - iconType = FaSort - } - - return ( -
- -
- ) -} const MetricsPage: FC = () => { const searchParams = useSearchParams() @@ -267,9 +212,41 @@ const MetricsPage: FC = () => { return ( <> -
-

Project Health Metrics

-
+
+

Project Health Metrics

+
+
+ ({ + label: field.label, + key: field.key, + })), + }, + { + title: 'Reset', + items: [{ label: 'Reset Sorting', key: 'reset-sort' }], + }, + ]} + selectionMode="single" + selectedKeys={urlKey ? [urlKey] : []} + selectedLabels={ + urlKey ? [SORT_FIELDS.find((f) => f.key === urlKey)?.label || ''] : [] + } + onAction={(key: string) => { + if (key === 'reset-sort') { + handleSort(null) + return + } + + handleSort(key) + }} + /> +
{ />
-
-
Project Name
- - - - - -
{loading ? ( ) : ( diff --git a/frontend/src/components/MetricsCard.tsx b/frontend/src/components/MetricsCard.tsx index 96ff65259a..445d06484b 100644 --- a/frontend/src/components/MetricsCard.tsx +++ b/frontend/src/components/MetricsCard.tsx @@ -8,46 +8,69 @@ const MetricsCard: FC<{ metric: HealthMetricsProps }> = ({ metric }) => { href={`/projects/dashboard/metrics/${metric.projectKey}`} className="text-gray-800 no-underline dark:text-gray-200" > -
-
-

+

+
+

+ {metric.projectName === '' ? 'No name' : metric.projectName} +

+
+
= 75, + 'bg-orange-500': metric.score >= 50 && metric.score < 75, + 'bg-red-500': metric.score < 50, + } )} > - {metric.projectName === '' ? 'No name' : metric.projectName} -

+

Score: {metric.score}

+
-
-

{metric.starsCount}

-
-
-

{metric.forksCount}

-
-
-

{metric.contributorsCount}

-
-
-

- {new Date(metric.createdAt).toLocaleString('default', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-
-
= 75, - 'bg-orange-500 text-orange-900': metric.score >= 50 && metric.score < 75, - 'bg-red-500 text-red-900': metric.score < 50, - } - )} - > -

{metric.score}

+
+
+
+

+ Stars +

+

+ {metric.starsCount} +

+
+
+

+ Forks +

+

+ {metric.forksCount} +

+
+
+

+ Contributors +

+

+ {metric.contributorsCount} +

+
+
+

+ Health Checked +

+

+ {new Date(metric.createdAt).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
diff --git a/frontend/src/components/ProjectsDashboardDropDown.tsx b/frontend/src/components/ProjectsDashboardDropDown.tsx index c00b54109c..9a51089013 100644 --- a/frontend/src/components/ProjectsDashboardDropDown.tsx +++ b/frontend/src/components/ProjectsDashboardDropDown.tsx @@ -52,7 +52,9 @@ const ProjectsDashboardDropDown: FC<{
{buttonDisplayName} {selectedLabels && selectedLabels.length > 0 && ( - {selectedLabels.join(', ')} + + {selectedLabels.join(', ')} + )}