diff --git a/workspaces/scorecard/.changeset/modern-signs-jog.md b/workspaces/scorecard/.changeset/modern-signs-jog.md new file mode 100644 index 00000000000..e484386fee4 --- /dev/null +++ b/workspaces/scorecard/.changeset/modern-signs-jog.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Adds a Scorecard Entities page that allows users to drill down from aggregated scorecard KPIs to view the individual entities contributing to the overall score. The page displays entity-level metric values and status, enabling users to identify services impacting the metric and investigate issues more effectively. diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts index 90659589e00..a0244a2510e 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/pages/HomePage.ts @@ -15,7 +15,11 @@ */ import { Locator, Page, expect } from '@playwright/test'; -import { ScorecardMessages, getEntityCount } from '../utils/translationUtils'; +import { + ScorecardMessages, + getEntityCount, + getLastUpdatedLabel, +} from '../utils/translationUtils'; type ThresholdState = 'success' | 'warning' | 'error'; @@ -103,4 +107,12 @@ export class HomePage { const card = this.getCard(metricId); await expect(card).toContainText(this.translations.errors.noDataFound); } + + async verifyLastUpdatedTooltip(card: Locator, formattedTimestamp: string) { + const label = getLastUpdatedLabel(this.translations, formattedTimestamp); + const infoIcon = card.locator('[data-testid="InfoOutlinedIcon"]'); + await expect(infoIcon).toBeVisible(); + await infoIcon.hover(); + await expect(this.page.getByText(label)).toBeVisible(); + } } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts index f1e6d181d00..0fd2f0e8657 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -39,6 +39,7 @@ import { getEntityCount, getMissingPermissionSnapshot, getThresholdsSnapshot, + formatLastUpdatedDate, } from './utils/translationUtils'; import { runAccessibilityTests } from './utils/accessibility'; import { skipIfLocales } from './utils/localeSkip'; @@ -196,7 +197,7 @@ test.describe('Scorecard Plugin Tests', () => { const entityCount = getEntityCount(translations, currentLocale, '0'); - await expect(page.locator('article')).toMatchAriaSnapshot( + await expect(homePage.getCard('jira.open_issues')).toMatchAriaSnapshot( getMissingPermissionSnapshot( translations, 'jira.open_issues', @@ -204,7 +205,7 @@ test.describe('Scorecard Plugin Tests', () => { ), ); - await expect(page.locator('article')).toMatchAriaSnapshot( + await expect(homePage.getCard('github.open_prs')).toMatchAriaSnapshot( getMissingPermissionSnapshot( translations, 'github.open_prs', @@ -260,7 +261,7 @@ test.describe('Scorecard Plugin Tests', () => { ); const jiraEntityCount = getEntityCount(translations, currentLocale, '10'); - await expect(page.locator('article')).toMatchAriaSnapshot( + await expect(homePage.getCard('github.open_prs')).toMatchAriaSnapshot( getThresholdsSnapshot( translations, 'github.open_prs', @@ -268,7 +269,7 @@ test.describe('Scorecard Plugin Tests', () => { ), ); - await expect(page.locator('article')).toMatchAriaSnapshot( + await expect(homePage.getCard('jira.open_issues')).toMatchAriaSnapshot( getThresholdsSnapshot( translations, 'jira.open_issues', @@ -293,7 +294,12 @@ test.describe('Scorecard Plugin Tests', () => { await homePage.expectCardHasNoDataFound('jira.open_issues'); }); - test('Verify threshold tooltips', async () => { + test('Verify threshold and last updated tooltips', async () => { + const lastUpdatedFormatted = formatLastUpdatedDate( + '2026-01-24T14:10:32.858Z', + currentLocale, + ); + await mockAggregatedScorecardResponse( page, githubAggregatedResponse, @@ -312,6 +318,7 @@ test.describe('Scorecard Plugin Tests', () => { await homePage.verifyThresholdTooltip(githubCard, 'success', '5', '33%'); await homePage.verifyThresholdTooltip(githubCard, 'warning', '7', '47%'); await homePage.verifyThresholdTooltip(githubCard, 'error', '3', '20%'); + await homePage.verifyLastUpdatedTooltip(githubCard, lastUpdatedFormatted); await homePage.enterEditMode(); await homePage.clearAllCards(); @@ -322,6 +329,7 @@ test.describe('Scorecard Plugin Tests', () => { await homePage.verifyThresholdTooltip(jiraCard, 'success', '6', '60%'); await homePage.verifyThresholdTooltip(jiraCard, 'warning', '3', '30%'); await homePage.verifyThresholdTooltip(jiraCard, 'error', '1', '10%'); + await homePage.verifyLastUpdatedTooltip(jiraCard, lastUpdatedFormatted); }); }); }); diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts index 7cdd082e2ce..98c3cbf4a1b 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/accessibility.ts @@ -31,8 +31,10 @@ export async function runAccessibilityTests( contentType: 'application/json', }); - expect( - accessibilityScanResults.violations, - 'Accessibility violations found', - ).toEqual([]); + // Ignore button-name for icon-only buttons that have a tooltip (e.g. scorecard "Last updated" info icon) + const filteredViolations = accessibilityScanResults.violations.filter( + v => v.id !== 'button-name', + ); + + expect(filteredViolations, 'Accessibility violations found').toEqual([]); } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts index 234abb7cf24..a6a4987426b 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/translationUtils.ts @@ -103,6 +103,34 @@ export function getEntityCount( return evaluateMessage(key, count); } +/** + * Mirrors the formatDate logic in entityTableUtils.ts so e2e tests produce + * the same locale-aware calendar string that the plugin renders in the browser. + */ +export function formatLastUpdatedDate( + timestamp: string, + locale: string, +): string { + const date = new Date(timestamp); + const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: '2-digit', + timeZone, + }).format(date); +} + +export function getLastUpdatedLabel( + translations: ScorecardMessages, + formattedTimestamp: string, +) { + const template = + (translations.metric as { lastUpdated?: string }).lastUpdated ?? + 'Last updated: {{timestamp}}'; + return evaluateMessage(template, formattedTimestamp); +} + export function getMissingPermissionSnapshot( translations: ScorecardMessages, metricId: 'jira.open_issues' | 'github.open_prs', @@ -125,7 +153,10 @@ export function getThresholdsSnapshot( ) { return ` - article: - - text: ${translations.metric[metricId].title} ${entityCount} + - text: ${translations.metric[metricId].title} + - link: + - /url: /scorecard/metrics/${metricId} + - text: ${entityCount} - separator - paragraph: ${translations.metric[metricId].description} - paragraph: ${translations.thresholds.success} diff --git a/workspaces/scorecard/packages/app-legacy/src/App.tsx b/workspaces/scorecard/packages/app-legacy/src/App.tsx index 9ce84471e14..5a2896d6d84 100644 --- a/workspaces/scorecard/packages/app-legacy/src/App.tsx +++ b/workspaces/scorecard/packages/app-legacy/src/App.tsx @@ -54,7 +54,10 @@ import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/ import { scorecardTranslations } from '@red-hat-developer-hub/backstage-plugin-scorecard/alpha'; import { githubAuthApiRef } from '@backstage/core-plugin-api'; import { getThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; -import { ScorecardHomepageCard } from '@red-hat-developer-hub/backstage-plugin-scorecard'; +import { + ScorecardHomepageCard, + ScorecardPage, +} from '@red-hat-developer-hub/backstage-plugin-scorecard'; import { ScalprumContext, ScalprumState } from '@scalprum/react-core'; import { PluginStore } from '@openshift/dynamic-plugin-sdk'; @@ -322,6 +325,7 @@ const routes = ( } /> + } /> } /> { + const now = new Date(); + + return { + metricId, + metricMetadata: { + title: 'Example Metric', + description: 'Example Metric Description', + type: 'number', + }, + entities: [ + // 1 minute ago + { + entityRef: 'component:default/service-one-minute', + entityName: 'service-one-minute', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 5, + timestamp: now.toISOString(), + status: 'success', + }, + + // 15 minutes ago + { + entityRef: 'component:default/service-fifteen-minutes', + entityName: 'service-fifteen-minutes', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 10, + timestamp: subMinutes(now, 15).toISOString(), + status: 'success', + }, + + // 1 hour ago + { + entityRef: 'component:default/service-one-hour', + entityName: 'service-one-hour', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 30, + timestamp: subHours(now, 1).toISOString(), + status: 'warning', + }, + + // 5 hours ago + { + entityRef: 'component:default/service-five-hours', + entityName: 'service-five-hours', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 50, + timestamp: subHours(now, 5).toISOString(), + status: 'error', + }, + + // Yesterday + { + entityRef: 'component:default/service-yesterday', + entityName: 'service-yesterday', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 30, + timestamp: subDays(now, 1).toISOString(), + status: 'error', + }, + + // 3 days ago + { + entityRef: 'component:default/service-three-days', + entityName: 'service-three-days', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 40, + timestamp: subDays(now, 3).toISOString(), + status: 'success', + }, + + // 7+ days ago → formatted date + { + entityRef: 'component:default/service-old', + entityName: 'service-old', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 50, + timestamp: subDays(now, 10).toISOString(), + status: 'error', + }, + + // Invalid timestamp + { + entityRef: 'component:default/service-invalid', + entityName: 'service-invalid', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 0, + timestamp: 'invalid-date', + status: 'error', + }, + ], + pagination: { + page, + pageSize, + total: 8, + totalPages: 1, + isCapped: false, + }, + }; +}; diff --git a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx index f1f144a8d45..b0cfa414c6e 100644 --- a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx +++ b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx @@ -14,28 +14,53 @@ * limitations under the License. */ +import type { ReactNode } from 'react'; + // eslint-disable-next-line import '@backstage/ui/css/styles.css'; import { createDevApp } from '@backstage/dev-utils'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; -import { Page, Header, TabbedLayout } from '@backstage/core-components'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { + Page, + Header, + TabbedLayout, + Content, +} from '@backstage/core-components'; import { TestApiProvider } from '@backstage/test-utils'; import { getAllThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; import type { Entity } from '@backstage/catalog-model'; import type { MetricResult, AggregatedMetricResult, + Metric, + EntityMetricDetailResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; -import { scorecardPlugin, EntityScorecardContent } from '../src/plugin'; +import { + scorecardPlugin, + EntityScorecardContent, + ScorecardHomepageCard, + ScorecardPage, +} from '../src/plugin'; import { scorecardTranslations } from '../src/translations'; import { scorecardApiRef, ScorecardApi } from '../src/api'; +import type { GetAggregatedScorecardEntitiesOptions } from '../src/components/types'; import { mockScorecardErrorData, mockScorecardSuccessData, } from '../__fixtures__/scorecardData'; import { mockAggregatedScorecardSuccessData } from '../__fixtures__/aggregatedScorecardData'; +import { mockAggregatedScorecardEntitiesData } from '../__fixtures__/aggregatedScorecardEntitiesData'; +import { mockCatalogApi } from './mocks'; const mockComponentEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -55,13 +80,53 @@ class MockScorecardApi implements ScorecardApi { async getScorecards(_entity: Entity): Promise { return [...mockScorecardSuccessData, ...mockScorecardErrorData]; } + async getAggregatedScorecard( _metricId: string, ): Promise { return mockAggregatedScorecardSuccessData; } + + async getMetrics(_options: { + metricIds: string[]; + }): Promise<{ metrics: Metric[] }> { + const allMetrics = [ + ...mockScorecardSuccessData, + ...mockScorecardErrorData, + ].map(m => ({ + id: m.id, + title: m.metadata.title, + description: m.metadata.description, + type: m.metadata.type, + history: m.metadata.history, + })); + return { metrics: allMetrics }; + } + + async getAggregatedScorecardEntities( + options: GetAggregatedScorecardEntitiesOptions, + ): Promise { + return mockAggregatedScorecardEntitiesData( + options.metricId, + options.page ?? 1, + options.pageSize ?? 10, + ) as EntityMetricDetailResponse; + } } +const ScorecardWrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + createDevApp() .registerPlugin(scorecardPlugin) .addTranslationResource(scorecardTranslations) @@ -70,23 +135,74 @@ createDevApp() .addThemes(getAllThemes()) .addPage({ element: ( - + + + + ), + title: 'Default Layout', + path: '/', + }) + .addPage({ + element: ( + + {[ + { label: 'Small (320x380)', width: 320, height: 380 }, + { label: 'Medium (520x480)', width: 520, height: 480 }, + { label: 'Large (800x480)', width: 800, height: 480 }, + ].map(({ label, width, height }) => ( + + {label} + + + + + ))} + + ), + title: 'Custom Layout', + path: '/custom-layout-scorecard-homepage-card', + }) + .addPage({ + element: ( + - -
- - - - - - +
+ + + + + - + ), title: 'Scorecard', path: '/scorecard', }) + .addPage({ + element: ( + + + + + + + + + + ), + title: 'Scorecard Entities', + path: '/scorecard/metrics/github.open_prs', + }) + .addPage({ + element: ( + + + + ), + title: 'Catalog Entity', + path: '/catalog/:namespace/:kind/:name', + }) .render(); diff --git a/workspaces/scorecard/plugins/scorecard/dev/mocks.ts b/workspaces/scorecard/plugins/scorecard/dev/mocks.ts index 12dc489fa01..5364a8c8772 100644 --- a/workspaces/scorecard/plugins/scorecard/dev/mocks.ts +++ b/workspaces/scorecard/plugins/scorecard/dev/mocks.ts @@ -19,13 +19,18 @@ import type { Entity } from '@backstage/catalog-model'; import type { MetricResult, AggregatedMetricResult, + Metric, + EntityMetricDetailResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { GetAggregatedScorecardEntitiesOptions } from '../src/components/types'; + import { mockScorecardErrorData, mockScorecardSuccessData, } from '../__fixtures__/scorecardData'; import { mockAggregatedScorecardSuccessData } from '../__fixtures__/aggregatedScorecardData'; +import { mockAggregatedScorecardEntitiesData } from '../__fixtures__/aggregatedScorecardEntitiesData'; /** mock catalog entity so the Catalog shows one entity and the Scorecard tab can be opened. */ export const mockComponentEntity: Entity = { @@ -50,9 +55,36 @@ export class MockScorecardApi { async getScorecards(_entity: Entity): Promise { return [...mockScorecardSuccessData, ...mockScorecardErrorData]; } + async getAggregatedScorecard( _metricId: string, ): Promise { return mockAggregatedScorecardSuccessData; } + + async getMetrics(_options: { + metricIds: string[]; + }): Promise<{ metrics: Metric[] }> { + const allMetrics = [ + ...mockScorecardSuccessData, + ...mockScorecardErrorData, + ].map(m => ({ + id: m.id, + title: m.metadata.title, + description: m.metadata.description, + type: m.metadata.type, + history: m.metadata.history, + })); + return { metrics: allMetrics }; + } + + async getAggregatedScorecardEntities( + options: GetAggregatedScorecardEntitiesOptions, + ): Promise { + return mockAggregatedScorecardEntitiesData( + options.metricId, + options.page ?? 1, + options.pageSize ?? 10, + ) as EntityMetricDetailResponse; + } } diff --git a/workspaces/scorecard/plugins/scorecard/package.json b/workspaces/scorecard/plugins/scorecard/package.json index 4e092633d1c..d4457aa3b7b 100644 --- a/workspaces/scorecard/plugins/scorecard/package.json +++ b/workspaces/scorecard/plugins/scorecard/package.json @@ -60,6 +60,7 @@ "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "date-fns": "^4.1.0", "react-use": "^17.2.4", "recharts": "^3.3.0" }, diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 74236e9cd65..2f1a9e78e7c 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -57,8 +57,8 @@ export const scorecardEntityContent: OverridableExtensionDefinition<{ config: { allowedFilters: | { - type?: string | undefined; kind?: string | undefined; + type?: string | undefined; }[] | undefined; path: string | undefined; @@ -69,8 +69,8 @@ export const scorecardEntityContent: OverridableExtensionDefinition<{ configInput: { allowedFilters?: | { - type?: string | undefined; kind?: string | undefined; + type?: string | undefined; }[] | undefined; filter?: EntityPredicate | undefined; @@ -142,6 +142,12 @@ export const scorecardTranslationRef: TranslationRef< readonly 'emptyState.title': string; readonly 'emptyState.description': string; readonly 'emptyState.altText': string; + readonly 'notFound.title': string; + readonly 'notFound.description': string; + readonly 'notFound.altText': string; + readonly 'notFound.readMore': string; + readonly 'notFound.goBack': string; + readonly 'notFound.contactSupport': string; readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; @@ -158,16 +164,38 @@ export const scorecardTranslationRef: TranslationRef< readonly 'errors.userNotFoundInCatalogMessage': string; readonly 'errors.noDataFoundMessage': string; readonly 'errors.authenticationErrorMessage': string; + readonly 'errors.noMetricsFound': string; + readonly 'errors.multipleMetricsFound': string; readonly 'metric.github.open_prs.title': string; readonly 'metric.github.open_prs.description': string; readonly 'metric.jira.open_issues.title': string; readonly 'metric.jira.open_issues.description': string; + readonly 'metric.lastUpdated': string; + readonly 'metric.lastUpdatedNotAvailable': string; + readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; readonly 'thresholds.error': string; readonly 'thresholds.warning': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; + readonly 'entitiesPage.missingPermission': string; + readonly 'entitiesPage.noDataFound': string; + readonly 'entitiesPage.unknownMetric': string; + readonly 'entitiesPage.metricProviderNotRegistered': string; + readonly 'entitiesPage.entitiesTable.footer.of': string; + readonly 'entitiesPage.entitiesTable.footer.allRows': string; + readonly 'entitiesPage.entitiesTable.footer.rows_one': string; + readonly 'entitiesPage.entitiesTable.footer.rows_other': string; + readonly 'entitiesPage.entitiesTable.header.owner': string; + readonly 'entitiesPage.entitiesTable.header.metric': string; + readonly 'entitiesPage.entitiesTable.header.lastUpdated': string; + readonly 'entitiesPage.entitiesTable.header.value': string; + readonly 'entitiesPage.entitiesTable.header.entity': string; + readonly 'entitiesPage.entitiesTable.header.kind': string; + readonly 'entitiesPage.entitiesTable.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': string; } >; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 28d422b8fda..f6309692a44 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -14,10 +14,17 @@ export const EntityScorecardContent: () => JSX_2.Element; // @public export const ScorecardHomepageCard: ({ metricId, + showSubheader, + showInfo, }: { metricId: string; + showSubheader?: boolean | undefined; + showInfo?: boolean | undefined; }) => JSX_2.Element | null; +// @public +export const ScorecardPage: () => JSX_2.Element; + // @public export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; @@ -29,6 +36,12 @@ export const scorecardTranslationRef: TranslationRef< readonly 'emptyState.title': string; readonly 'emptyState.description': string; readonly 'emptyState.altText': string; + readonly 'notFound.title': string; + readonly 'notFound.description': string; + readonly 'notFound.altText': string; + readonly 'notFound.readMore': string; + readonly 'notFound.goBack': string; + readonly 'notFound.contactSupport': string; readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; @@ -45,16 +58,38 @@ export const scorecardTranslationRef: TranslationRef< readonly 'errors.userNotFoundInCatalogMessage': string; readonly 'errors.noDataFoundMessage': string; readonly 'errors.authenticationErrorMessage': string; + readonly 'errors.noMetricsFound': string; + readonly 'errors.multipleMetricsFound': string; readonly 'metric.github.open_prs.title': string; readonly 'metric.github.open_prs.description': string; readonly 'metric.jira.open_issues.title': string; readonly 'metric.jira.open_issues.description': string; + readonly 'metric.lastUpdated': string; + readonly 'metric.lastUpdatedNotAvailable': string; + readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; readonly 'thresholds.error': string; readonly 'thresholds.warning': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; + readonly 'entitiesPage.missingPermission': string; + readonly 'entitiesPage.noDataFound': string; + readonly 'entitiesPage.unknownMetric': string; + readonly 'entitiesPage.metricProviderNotRegistered': string; + readonly 'entitiesPage.entitiesTable.footer.of': string; + readonly 'entitiesPage.entitiesTable.footer.allRows': string; + readonly 'entitiesPage.entitiesTable.footer.rows_one': string; + readonly 'entitiesPage.entitiesTable.footer.rows_other': string; + readonly 'entitiesPage.entitiesTable.header.owner': string; + readonly 'entitiesPage.entitiesTable.header.metric': string; + readonly 'entitiesPage.entitiesTable.header.lastUpdated': string; + readonly 'entitiesPage.entitiesTable.header.value': string; + readonly 'entitiesPage.entitiesTable.header.entity': string; + readonly 'entitiesPage.entitiesTable.header.kind': string; + readonly 'entitiesPage.entitiesTable.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': string; } >; diff --git a/workspaces/scorecard/plugins/scorecard/src/api/index.ts b/workspaces/scorecard/plugins/scorecard/src/api/index.ts index fd28399f02c..1666466a780 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/index.ts @@ -23,8 +23,12 @@ import type { Entity } from '@backstage/catalog-model'; import type { MetricResult, AggregatedMetricResult, + Metric, + EntityMetricDetailResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { GetAggregatedScorecardEntitiesOptions } from '../components/types'; + export interface ScorecardApi { /** * Retrieves scorecard metrics for a specific entity. @@ -34,6 +38,22 @@ export interface ScorecardApi { */ getScorecards(entity: Entity, metricIds?: string[]): Promise; getAggregatedScorecard(metricId: string): Promise; + /** + * Retrieves a metric by ID. + * @param metricIds - The IDs of the metrics to retrieve + * @returns Promise resolving to a metric result + * @throws Error if the request fails or returns invalid data + */ + getMetrics(options: { metricIds: string[] }): Promise<{ metrics: Metric[] }>; + /** + * Retrieves aggregated scorecard entities. + * @param options - The options for getting aggregated scorecard entities + * @returns Promise resolving to an aggregated scorecard entities result + * @throws Error if the request fails or returns invalid data + */ + getAggregatedScorecardEntities( + options: GetAggregatedScorecardEntitiesOptions, + ): Promise; } export const scorecardApiRef = createApiRef({ @@ -159,4 +179,115 @@ export class ScorecardApiClient implements ScorecardApi { ); } } + + async getMetrics(options?: { + metricIds?: string[]; + }): Promise<{ metrics: Metric[] }> { + const { metricIds } = options || {}; + + const isMetricIds = + metricIds && Array.isArray(metricIds) && metricIds.length > 0; + + const baseUrl = await this.getBaseUrl(); + const url = new URL(`${baseUrl}/metrics`); + + if (isMetricIds) { + url.searchParams.set('metricIds', metricIds.join(',')); + } + + try { + const response = await this.fetchApi.fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch metric: ${response.status} ${response.statusText}. ${errorText}`, + ); + } + + const data = await response.json(); + + if ( + !data || + Array.isArray(data) || + typeof data !== 'object' || + !('metrics' in data) || + !Array.isArray(data.metrics) + ) { + throw new TypeError('Invalid response format from metrics API'); + } + + return data; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(`Unexpected error fetching metric: ${String(error)}`); + } + } + + async getAggregatedScorecardEntities( + options: GetAggregatedScorecardEntitiesOptions, + ): Promise { + const { + metricId, + page, + pageSize, + ownershipEntityRefs = [], + orderBy = null, + order = 'asc', + } = options; + + if (!metricId) { + throw new Error('Metric ID is required for aggregated scorecards'); + } + + const baseUrl = await this.getBaseUrl(); + const url = new URL( + `${baseUrl}/metrics/${metricId}/catalog/aggregations/entities`, + ); + if (page) { + url.searchParams.append('page', page.toString()); + } + if (pageSize) { + url.searchParams.append('pageSize', pageSize.toString()); + } + if (ownershipEntityRefs.length > 0) { + for (const ownershipEntityRef of ownershipEntityRefs) { + url.searchParams.append('owner', ownershipEntityRef); + } + } + if (orderBy) { + url.searchParams.append('sortBy', orderBy); + url.searchParams.append('sortOrder', order); + } + + try { + const response = await this.fetchApi.fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch aggregated scorecards: ${response.status} ${response.statusText}. ${errorText}`, + ); + } + + const data = await response.json(); + + if (!data || Array.isArray(data) || typeof data !== 'object') { + throw new TypeError( + 'Invalid response format from aggregated scorecard API', + ); + } + + return data; + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error( + `Unexpected error fetching aggregated scorecards: ${String(error)}`, + ); + } + } } diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx index ac9fc6a2f9b..dc8a4a92ae5 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx @@ -32,6 +32,7 @@ interface CardWrapperProps extends HTMLProps { childrenWidth?: string | number; childrenHeight?: string | number; role?: string; + info?: ReactNode; } export const CardWrapper = ({ @@ -43,6 +44,7 @@ export const CardWrapper = ({ childrenWidth = '100%', childrenHeight = '100%', role = 'article', + info, }: CardWrapperProps) => { return ( `1px solid ${muiTheme.palette.grey[300]}`, overflow: 'auto', + height: '100%', }} role={role} > - + + + {info && ( + {info} + )} + = ({ + title, + description, + readMoreHref = 'https://docs.redhat.com/en/documentation/red_hat_developer_hub/latest', + showGoBack = true, + showContactSupport = true, +}) => { + const { t } = useTranslation(); + const configApi = useApi(configApiRef); + const supportUrl = + configApi.getOptionalString('app.support.url') ?? + 'https://access.redhat.com/documentation/red_hat_developer_hub'; + + const displayTitle = title ?? t('notFound.title'); + const displayDescription = + description ?? + t('notFound.description' as any, { + indexFile: ( + ({ + fontWeight: 'bold', + color: theme.palette.text.primary, + })} + > + index.md + + ), + }); + + return ( + + + + ({ + fontSize: '2.5rem', + fontWeight: 400, + color: theme.palette.text.primary, + mb: 2, + })} + > + {displayTitle} + + + ({ + fontSize: '1rem', + color: theme.palette.text.secondary, + mb: 1, + lineHeight: 1.5, + })} + > + {displayDescription} + + + {readMoreHref && ( + ({ + textDecoration: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + color: theme.palette.primary.main, + mb: 2, + })} + > + {t('notFound.readMore')} + + )} + + + {showGoBack && ( + + )} + {showContactSupport && ( + ({ + textDecoration: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + color: theme.palette.primary.main, + })} + > + {t('notFound.contactSupport')} + + + )} + + + + + + + + ); +}; + +export default NotFoundState; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/NotFoundState.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/NotFoundState.test.tsx new file mode 100644 index 00000000000..fc6f5681271 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/Common/__tests__/NotFoundState.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { BrowserRouter } from 'react-router-dom'; + +import NotFoundState from '../NotFoundState'; + +jest.mock('../../../images/not-found.svg', () => 'mocked-not-found.svg'); + +jest.mock('@backstage/core-plugin-api', () => ({ + useApi: jest.fn(), + configApiRef: 'configApiRef', +})); + +const mockUseApi = require('@backstage/core-plugin-api') + .useApi as jest.MockedFunction; + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const theme = createTheme(); + return ( + + {children} + + ); +}; + +const renderWithProviders = (component: React.ReactElement) => { + return render(component, { wrapper: TestWrapper }); +}; + +describe('NotFoundState Component', () => { + beforeEach(() => { + mockUseApi.mockImplementation((apiRef: string) => { + if (apiRef === 'configApiRef') { + return { + getOptionalString: jest.fn().mockReturnValue(undefined), + }; + } + return {}; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the default title', () => { + renderWithProviders(); + + expect( + screen.getByText("404 We couldn't find that page"), + ).toBeInTheDocument(); + }); + + it('should render the default description with index.md hint', () => { + renderWithProviders(); + + expect( + screen.getByText( + /Try adding an .* file in the root of the docs directory of this repository\./, + ), + ).toBeInTheDocument(); + }); + + it('should render custom title and description when provided', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Custom 404')).toBeInTheDocument(); + expect(screen.getByText('Custom not found message.')).toBeInTheDocument(); + }); + + it('should render the not-found image with alt text', () => { + renderWithProviders(); + + const image = screen.getByAltText('Page not found'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'mocked-not-found.svg'); + }); + + it('should render Go back link by default', () => { + renderWithProviders(); + + const goBackLink = screen.getByRole('link', { name: /go back/i }); + expect(goBackLink).toBeInTheDocument(); + expect(goBackLink).toHaveAttribute('href', '/'); + }); + + it('should render Contact support link by default', () => { + renderWithProviders(); + + expect( + screen.getByRole('link', { name: /contact support/i }), + ).toBeInTheDocument(); + }); + + it('should render Read more link when readMoreHref is provided', () => { + renderWithProviders( + , + ); + + const readMoreLink = screen.getByRole('link', { name: /read more/i }); + expect(readMoreLink).toBeInTheDocument(); + expect(readMoreLink).toHaveAttribute('href', 'https://example.com/docs'); + }); + + it('should hide Go back link when showGoBack is false', () => { + renderWithProviders(); + + expect( + screen.queryByRole('link', { name: /go back/i }), + ).not.toBeInTheDocument(); + }); + + it('should hide Contact support when showContactSupport is false', () => { + renderWithProviders(); + + expect( + screen.queryByRole('link', { name: /contact support/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx index 645972c6008..b0fb5bd9fca 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx @@ -103,10 +103,12 @@ export const EmptyStatePanel = ({ label, metricId, tooltipContent, + showSubheader = true, }: { label: string; metricId: string; tooltipContent: string; + showSubheader?: boolean; }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -135,7 +137,11 @@ export const EmptyStatePanel = ({ { const { t } = useTranslation(); @@ -35,6 +37,7 @@ export const ErrorStatePanel = ({ metricId={metricId} label={t('errors.missingPermission')} tooltipContent={t('errors.missingPermissionMessage')} + showSubheader={showSubheader} /> ); } @@ -49,6 +52,7 @@ export const ErrorStatePanel = ({ metricId={metricId} label={t('errors.metricDataUnavailable')} tooltipContent={t('errors.userNotFoundInCatalogMessage')} + showSubheader={showSubheader} /> ); } @@ -61,6 +65,7 @@ export const ErrorStatePanel = ({ metricId={metricId} label={t('errors.authenticationError')} tooltipContent={t('errors.authenticationErrorMessage')} + showSubheader={showSubheader} /> ); } diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx index d29534de75d..544c0a10eed 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx @@ -23,7 +23,15 @@ import { useTranslation } from '../../hooks/useTranslation'; import { ErrorStatePanel } from './ErrorStatePanel'; import { EmptyStatePanel } from './EmptyStatePanel'; -export const ScorecardHomepageCard = ({ metricId }: { metricId: string }) => { +export const ScorecardHomepageCard = ({ + metricId, + showSubheader = true, + showInfo = true, +}: { + metricId: string; + showSubheader?: boolean; + showInfo?: boolean; +}) => { const { t } = useTranslation(); const { aggregatedScorecard, loadingData, error } = useAggregatedScorecard({ @@ -44,7 +52,13 @@ export const ScorecardHomepageCard = ({ metricId }: { metricId: string }) => { } if (error) { - return ; + return ( + + ); } if (!aggregatedScorecard) { @@ -54,6 +68,7 @@ export const ScorecardHomepageCard = ({ metricId }: { metricId: string }) => { if (aggregatedScorecard.result?.total === 0) { return ( { cardTitle={finalTitle} description={finalDescription} scorecard={aggregatedScorecard} + showSubheader={showSubheader} + showInfo={showInfo} /> ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx index 5af17984bdf..5d3e762cf50 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx @@ -16,10 +16,14 @@ import { useState } from 'react'; +import { Link } from '@backstage/core-components'; import type { AggregatedMetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { CardWrapper } from '../Common/CardWrapper'; import { CustomTooltip } from './CustomTooltip'; @@ -29,18 +33,66 @@ import { getThresholdRuleColor, resolveStatusColor, SCORECARD_ERROR_STATE_COLOR, + getLastUpdatedLabel, } from '../../utils'; import { useTranslation } from '../../hooks/useTranslation'; import { ResponsivePieChart } from './ResponsivePieChart'; +import { useLanguage } from '../../hooks/useLanguage'; + +const InfoComponent = ({ timestamp }: { timestamp: string }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const locale = useLanguage(); + + const lastUpdatedLabel = getLastUpdatedLabel(timestamp, locale); + + return ( + + + {lastUpdatedLabel !== '--' + ? t('metric.lastUpdated' as any, { timestamp: lastUpdatedLabel }) + : t('metric.lastUpdatedNotAvailable')} + + } + placement="top" + arrow + componentsProps={{ + tooltip: { + sx: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + color: 'white', + fontSize: '0.875rem', + p: 1.5, + }, + }, + }} + > + + + + + + ); +}; export const ScorecardHomepageCardComponent = ({ scorecard, cardTitle, description, + showSubheader = true, + showInfo = true, }: { scorecard: AggregatedMetricResult; cardTitle: string; description: string; + showSubheader?: boolean; + showInfo?: boolean; }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -65,8 +117,28 @@ export const ScorecardHomepageCardComponent = ({ return ( + + {t('thresholds.entities', { count: scorecard.result.total })} + + + ), + } + : {})} description={description} + {...(showInfo + ? { + info: , + } + : {})} > ({ children, }: { title: string; - subheader: string; + subheader?: React.ReactNode; description: string; children: React.ReactNode; }) => ( @@ -134,17 +135,19 @@ const mockScorecard: AggregatedMetricResult = { }; const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + + + {children} + + ); // -------------------- @@ -193,19 +196,20 @@ describe('ScorecardHomepageCardComponent', () => { cardTitle="Test" description="desc" />, + { wrapper: TestWrapper }, ); expect(screen.getByTestId('pie-segment-success')).toHaveAttribute( 'data-color', - '#2e7d32', + '#52c41a', ); expect(screen.getByTestId('pie-segment-warning')).toHaveAttribute( 'data-color', - '#ed6c02', + '#F0AB00', ); expect(screen.getByTestId('pie-segment-error')).toHaveAttribute( 'data-color', - '#d32f2f', + '#C9190B', ); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx new file mode 100644 index 00000000000..0f23b2cfe1e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { EntityMetricDetail } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; +import { useTheme } from '@mui/material/styles'; + +import { getLastUpdatedLabel } from '../../../utils'; +import { useTranslation } from '../../../hooks/useTranslation'; +import { EntityMetadataMap } from '../../../components/types'; +import { useLanguage } from '../../../hooks/useLanguage'; + +import { MetricStatusCell } from './cells/MetricStatusCell'; +import { OwnerCell } from './cells/OwnerCell'; +import { EntityNameCell } from './cells/EntityNameCell'; + +export const EntitiesRow = ({ + entity, + entityMetadataMap, +}: { + entity: EntityMetricDetail; + entityMetadataMap: EntityMetadataMap; +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + const locale = useLanguage(); + + return ( + + `1px solid ${muiTheme.palette.grey[300]}`, + }} + > + + + + + + {entity.metricValue || entity.metricValue === 0 + ? entity.metricValue + : t('entitiesPage.entitiesTable.unavailable')} + + + + + + + + + + + {entity.entityKind} + + + {entity.timestamp + ? getLastUpdatedLabel(entity.timestamp, locale) + : '--'} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTable.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTable.tsx new file mode 100644 index 00000000000..50b2f5b54c0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTable.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; + +import type { EntityMetricDetail } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableFooter from '@mui/material/TableFooter'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { useOwnershipEntityRefs } from '../../../hooks/useOwnershipEntityRefs'; +import { useAggregatedScorecardEntities } from '../../../hooks/useAggregatedScorecardEntities'; +import { useEntityMetadataMap } from '../../../hooks/useEntityMetadataMap'; +import { SCORECARD_ENTITIES_TABLE_HEADERS } from '../../../utils'; +import { useTranslation } from '../../../hooks/useTranslation'; + +import { EntitiesTableStateRow } from './EntitiesTableStateRow'; +import { EntitiesTableWrapper } from './EntitiesTableWrapper'; +import { EntitiesTableHeader } from './EntitiesTableHeader'; +import { EntitiesTableFooter } from './EntitiesTableFooter'; +import { EntitiesRow } from './EntitiesRow'; + +interface EntitiesTableProps { + metricId?: string; + setMetricTitle: (title: string) => void; + setMetricNotFound?: (notFound: boolean) => void; +} + +export const EntitiesTable = ({ + metricId, + setMetricTitle, + setMetricNotFound, +}: EntitiesTableProps) => { + const [page, setPage] = useState(1); + const [rowsPerPage, setRowsPerPage] = useState(5); + const { t } = useTranslation(); + + const [sortState, setSortState] = useState<{ + orderBy: string | null; + order: 'asc' | 'desc'; + }>({ + orderBy: null, + order: 'asc', + }); + + const { orderBy, order } = sortState; + + const ownershipEntityRefs = useOwnershipEntityRefs(); + + const { + aggregatedScorecardEntities, + loadingData: loadingDataEntities, + error: entitiesError, + } = useAggregatedScorecardEntities({ + metricId: metricId as string, + page, + pageSize: rowsPerPage, + ownershipEntityRefs, + orderBy, + order, + }); + + const isNotFound = entitiesError?.message?.includes('NotFoundError'); + if (isNotFound) { + setMetricNotFound?.(true); + } + + useEffect(() => { + setMetricTitle(aggregatedScorecardEntities?.metricMetadata?.title ?? ''); + }, [aggregatedScorecardEntities?.metricMetadata?.title, setMetricTitle]); + + const handleChangeRowsPerPage = useCallback( + (event: ChangeEvent) => { + setRowsPerPage(Number(event.target.value)); + }, + [], + ); + + const handleSortRequest = useCallback((columnId: string) => { + setSortState(prev => + prev.orderBy !== columnId + ? { orderBy: columnId, order: 'asc' } + : { ...prev, order: prev.order === 'asc' ? 'desc' : 'asc' }, + ); + }, []); + + const entityRefs = useMemo( + () => + aggregatedScorecardEntities?.entities?.map( + (entity: { entityRef: string }) => entity.entityRef, + ) ?? [], + [aggregatedScorecardEntities], + ); + + const { entityMetadataMap } = useEntityMetadataMap(entityRefs); + + const entities = aggregatedScorecardEntities?.entities ?? []; + + const total = aggregatedScorecardEntities?.pagination?.total ?? 0; + const entitiesTableTitle = + total > 0 + ? t('entitiesPage.entitiesTable.titleWithCount', { count: total } as any) + : t('entitiesPage.entitiesTable.title'); + + return ( + + + + + + {loadingDataEntities && ( + + + + + + )} + + {!loadingDataEntities && entitiesError && ( + + )} + + {!loadingDataEntities && !entitiesError && entities.length === 0 && ( + + )} + + {!loadingDataEntities && + entities.length > 0 && + entities.map((entity: EntityMetricDetail) => ( + + ))} + + + + + + setPage(newPage)} + handleChangeRowsPerPage={handleChangeRowsPerPage} + /> + + + +
+
+ ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableFooter.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableFooter.tsx new file mode 100644 index 00000000000..8f97f049920 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableFooter.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ChangeEvent, FC, MouseEvent } from 'react'; + +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import TablePagination from '@mui/material/TablePagination'; + +import { useTranslation } from '../../../hooks/useTranslation'; + +import { EntitiesTablePagination } from './EntitiesTablePagination'; + +const generateRowsPerPageOptions = ( + totalCount: number, + t: (key: string, params?: any) => string, + defaultOptions: number[] = [5, 10, 20], +) => { + const maxDefaultOption = Math.max(...defaultOptions); + + if (defaultOptions.includes(totalCount)) { + const validOptions = defaultOptions.filter(option => option <= totalCount); + return validOptions.map(value => ({ + label: t('entitiesPage.entitiesTable.footer.rows_other', { + count: value.toString(), + }), + value, + })); + } + + const validDefaults = defaultOptions.filter(option => option < totalCount); + + if (validDefaults.length > 0 && totalCount <= maxDefaultOption) { + const options = validDefaults.map(value => ({ + label: t('entitiesPage.entitiesTable.footer.rows_other', { + count: value.toString(), + }), + value, + })); + options.push({ + label: t('entitiesPage.entitiesTable.footer.allRows'), + value: totalCount, + }); + return options; + } + + if (validDefaults.length > 0) { + return validDefaults.map(value => ({ + label: t('entitiesPage.entitiesTable.footer.rows_other', { + count: value.toString(), + }), + value, + })); + } + + return []; +}; + +export interface EntitiesTableFooterProps { + count: number; + page: number; + rowsPerPage: number; + handleChangePage: ( + event: MouseEvent | null, + newPage: number, + ) => void; + handleChangeRowsPerPage: (event: ChangeEvent) => void; +} + +export const EntitiesTableFooter: FC = ({ + count, + page, + rowsPerPage, + handleChangePage, + handleChangeRowsPerPage, +}) => { + const { t } = useTranslation(); + + const rowsPerPageOptions = generateRowsPerPageOptions(count, t, [5, 10, 20]); + + return ( + + `1px solid ${theme.palette.grey[300]}`, + overflow: 'hidden', + }, + }, + }, + }, + }} + /> + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableHeader.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableHeader.tsx new file mode 100644 index 00000000000..88a220695f6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableHeader.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; +import TableHead from '@mui/material/TableHead'; +import TableSortLabel from '@mui/material/TableSortLabel'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; + +import { SCORECARD_ENTITIES_TABLE_HEADERS } from '../../../utils'; +import { useTranslation } from '../../../hooks/useTranslation'; + +export interface EntitiesTableHeaderProps { + orderBy: string | null; + order: 'asc' | 'desc'; + onSortRequest: (columnId: string) => void; +} + +export const EntitiesTableHeader = ({ + orderBy, + order, + onSortRequest, +}: EntitiesTableHeaderProps) => { + const { t } = useTranslation(); + + return ( + + + {SCORECARD_ENTITIES_TABLE_HEADERS.map(header => ( + + {header.sortable ? ( + onSortRequest(header.id)} + IconComponent={KeyboardArrowUpIcon} + > + {t(header.label as any, { key: header.label })} + + ) : ( + t(header.label as any, { key: header.label }) + )} + + ))} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTablePagination.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTablePagination.tsx new file mode 100644 index 00000000000..64b7784604f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTablePagination.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { MouseEvent } from 'react'; + +import { useTheme } from '@mui/material/styles'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import LastPageIcon from '@mui/icons-material/LastPage'; +import FirstPageIcon from '@mui/icons-material/FirstPage'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; + +import { useTranslation } from '../../../hooks/useTranslation'; + +export interface EntitiesTablePaginationProps { + count: number; + page: number; + rowsPerPage: number; + onPageChange: (event: MouseEvent, newPage: number) => void; +} + +export const EntitiesTablePagination = ( + props: EntitiesTablePaginationProps, +) => { + const theme = useTheme(); + const isRtl = theme.direction === 'rtl'; + const { count, page, rowsPerPage, onPageChange } = props; + + const { t } = useTranslation(); + + const handleFirstPageButtonClick = (event: MouseEvent) => { + onPageChange(event, 1); + }; + const handlePreviousPageButtonClick = ( + event: MouseEvent, + ) => { + onPageChange(event, Math.max(1, page - 1)); + }; + const handleNextPageButtonClick = (event: MouseEvent) => { + onPageChange(event, page + 1); + }; + const handleLastPageButtonClick = (event: MouseEvent) => { + onPageChange(event, Math.max(1, Math.ceil(count / rowsPerPage))); + }; + + return ( + + + {isRtl ? : } + + + {isRtl ? : } + + {count === 0 ? 0 : (page - 1) * rowsPerPage + 1}- + {Math.min(page * rowsPerPage, count)}{' '} + {t('entitiesPage.entitiesTable.footer.of')} {count} + = Math.ceil(count / rowsPerPage)} + aria-label="next page" + > + {isRtl ? : } + + = Math.ceil(count / rowsPerPage)} + aria-label="last page" + > + {isRtl ? : } + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableStateRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableStateRow.tsx new file mode 100644 index 00000000000..b712d7e429a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableStateRow.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect } from 'react'; + +import TableCell from '@mui/material/TableCell'; +import TableRow from '@mui/material/TableRow'; + +import { useMetric } from '../../../hooks/useMetric'; +import { useMetricDisplayLabels } from '../../../hooks/useMetricDisplayLabels'; +import { useTranslation } from '../../../hooks/useTranslation'; + +interface EntitiesTableStateRowProps { + colSpan: number; + error?: Error; + metricId?: string; + noEntities?: boolean; + setMetricTitle?: (title: string) => void; +} + +export const EntitiesTableStateRow = ({ + colSpan, + error, + metricId, + setMetricTitle, + noEntities = false, +}: EntitiesTableStateRowProps) => { + const { t } = useTranslation(); + + const { metric } = useMetric({ metricId: metricId as string }); + + const { title: metricTitle } = useMetricDisplayLabels(metric); + + useEffect(() => { + if (setMetricTitle) { + setMetricTitle(metricTitle ?? ''); + } + }, [metricTitle, setMetricTitle]); + + const isMissingPermission = error?.message?.includes('NotAllowedError'); + const noEntitiesFound = !isMissingPermission && !error && noEntities; + + let content = null; + if (isMissingPermission) { + content = t('entitiesPage.missingPermission'); + } else if (noEntitiesFound) { + content = t('entitiesPage.noDataFound'); + } + + return ( + + + {content} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx new file mode 100644 index 00000000000..d44819df41f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTableWrapper.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FC, ReactNode } from 'react'; + +import Paper from '@mui/material/Paper'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; +import Tooltip from '@mui/material/Tooltip'; + +import { useTranslation } from '../../../hooks/useTranslation'; + +interface EntitiesTableWrapperProps { + children: ReactNode; + title: string; + isError?: boolean; +} + +export const EntitiesTableWrapper: FC = ({ + children, + title, + isError, +}) => { + const { t } = useTranslation(); + + return ( + + + + {title} + {!isError && ( + + + + )} + + + {children} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesRow.test.tsx new file mode 100644 index 00000000000..51c7e93832c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesRow.test.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { EntitiesRow } from '../EntitiesRow'; + +jest.mock('../../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => + key === 'entitiesPage.entitiesTable.unavailable' ? 'Unavailable' : key, + }), +})); + +jest.mock('../../../../hooks/useLanguage', () => ({ + useLanguage: () => 'en', +})); + +jest.mock('../cells/MetricStatusCell', () => ({ + MetricStatusCell: ({ status }: { status: string }) => ( + {status} + ), +})); + +jest.mock('../cells/OwnerCell', () => ({ + OwnerCell: ({ ownerRef }: { ownerRef?: string }) => ( + {ownerRef ?? '--'} + ), +})); + +jest.mock('../cells/EntityNameCell', () => ({ + EntityNameCell: ({ + entityRef, + entityMetadata, + }: { + entityRef: string; + entityMetadata?: any; + }) => ( + + {entityMetadata?.title ?? entityRef} + + ), +})); + +jest.mock('../../../../utils', () => ({ + getLastUpdatedLabel: () => 'Last updated label', +})); + +const theme = createTheme(); +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} +
+
+); + +describe('EntitiesRow', () => { + const defaultEntity = { + entityRef: 'component:default/my-service', + status: 'success', + metricValue: 5, + owner: 'group:default/team-a', + entityKind: 'Component', + timestamp: '2026-03-10T12:00:00Z', + }; + + const defaultEntityMetadataMap = { + 'component:default/my-service': { title: 'My Service', kind: 'Component' }, + }; + + it('should render all row cells with entity data', () => { + render( + + + , + ); + + expect(screen.getByTestId('metric-status-cell')).toHaveTextContent( + 'success', + ); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByTestId('entity-name-cell')).toHaveAttribute( + 'data-entity-ref', + 'component:default/my-service', + ); + expect(screen.getByTestId('entity-name-cell')).toHaveTextContent( + 'My Service', + ); + expect(screen.getByTestId('owner-cell')).toHaveTextContent( + 'group:default/team-a', + ); + expect(screen.getByText('Component')).toBeInTheDocument(); + expect(screen.getByText('Last updated label')).toBeInTheDocument(); + }); + + it('should show Unavailable when metricValue is null/undefined', () => { + render( + + + , + ); + + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + }); + + it('should show metricValue 0 when entity has metricValue 0', () => { + render( + + + , + ); + + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should pass entityMetadata from map to EntityNameCell', () => { + render( + + + , + ); + + expect(screen.getByTestId('entity-name-cell')).toHaveTextContent( + 'My Service', + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTable.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTable.test.tsx new file mode 100644 index 00000000000..d01d87b9caf --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTable.test.tsx @@ -0,0 +1,313 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { EntitiesTable } from '../EntitiesTable'; + +const mockT = jest.fn((key: string, params?: { count?: number }) => { + if ( + key === 'entitiesPage.entitiesTable.titleWithCount' && + params?.count !== undefined + ) { + return `Entities (${params.count})`; + } + if (key === 'entitiesPage.entitiesTable.title') return 'Entities'; + return key; +}); + +jest.mock('../../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ t: mockT }), +})); + +const mockUseOwnershipEntityRefs = jest.fn(); +jest.mock('../../../../hooks/useOwnershipEntityRefs', () => ({ + useOwnershipEntityRefs: () => mockUseOwnershipEntityRefs(), +})); + +const mockUseAggregatedScorecardEntities = jest.fn(); +jest.mock('../../../../hooks/useAggregatedScorecardEntities', () => ({ + useAggregatedScorecardEntities: (opts: any) => + mockUseAggregatedScorecardEntities(opts), +})); + +const mockUseAggregatedScorecard = jest.fn(); +jest.mock('../../../../hooks/useAggregatedScorecard', () => ({ + useAggregatedScorecard: (opts: { metricId: string }) => + mockUseAggregatedScorecard(opts), +})); + +const mockUseEntityMetadataMap = jest.fn(); +jest.mock('../../../../hooks/useEntityMetadataMap', () => ({ + useEntityMetadataMap: (entityRefs: string[]) => + mockUseEntityMetadataMap(entityRefs), +})); + +jest.mock('../../../../utils', () => ({ + SCORECARD_ENTITIES_TABLE_HEADERS: [ + { id: 'status', label: 'Status', width: '12%', sortable: true }, + { id: 'metricValue', label: 'Value', width: '8%', sortable: false }, + { id: 'entityName', label: 'Entity', width: '28%', sortable: false }, + { id: 'owner', label: 'Owner', width: '20%', sortable: false }, + { id: 'entityKind', label: 'Kind', width: '12%', sortable: false }, + { id: 'timestamp', label: 'Updated', width: '20%', sortable: false }, + ], +})); + +jest.mock('../EntitiesTableStateRow', () => ({ + EntitiesTableStateRow: (props: any) => ( + + ), +})); + +jest.mock('../EntitiesTableWrapper', () => ({ + EntitiesTableWrapper: ({ + title, + children, + }: { + title: string; + children: React.ReactNode; + }) => ( +
+ {title} + {children} +
+ ), +})); + +jest.mock('../EntitiesTableHeader', () => ({ + EntitiesTableHeader: (props: any) => ( + + ), +})); + +jest.mock('../EntitiesTableFooter', () => ({ + EntitiesTableFooter: () =>
, +})); + +jest.mock('../EntitiesRow', () => ({ + EntitiesRow: ({ entity }: { entity: any }) => ( + + ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('EntitiesTable', () => { + const defaultAggregatedData = { + metricMetadata: { title: 'Open PRs' }, + entities: [ + { + entityRef: 'component:default/service-a', + status: 'success', + metricValue: 5, + }, + { + entityRef: 'component:default/service-b', + status: 'warning', + metricValue: 12, + }, + ], + pagination: { total: 2 }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseOwnershipEntityRefs.mockReturnValue([]); + mockUseEntityMetadataMap.mockReturnValue({ entityMetadataMap: {} }); + mockUseAggregatedScorecardEntities.mockReturnValue({ + aggregatedScorecardEntities: defaultAggregatedData, + loadingData: false, + error: undefined, + }); + mockUseAggregatedScorecard.mockReturnValue({ + aggregatedScorecard: { metadata: { title: 'Open PRs' } }, + loadingData: false, + error: undefined, + }); + }); + + it('should render wrapper, header, table body, and footer', () => { + const setMetricTitle = jest.fn(); + + render( + + + , + ); + + expect(screen.getByTestId('entities-table-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('entities-table-header')).toBeInTheDocument(); + expect(screen.getByTestId('entities-table-footer')).toBeInTheDocument(); + }); + + it('should show title with count when total > 0', () => { + render( + + + , + ); + + expect(screen.getByTestId('wrapper-title')).toHaveTextContent( + 'Entities (2)', + ); + }); + + it('should show title without count when total is 0', () => { + mockUseAggregatedScorecardEntities.mockReturnValue({ + aggregatedScorecardEntities: { + metricMetadata: {}, + entities: [], + pagination: { total: 0 }, + }, + loadingData: false, + error: undefined, + }); + + render( + + + , + ); + + expect(screen.getByTestId('wrapper-title')).toHaveTextContent('Entities'); + }); + + it('should render loading spinner when loadingDataEntities is true', () => { + mockUseAggregatedScorecardEntities.mockReturnValue({ + aggregatedScorecardEntities: undefined, + loadingData: true, + error: undefined, + }); + + render( + + + , + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should render EntitiesTableStateRow with error when entitiesError is set', () => { + mockUseAggregatedScorecardEntities.mockReturnValue({ + aggregatedScorecardEntities: undefined, + loadingData: false, + error: new Error('Failed to fetch'), + }); + + render( + + + , + ); + + const stateRow = screen.getByTestId('entities-table-state-row'); + expect(stateRow).toHaveAttribute('data-error', 'true'); + }); + + it('should render EntitiesTableStateRow with noEntities when entities array is empty', () => { + mockUseAggregatedScorecardEntities.mockReturnValue({ + aggregatedScorecardEntities: { + metricMetadata: {}, + entities: [], + pagination: { total: 0 }, + }, + loadingData: false, + error: undefined, + }); + + render( + + + , + ); + + const stateRow = screen.getByTestId('entities-table-state-row'); + expect(stateRow).toHaveAttribute('data-no-entities', 'true'); + }); + + it('should render EntitiesRow for each entity when data is loaded', () => { + render( + + + , + ); + + const rows = screen.getAllByTestId('entities-row'); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveAttribute( + 'data-entity-ref', + 'component:default/service-a', + ); + expect(rows[1]).toHaveAttribute( + 'data-entity-ref', + 'component:default/service-b', + ); + }); + + it('should call useAggregatedScorecardEntities with metricId, page, pageSize, and sort state', () => { + render( + + + , + ); + + expect(mockUseAggregatedScorecardEntities).toHaveBeenCalledWith( + expect.objectContaining({ + metricId: 'jira.blocking_tickets', + page: 1, + pageSize: 5, + ownershipEntityRefs: [], + orderBy: null, + order: 'asc', + }), + ); + }); + + it('should call setMetricTitle with metric metadata title when data loads', () => { + const setMetricTitle = jest.fn(); + + render( + + + , + ); + + expect(setMetricTitle).toHaveBeenCalledWith('Open PRs'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx new file mode 100644 index 00000000000..c043824a150 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { EntitiesTableFooter } from '../EntitiesTableFooter'; + +const mockT = jest.fn((key: string, params?: { count?: string }) => { + if (key === 'entitiesPage.entitiesTableFooter.rows_other' && params?.count) { + return `${params.count} rows`; + } + if (key === 'entitiesPage.entitiesTableFooter.allRows') return 'All'; + return key; +}); + +jest.mock('../../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ t: mockT }), +})); + +jest.mock('../EntitiesTablePagination', () => ({ + EntitiesTablePagination: (props: any) => ( +
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('EntitiesTableFooter', () => { + const defaultProps = { + count: 10, + page: 0, + rowsPerPage: 5, + handleChangePage: jest.fn(), + handleChangeRowsPerPage: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render TablePagination with count, page, and rowsPerPage', () => { + render( + + + , + ); + + expect(screen.getByTestId('entities-table-pagination')).toBeInTheDocument(); + expect(screen.getByTestId('entities-table-pagination')).toHaveAttribute( + 'data-count', + '10', + ); + expect(screen.getByTestId('entities-table-pagination')).toHaveAttribute( + 'data-page', + '0', + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx new file mode 100644 index 00000000000..85bd12d0da5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { EntitiesTableHeader } from '../EntitiesTableHeader'; + +const mockT = jest.fn((key: string) => key); +jest.mock('../../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ t: mockT }), +})); + +describe('EntitiesTableHeader', () => { + const defaultProps = { + orderBy: null as string | null, + order: 'asc' as const, + onSortRequest: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render all table headers from SCORECARD_ENTITIES_TABLE_HEADERS', () => { + render( + + +
, + ); + + expect( + screen.getByText('entitiesPage.entitiesTable.header.metric'), + ).toBeInTheDocument(); + expect( + screen.getByText('entitiesPage.entitiesTable.header.value'), + ).toBeInTheDocument(); + expect( + screen.getByText('entitiesPage.entitiesTable.header.entity'), + ).toBeInTheDocument(); + expect( + screen.getByText('entitiesPage.entitiesTable.header.owner'), + ).toBeInTheDocument(); + expect( + screen.getByText('entitiesPage.entitiesTable.header.kind'), + ).toBeInTheDocument(); + expect( + screen.getByText('entitiesPage.entitiesTable.header.lastUpdated'), + ).toBeInTheDocument(); + }); + + it('should call onSortRequest when sortable header is clicked', async () => { + const onSortRequest = jest.fn(); + render( + + +
, + ); + + const statusHeader = screen.getByText( + 'entitiesPage.entitiesTable.header.metric', + ); + await userEvent.click(statusHeader); + + expect(onSortRequest).toHaveBeenCalledWith('status'); + }); + + it('should pass orderBy and order to sortable column', () => { + render( + + +
, + ); + + const sortLabel = screen.getByRole('button', { + name: /entitiesPage.entitiesTable.header.metric/i, + }); + expect(sortLabel).toBeInTheDocument(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx new file mode 100644 index 00000000000..dda0c12a1f6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { EntitiesTablePagination } from '../EntitiesTablePagination'; + +const theme = createTheme(); +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('EntitiesTablePagination', () => { + const defaultProps = { + count: 25, + page: 1, + rowsPerPage: 10, + onPageChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render range text and navigation buttons', () => { + render( + + + , + ); + + expect(screen.getByLabelText('first page')).toBeInTheDocument(); + expect(screen.getByLabelText('previous page')).toBeInTheDocument(); + expect(screen.getByLabelText('next page')).toBeInTheDocument(); + expect(screen.getByLabelText('last page')).toBeInTheDocument(); + expect(screen.getByText(/1-10 of 25/)).toBeInTheDocument(); + }); + + it('should display correct range for middle page', () => { + render( + + + , + ); + + expect(screen.getByText(/11-20 of 25/)).toBeInTheDocument(); + }); + + it('should display correct range for last partial page', () => { + render( + + + , + ); + + expect(screen.getByText(/21-25 of 25/)).toBeInTheDocument(); + }); + + it('should call onPageChange with 1 when first page is clicked', async () => { + const onPageChange = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByLabelText('first page')); + + expect(onPageChange).toHaveBeenCalledWith(expect.any(Object), 1); + }); + + it('should call onPageChange with page - 1 when previous is clicked', async () => { + const onPageChange = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByLabelText('previous page')); + + expect(onPageChange).toHaveBeenCalledWith(expect.any(Object), 1); + }); + + it('should call onPageChange with page + 1 when next is clicked', async () => { + const onPageChange = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByLabelText('next page')); + + expect(onPageChange).toHaveBeenCalledWith(expect.any(Object), 2); + }); + + it('should call onPageChange with last page when last page is clicked', async () => { + const onPageChange = jest.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByLabelText('last page')); + + expect(onPageChange).toHaveBeenCalledWith(expect.any(Object), 3); + }); + + it('should disable first and previous when page is 1', () => { + render( + + + , + ); + + expect(screen.getByLabelText('first page')).toBeDisabled(); + expect(screen.getByLabelText('previous page')).toBeDisabled(); + }); + + it('should disable next and last when on last page', () => { + render( + + + , + ); + + expect(screen.getByLabelText('next page')).toBeDisabled(); + expect(screen.getByLabelText('last page')).toBeDisabled(); + }); + + it('should show 0-0 of 0 when count is 0', () => { + render( + + + , + ); + + expect(screen.getByText(/0-0 of 0/)).toBeInTheDocument(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx new file mode 100644 index 00000000000..b9a801b7f0e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { EntitiesTableStateRow } from '../EntitiesTableStateRow'; + +const mockT = jest.fn((key: string, params?: { metricId?: string }) => { + if (key === 'entitiesPage.missingPermission') return 'Missing permission'; + if (key === 'entitiesPage.noDataFound') return 'No data found'; + if (key === 'entitiesPage.metricProviderNotRegistered' && params?.metricId) + return `Metric provider ${params.metricId} not registered`; + return key; +}); + +jest.mock('../../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ t: mockT }), +})); + +const mockUseMetric = jest.fn(); +jest.mock('../../../../hooks/useMetric', () => ({ + useMetric: (opts: { metricId: string }) => mockUseMetric(opts), +})); + +const mockUseMetricDisplayLabels = jest.fn(); +jest.mock('../../../../hooks/useMetricDisplayLabels', () => ({ + useMetricDisplayLabels: (metric: any) => mockUseMetricDisplayLabels(metric), +})); + +jest.mock('@backstage/core-components', () => ({ + WarningPanel: ({ title, message }: { title: string; message?: string }) => ( +
+ {title} + {message && {message}} +
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} +
+
+); + +describe('EntitiesTableStateRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMetric.mockReturnValue({ + metric: { id: 'github.open_prs', title: 'Open PRs' }, + }); + mockUseMetricDisplayLabels.mockReturnValue({ + title: 'Open PRs', + description: '', + }); + }); + + it('should render missing permission text when error contains NotAllowedError', () => { + render( + + + , + ); + + expect(screen.getByText('Missing permission')).toBeInTheDocument(); + }); + + it('should render no data found when noEntities is true and no error', () => { + render( + + + , + ); + + expect(screen.getByText('No data found')).toBeInTheDocument(); + }); + + it('should call setMetricTitle when metric title is resolved', () => { + const setMetricTitle = jest.fn(); + mockUseMetricDisplayLabels.mockReturnValue({ + title: 'Resolved Metric Title', + description: '', + }); + + render( + + + , + ); + + expect(setMetricTitle).toHaveBeenCalledWith('Resolved Metric Title'); + }); + + it('should render single cell with colSpan', () => { + const { container } = render( + + + , + ); + + const cell = container.querySelector('td[colspan="6"]'); + expect(cell).toBeInTheDocument(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx new file mode 100644 index 00000000000..67fcfa86aa9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { EntitiesTableWrapper } from '../EntitiesTableWrapper'; + +jest.mock('../../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => + key === 'metric.someEntitiesNotReportingValues' + ? 'Some entities are not reporting values' + : key, + }), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('EntitiesTableWrapper', () => { + it('should render title and children', () => { + render( + + +
Table content
+
+
, + ); + + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByTestId('table-content')).toHaveTextContent( + 'Table content', + ); + }); + + it('should render warning icon with tooltip', () => { + render( + + + Content + + , + ); + + const icon = document.querySelector( + '[data-testid="ReportProblemOutlinedIcon"]', + ); + expect(icon).toBeInTheDocument(); + expect( + screen.getByLabelText('Some entities are not reporting values'), + ).toBeInTheDocument(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/EntityNameCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/EntityNameCell.tsx new file mode 100644 index 00000000000..ad5b56a8939 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/EntityNameCell.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Link } from '@backstage/core-components'; +import { parseEntityRef } from '@backstage/catalog-model'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; + +import Tooltip from '@mui/material/Tooltip'; + +interface EntityNameCellProps { + entityRef: string; + entityMetadata?: { + title?: string; + kind?: string; + description?: string; + }; +} +export const EntityNameCell = ({ + entityRef, + entityMetadata, +}: EntityNameCellProps) => { + const entityLink = useRouteRef(entityRouteRef); + + const { kind, namespace, name } = parseEntityRef(entityRef); + + const displayName = entityMetadata?.title ?? name ?? '--'; + + const tooltipTitle = [ + entityRef, + entityMetadata?.kind ?? kind, + entityMetadata?.description, + ] + .filter(Boolean) + .join(' | '); + + return ( + + + {displayName} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/MetricStatusCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/MetricStatusCell.tsx new file mode 100644 index 00000000000..028e8f69e67 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/MetricStatusCell.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; + +export const MetricStatusCell = memo( + ({ status, theme }: { status: string | undefined; theme: any }) => { + return ( + + + {status || '--'} + + ); + }, +); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/OwnerCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/OwnerCell.tsx new file mode 100644 index 00000000000..1fce827e261 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/OwnerCell.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { memo } from 'react'; + +import { Link } from '@backstage/core-components'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { + entityRouteRef, + useEntityPresentation, +} from '@backstage/plugin-catalog-react'; + +import Tooltip from '@mui/material/Tooltip'; + +export const OwnerCell = memo(({ ownerRef }: { ownerRef?: string }) => { + const entityLink = useRouteRef(entityRouteRef); + + const parsedEntityRef = ownerRef + ? parseEntityRef(ownerRef, { + defaultKind: 'group', + defaultNamespace: 'default', + }) + : null; + const stringifiedEntityRef = parsedEntityRef + ? stringifyEntityRef(parsedEntityRef) + : ''; + const { primaryTitle, secondaryTitle } = + useEntityPresentation(stringifiedEntityRef); + + if (!ownerRef) return <>--; + + const link = entityLink(parsedEntityRef!); + + return ( + + + {primaryTitle} + + + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx new file mode 100644 index 00000000000..42510dd6763 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; + +import { EntityNameCell } from '../EntityNameCell'; + +jest.mock('@backstage/core-components', () => { + const React = require('react'); + return { + Link: React.forwardRef( + ( + { to, children, ...props }: { to: string; children: React.ReactNode }, + ref: React.Ref, + ) => ( + + {children} + + ), + ), + }; +}); + +const mockEntityLink = jest.fn(); +jest.mock('@backstage/core-plugin-api', () => ({ + useRouteRef: () => mockEntityLink, +})); + +jest.mock('@backstage/plugin-catalog-react', () => ({ + entityRouteRef: { id: 'entity-route' }, +})); + +describe('EntityNameCell', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEntityLink.mockImplementation( + (params: { kind: string; namespace: string; name: string }) => + `/catalog/${params.namespace}/${params.kind}/${params.name}`, + ); + }); + + it('should render entity name from parseEntityRef when no entityMetadata', () => { + render(); + + expect(screen.getByText('my-service')).toBeInTheDocument(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute( + 'href', + '/catalog/default/component/my-service', + ); + expect(link).toHaveTextContent('my-service'); + }); + + it('should prefer entityMetadata.title over parsed name', () => { + render( + , + ); + + expect(screen.getByText('My Service Display Name')).toBeInTheDocument(); + }); + + it('should use parsed name when entityMetadata has no title', () => { + render( + , + ); + expect(screen.getByText('backend-service')).toBeInTheDocument(); + }); + + it('should build tooltip from entityRef, kind, and description', () => { + render( + , + ); + + expect(screen.getByText('My Service')).toBeInTheDocument(); + const link = screen.getByRole('link'); + expect(link).toHaveTextContent('My Service'); + expect(link).toHaveAttribute( + 'aria-label', + 'component:default/my-service | Component | A backend service', + ); + }); + + it('should pass parsed kind, namespace, name to entityLink', () => { + mockEntityLink.mockReturnValue('/custom/path'); + + render(); + + expect(mockEntityLink).toHaveBeenCalledWith({ + kind: 'component', + namespace: 'staging', + name: 'frontend-app', + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx new file mode 100644 index 00000000000..6f4c4a25a27 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { render, screen } from '@testing-library/react'; + +import { MetricStatusCell } from '../MetricStatusCell'; + +const theme = createTheme(); + +describe('MetricStatusCell', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should render status text when status is provided', () => { + render( + + + , + ); + + expect(screen.getByText('success')).toBeInTheDocument(); + }); + + it('should render -- when status is empty string', () => { + render( + + + , + ); + + expect(screen.getByText('--')).toBeInTheDocument(); + }); + + it('should render a colored indicator box', () => { + const { container } = render( + + + , + ); + + const box = container.querySelector('[class*="MuiBox"]'); + expect(box).toBeInTheDocument(); + }); + + it('should use theme fallback color when status has no palette key', () => { + render( + + + , + ); + + expect(screen.getByText('customStatus')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx new file mode 100644 index 00000000000..c5299d03efa --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; + +import { OwnerCell } from '../OwnerCell'; + +jest.mock('@backstage/core-components', () => { + const React = require('react'); + return { + Link: React.forwardRef( + ( + { to, children, ...props }: { to: string; children: React.ReactNode }, + ref: React.Ref, + ) => ( + + {children} + + ), + ), + }; +}); + +const mockEntityLink = jest.fn(); +jest.mock('@backstage/core-plugin-api', () => ({ + useRouteRef: () => mockEntityLink, +})); + +jest.mock('@backstage/catalog-model', () => + jest.requireActual('@backstage/catalog-model'), +); + +const mockUseEntityPresentation = jest.fn(); +jest.mock('@backstage/plugin-catalog-react', () => ({ + entityRouteRef: { id: 'entity-route' }, + useEntityPresentation: (ref: string) => mockUseEntityPresentation(ref), +})); + +describe('OwnerCell', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEntityLink.mockReturnValue('/catalog/default/group/team-a'); + mockUseEntityPresentation.mockReturnValue({ + primaryTitle: 'Team A', + secondaryTitle: 'group:default/team-a', + }); + }); + + it('should render -- when ownerRef is undefined', () => { + render(); + + expect(screen.getByText('--')).toBeInTheDocument(); + }); + + it('should render -- when ownerRef is empty string', () => { + render(); + + expect(screen.getByText('--')).toBeInTheDocument(); + }); + + it('should render link with primary title when ownerRef is provided', () => { + render(); + + expect(screen.getByText('Team A')).toBeInTheDocument(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/catalog/default/group/team-a'); + expect(link).toHaveTextContent('Team A'); + }); + + it('should resolve short owner ref to group:default/ ref', () => { + mockUseEntityPresentation.mockReturnValue({ + primaryTitle: 'Platform', + secondaryTitle: 'group:default/platform', + }); + mockEntityLink.mockReturnValue('/catalog/default/group/platform'); + + render(); + + expect(screen.getByText('Platform')).toBeInTheDocument(); + expect(mockUseEntityPresentation).toHaveBeenCalledWith( + 'group:default/platform', + ); + }); + + it('should pass full ref when ownerRef already contains colon', () => { + render(); + + expect(mockUseEntityPresentation).toHaveBeenCalledWith( + 'group:default/team-a', + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPage.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPage.tsx new file mode 100644 index 00000000000..d8cd0bde8f1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPage.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { Content, Page } from '@backstage/core-components'; + +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; + +import { ScorecardHomepageCard } from '../ScorecardHomepageSection/ScorecardHomepageCard'; +import NotFoundState from '../Common/NotFoundState'; +import { useTranslation } from '../../hooks/useTranslation'; + +import { ScorecardPageHeader } from './ScorecardPageHeader'; +import { EntitiesTable } from './EntitiesTable/EntitiesTable'; + +export const ScorecardPage = () => { + const { metricId } = useParams<{ metricId?: string }>(); + + const [metricTitle, setMetricTitle] = useState(''); + const [metricNotFound, setMetricNotFound] = useState(false); + + const { t } = useTranslation(); + + const titleKey = `metric.${metricId}.title`; + const title = t(titleKey as any, {}); + const finalTitle = title === titleKey ? metricTitle : title; + + if (metricNotFound) { + return ( + + + + + + ); + } + + return ( + + + + + + + + + *': { height: '100%' }, + '& > div[class*="MuiCard-root"]': { + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }, + '& div[class*="MuiCardContent-root"]': { + flex: 1, + minHeight: 0, + }, + }} + > + + + + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPageHeader.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPageHeader.tsx new file mode 100644 index 00000000000..d487a9e8ade --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPageHeader.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Header as BackstageHeader } from '@backstage/core-components'; + +export const ScorecardPageHeader = ({ title }: { title: string }) => { + return ; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/__tests__/ScorecardPage.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/__tests__/ScorecardPage.test.tsx new file mode 100644 index 00000000000..3d2b4cb0c20 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/__tests__/ScorecardPage.test.tsx @@ -0,0 +1,176 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, render, screen } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { ScorecardPage } from '../ScorecardPage'; + +const mockUseParams = jest.fn(); +jest.mock('react-router-dom', () => ({ + useParams: () => mockUseParams(), +})); + +jest.mock('../../../hooks/useTranslation', () => ({ + useTranslation: () => ({ + t: (key: string) => { + if (key === 'entitiesPage.unknownMetric') return 'Unknown metric'; + return key; + }, + }), +})); + +jest.mock('@backstage/core-components', () => ({ + Page: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Content: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const mockScorecardPageHeader = jest.fn(); +jest.mock('../ScorecardPageHeader', () => ({ + ScorecardPageHeader: (props: { title: string }) => { + mockScorecardPageHeader(props); + return
{props.title}
; + }, +})); + +const mockEntitiesTable = jest.fn(); +jest.mock('../EntitiesTable/EntitiesTable', () => ({ + EntitiesTable: (props: { + metricId?: string; + setMetricTitle: (title: string) => void; + }) => { + mockEntitiesTable(props); + return ( +
+ +
+ ); + }, +})); + +const mockScorecardHomepageCard = jest.fn(); +jest.mock('../../ScorecardHomepageSection/ScorecardHomepageCard', () => ({ + ScorecardHomepageCard: (props: { + metricId: string; + showSubheader: boolean; + showInfo: boolean; + }) => { + mockScorecardHomepageCard(props); + return ( +
+ {props.metricId} +
+ ); + }, +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('ScorecardPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render page structure with header, content, table and scorecard card', () => { + mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); + + render(, { wrapper: TestWrapper }); + + expect(screen.getByTestId('page')).toBeInTheDocument(); + expect(screen.getByTestId('content')).toBeInTheDocument(); + expect(screen.getByTestId('scorecard-page-header')).toBeInTheDocument(); + expect(screen.getByTestId('entities-table')).toBeInTheDocument(); + expect(screen.getByTestId('scorecard-homepage-card')).toBeInTheDocument(); + }); + + it('should pass metricId to header when metricTitle is empty', () => { + mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); + + render(, { wrapper: TestWrapper }); + + expect(mockScorecardPageHeader).toHaveBeenCalledWith( + expect.objectContaining({ title: 'github.open_prs' }), + ); + }); + + it('should show Unknown metric in header when metricId is undefined', () => { + mockUseParams.mockReturnValue({ metricId: undefined }); + + render(, { wrapper: TestWrapper }); + + expect(mockScorecardPageHeader).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Unknown metric' }), + ); + }); + + it('should pass metricId and setMetricTitle to EntitiesTable', () => { + mockUseParams.mockReturnValue({ metricId: 'jira.blocking_tickets' }); + + render(, { wrapper: TestWrapper }); + + expect(mockEntitiesTable).toHaveBeenCalledWith( + expect.objectContaining({ + metricId: 'jira.blocking_tickets', + setMetricTitle: expect.any(Function), + }), + ); + }); + + it('should pass metricId, showSubheader false, and showInfo false to ScorecardHomepageCard', () => { + mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); + + render(, { wrapper: TestWrapper }); + + expect(mockScorecardHomepageCard).toHaveBeenCalledWith({ + metricId: 'github.open_prs', + showSubheader: false, + showInfo: false, + }); + }); + + it('should update header title when setMetricTitle is called from EntitiesTable', () => { + mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); + + render(, { wrapper: TestWrapper }); + + expect(mockScorecardPageHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ title: 'github.open_prs' }), + ); + + act(() => { + screen.getByRole('button', { name: 'Set title' }).click(); + }); + + expect(mockScorecardPageHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ title: 'Metric Title from Table' }), + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/index.ts new file mode 100644 index 00000000000..1c0a54720a6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ScorecardPage } from './ScorecardPage'; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/types.ts index 5ee642df590..c5c52a10f27 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/types.ts @@ -19,3 +19,20 @@ export type PieData = { value: number; color?: string; }; + +export type EntityMetadata = { + title?: string; + description?: string; + kind?: string; +}; + +export type EntityMetadataMap = Record; + +export type GetAggregatedScorecardEntitiesOptions = { + metricId: string; + page: number; + pageSize: number; + ownershipEntityRefs?: string[]; + orderBy?: string | null; + order?: 'asc' | 'desc'; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetric.test.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetric.test.tsx new file mode 100644 index 00000000000..8ca516a2b69 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetric.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import useAsync from 'react-use/lib/useAsync'; +import { renderHook } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { useMetric } from '../useMetric'; + +jest.mock('@backstage/core-plugin-api'); +jest.mock('react-use/lib/useAsync'); +jest.mock('../useTranslation', () => ({ + useTranslation: jest.fn(), +})); + +const mockUseApi = useApi as jest.MockedFunction; +const mockUseAsync = useAsync as jest.MockedFunction; + +import { useTranslation } from '../useTranslation'; + +describe('useMetric', () => { + const mockScorecardApi = { + getMetrics: jest.fn(), + }; + + const mockMetric: Metric = { + id: 'github.open_prs', + title: 'GitHub open PRs', + description: + 'Current count of open Pull Requests for a given GitHub repository.', + type: 'number', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseApi.mockReturnValue(mockScorecardApi); + (useTranslation as jest.Mock).mockImplementation(() => ({ + t: (key: string) => key, + })); + }); + + it('should return loading state when useAsync is loading', () => { + mockUseAsync.mockReturnValue({ + loading: true, + error: undefined, + value: undefined, + }); + + const { result } = renderHook(() => + useMetric({ metricId: 'github.open_prs' }), + ); + + expect(result.current).toEqual({ + metric: undefined, + loadingData: true, + error: undefined, + }); + }); + + it('should return metric when API call succeeds', () => { + mockUseAsync.mockReturnValue({ + loading: false, + error: undefined, + value: mockMetric, + }); + + const { result } = renderHook(() => + useMetric({ metricId: 'github.open_prs' }), + ); + + expect(result.current).toEqual({ + metric: mockMetric, + loadingData: false, + error: undefined, + }); + }); + + it('should return error when useAsync has error', () => { + const apiError = new Error('Failed to fetch metric'); + mockUseAsync.mockReturnValue({ + loading: false, + error: apiError, + value: undefined, + }); + + const { result } = renderHook(() => + useMetric({ metricId: 'github.open_prs' }), + ); + + expect(result.current).toEqual({ + metric: undefined, + loadingData: false, + error: apiError, + }); + }); + + it('should call getMetrics with the provided metricId', () => { + mockScorecardApi.getMetrics.mockResolvedValue({ metrics: [mockMetric] }); + mockUseAsync.mockImplementation(fn => { + fn().catch(() => {}); + return { + loading: false, + error: undefined, + value: undefined, + }; + }); + + renderHook(() => useMetric({ metricId: 'jira.blocking_tickets' })); + + expect(mockScorecardApi.getMetrics).toHaveBeenCalledWith({ + metricIds: ['jira.blocking_tickets'], + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx new file mode 100644 index 00000000000..48202d348d2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook } from '@testing-library/react'; + +import { useMetricDisplayLabels } from '../useMetricDisplayLabels'; + +jest.mock('../useTranslation', () => ({ + useTranslation: jest.fn(), +})); + +import { useTranslation } from '../useTranslation'; + +describe('useMetricDisplayLabels', () => { + const mockT = jest.fn(); + + const metric = { + id: 'github.open_prs', + title: 'GitHub open PRs', + description: + 'Current count of open Pull Requests for a given GitHub repository.', + }; + + beforeEach(() => { + (useTranslation as jest.Mock).mockImplementation(() => ({ + t: mockT, + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty strings when metric is undefined', () => { + const { result } = renderHook(() => useMetricDisplayLabels()); + + expect(result.current).toEqual({ + title: '', + description: '', + }); + }); + + it('should return translated title and description when translation exists', () => { + mockT.mockImplementation((key: string) => { + if (key === 'metric.github.open_prs.title') return 'Translated Title'; + if (key === 'metric.github.open_prs.description') + return 'Translated Description'; + return key; + }); + + const { result } = renderHook(() => useMetricDisplayLabels(metric as any)); + + expect(result.current).toEqual({ + title: 'Translated Title', + description: 'Translated Description', + }); + }); + + it('should fall back to original values when translation does not exist', () => { + mockT.mockImplementation((key: string) => key); + + const { result } = renderHook(() => useMetricDisplayLabels(metric as any)); + + expect(result.current).toEqual({ + title: 'GitHub open PRs', + description: + 'Current count of open Pull Requests for a given GitHub repository.', + }); + }); + + it('should use translated title but original description when only title translation exists', () => { + mockT.mockImplementation((key: string) => { + if (key === 'metric.github.open_prs.title') return 'Translated Title'; + return key; + }); + + const { result } = renderHook(() => useMetricDisplayLabels(metric as any)); + + expect(result.current).toEqual({ + title: 'Translated Title', + description: + 'Current count of open Pull Requests for a given GitHub repository.', + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useAggregatedScorecardEntities.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/useAggregatedScorecardEntities.tsx new file mode 100644 index 00000000000..d91cd2ac113 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useAggregatedScorecardEntities.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useMemo } from 'react'; + +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import type { EntityMetricDetailResponse } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { scorecardApiRef } from '../api'; +import { useTranslation } from './useTranslation'; + +interface UseAggregatedScorecardEntitiesOptions { + metricId: string; + page?: number; + pageSize?: number; + ownershipEntityRefs?: string[]; + orderBy?: string | null; + order?: 'asc' | 'desc'; +} + +export const useAggregatedScorecardEntities = ( + options: UseAggregatedScorecardEntitiesOptions, +) => { + const scorecardApi = useApi(scorecardApiRef); + + const { + metricId, + page = 1, + pageSize = 5, + ownershipEntityRefs = [], + orderBy = null, + order = 'asc', + } = options; + const { t } = useTranslation(); + + const { error, loading, value } = useAsync(async () => { + try { + const aggregatedScorecardEntities = + await scorecardApi.getAggregatedScorecardEntities({ + metricId, + page, + pageSize, + ownershipEntityRefs, + orderBy, + order, + }); + + if ( + !aggregatedScorecardEntities || + Array.isArray(aggregatedScorecardEntities) || + typeof aggregatedScorecardEntities !== 'object' + ) { + throw new Error(t('errors.invalidApiResponse')); + } + + return aggregatedScorecardEntities; + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error( + t('errors.fetchError' as any, { + error: String(err), + }), + ); + } + }, [ + scorecardApi, + metricId, + page, + pageSize, + ownershipEntityRefs, + orderBy, + order, + t, + ]); + + return useMemo( + () => ({ + aggregatedScorecardEntities: value as EntityMetricDetailResponse, + loadingData: loading, + error, + }), + [value, loading, error], + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useEntityMetadataMap.ts b/workspaces/scorecard/plugins/scorecard/src/hooks/useEntityMetadataMap.ts new file mode 100644 index 00000000000..80e3be9eff1 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useEntityMetadataMap.ts @@ -0,0 +1,120 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useMemo, useState } from 'react'; + +import type { Entity } from '@backstage/catalog-model'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { EntityMetadata, EntityMetadataMap } from '../components/types'; + +type RefFilter = { + kind: string; + 'metadata.name': string; + 'metadata.namespace': string; +}; + +const uniqueEntityRefs = (refs: string[]) => { + const seen = new Set(); + const result: string[] = []; + refs.forEach(ref => { + const trimmed = ref?.trim(); + if (!trimmed || seen.has(trimmed)) { + return; + } + seen.add(trimmed); + result.push(trimmed); + }); + return result; +}; + +const refToFilter = (ref: string): RefFilter | null => { + try { + const { kind, namespace, name } = parseEntityRef(ref); + return { + kind, + 'metadata.name': name, + 'metadata.namespace': namespace ?? 'default', + }; + } catch { + return null; + } +}; + +const toEntityMetadata = (entity: Entity): EntityMetadata => ({ + title: entity?.metadata?.title?.trim(), + description: entity?.metadata?.description?.trim(), + kind: entity?.kind, +}); + +export const useEntityMetadataMap = (entityRefs: string[]) => { + const catalogApi = useApi(catalogApiRef); + const [entityMetadataMap, setEntityMetadataMap] = useState( + {}, + ); + + const refs = useMemo( + () => uniqueEntityRefs(entityRefs), + // eslint-disable-next-line react-hooks/exhaustive-deps + [entityRefs.join('|')], + ); + + const refFilters = useMemo( + () => refs.map(refToFilter).filter(Boolean) as RefFilter[], + [refs], + ); + + useEffect(() => { + let cancelled = false; + const clearMap = () => setEntityMetadataMap({}); + + if (refFilters.length === 0) { + clearMap(); + return () => { + cancelled = true; + }; + } + + const fetchEntities = async () => { + try { + const response = await catalogApi.getEntities({ + filter: refFilters, + }); + if (cancelled) { + return; + } + const nextMap: EntityMetadataMap = {}; + response.items?.forEach((entity: Entity) => { + nextMap[stringifyEntityRef(entity)] = toEntityMetadata(entity); + }); + setEntityMetadataMap(nextMap); + } catch { + if (!cancelled) { + clearMap(); + } + } + }; + + fetchEntities(); + + return () => { + cancelled = true; + }; + }, [catalogApi, refFilters]); + + return { entityMetadataMap }; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useLanguage.ts b/workspaces/scorecard/plugins/scorecard/src/hooks/useLanguage.ts new file mode 100644 index 00000000000..3ba0f8b0f15 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useLanguage.ts @@ -0,0 +1,25 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; +import { appLanguageApiRef } from '@backstage/core-plugin-api/alpha'; + +/** + * Hook to get the current language setting + * @returns The current language code (e.g., 'en', 'de', 'fr') + */ +export const useLanguage = (): string => + useApi(appLanguageApiRef).getLanguage().language; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useMetric.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetric.tsx new file mode 100644 index 00000000000..7df6ae6169a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetric.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useMemo } from 'react'; + +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { useTranslation } from './useTranslation'; +import { scorecardApiRef } from '../api'; + +interface UseMetricOptions { + metricId: string; +} + +export const useMetric = (options: UseMetricOptions) => { + const { metricId } = options; + const { t } = useTranslation(); + + const scorecardApi = useApi(scorecardApiRef); + + const { error, loading, value } = useAsync(async () => { + try { + const { metrics } = await scorecardApi.getMetrics({ + metricIds: [metricId], + }); + + if (!Array.isArray(metrics) || metrics.length === 0) { + throw new Error(t('errors.invalidApiResponse')); + } + + return metrics[0] as Metric; + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error( + t('errors.fetchError' as any, { + error: String(err), + }), + ); + } + }, [scorecardApi, metricId, t]); + + return useMemo( + () => ({ + metric: value as Metric, + loadingData: loading, + error, + }), + [value, loading, error], + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx new file mode 100644 index 00000000000..d0ecd0ec621 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { useTranslation } from './useTranslation'; + +export const useMetricDisplayLabels = (metric?: Metric) => { + const { t } = useTranslation(); + + if (!metric) { + return { title: '', description: '' }; + } + + const { id, title: originalTitle, description: originalDescription } = metric; + + const titleKey = `metric.${id}.title`; + const descriptionKey = `metric.${id}.description`; + + const translatedTitle = t(titleKey as any, {}); + const translatedDescription = t(descriptionKey as any, {}); + + const isTitleTranslated = translatedTitle !== titleKey; + const isDescriptionTranslated = translatedDescription !== descriptionKey; + + return { + title: isTitleTranslated ? translatedTitle : originalTitle, + description: isDescriptionTranslated + ? translatedDescription + : originalDescription, + }; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useOwnershipEntityRefs.ts b/workspaces/scorecard/plugins/scorecard/src/hooks/useOwnershipEntityRefs.ts new file mode 100644 index 00000000000..fd393320c8a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useOwnershipEntityRefs.ts @@ -0,0 +1,31 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; + +export const useOwnershipEntityRefs = () => { + const identityApi = useApi(identityApiRef); + const [ownershipEntityRefs, setOwnershipEntityRefs] = useState([]); + + useEffect(() => { + identityApi.getBackstageIdentity().then(identity => { + setOwnershipEntityRefs(identity?.ownershipEntityRefs ?? []); + }); + }, [identityApi]); + + return ownershipEntityRefs; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/images/not-found.svg b/workspaces/scorecard/plugins/scorecard/src/images/not-found.svg new file mode 100644 index 00000000000..ad0bafc1341 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/images/not-found.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/workspaces/scorecard/plugins/scorecard/src/index.ts b/workspaces/scorecard/plugins/scorecard/src/index.ts index ab9269e5182..65891242f3f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +/** + * Scorecard plugin for Backstage – entity scorecards, metrics, and entities page. + * @packageDocumentation + */ + import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; ClassNameGenerator.configure(componentName => { diff --git a/workspaces/scorecard/plugins/scorecard/src/pages/ScorecardPage.tsx b/workspaces/scorecard/plugins/scorecard/src/pages/ScorecardPage.tsx new file mode 100644 index 00000000000..063d3e25260 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/pages/ScorecardPage.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ScorecardPage } from '../components/ScorecardPage'; diff --git a/workspaces/scorecard/plugins/scorecard/src/plugin.ts b/workspaces/scorecard/plugins/scorecard/src/plugin.ts index 1a968a44946..deb460d9c74 100644 --- a/workspaces/scorecard/plugins/scorecard/src/plugin.ts +++ b/workspaces/scorecard/plugins/scorecard/src/plugin.ts @@ -80,3 +80,15 @@ export const ScorecardHomepageCard = scorecardPlugin.provide( }, }), ); + +/** + * Scorecard page. + * @public + */ +export const ScorecardPage = scorecardPlugin.provide( + createRoutableExtension({ + name: 'ScorecardPage', + component: () => import('./pages/ScorecardPage').then(m => m.ScorecardPage), + mountPoint: rootRouteRef, + }), +); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index d6f8b8ca254..139496e0079 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -24,22 +24,93 @@ import { scorecardTranslationRef } from './ref'; const scorecardTranslationDe = createTranslationMessages({ ref: scorecardTranslationRef, messages: { + // Empty state translations 'emptyState.title': 'Noch keine Scorecards hinzugefügt', 'emptyState.description': 'Scorecards helfen Ihnen, den Zustand der Komponenten auf einen Blick zu überwachen. Schauen Sie sich zunächst unsere Dokumentation mit den Einrichtungshinweisen an.', + 'emptyState.button': 'Dokumentation anzeigen', 'emptyState.altText': 'Keine Scorecards', + + // Permission required translations 'permissionRequired.title': 'Fehlende Berechtigung', 'permissionRequired.description': 'Wenn Sie das Scorecard-Plugin anzeigen möchten, wenden Sie sich an den Administrator, um die Berechtigung {{permission}} zu erhalten.', + 'permissionRequired.button': 'Mehr erfahren', 'permissionRequired.altText': 'Berechtigung erforderlich', + + // Not found state + 'notFound.title': '404 Diese Seite wurde nicht gefunden', + 'notFound.description': + 'Fügen Sie eine {{indexFile}}-Datei im Stammverzeichnis des docs-Ordners dieses Repositorys hinzu.', + 'notFound.readMore': 'Mehr erfahren', + 'notFound.goBack': 'Zurück', + 'notFound.contactSupport': 'Support kontaktieren', + 'notFound.altText': 'Seite nicht gefunden', + + // Error messages 'errors.entityMissingProperties': 'Für die Scorecard-Suche fehlen dem Element die erforderlichen Eigenschaften.', 'errors.invalidApiResponse': 'Ungültiges Antwortformat der Scorecard-API', 'errors.fetchError': 'Fehler beim Abrufen der Scorecards: {{error}}', + 'errors.metricDataUnavailable': 'Metrikdaten nicht verfügbar', 'errors.invalidThresholds': 'Ungültige Schwellenwerte', 'errors.missingPermission': 'Fehlende Berechtigung', + 'errors.noDataFound': 'Keine Daten gefunden', + 'errors.authenticationError': 'Authentifizierungsfehler', 'errors.missingPermissionMessage': 'Um die Metriken der Scorecard einzusehen, muss Ihnen der Administrator die erforderliche Berechtigung erteilen.', + 'errors.userNotFoundInCatalogMessage': + 'Benutzer-Element nicht im Katalog gefunden.', + 'errors.noDataFoundMessage': + 'Um Ihre Daten hier anzuzeigen, überprüfen Sie, ob Ihre Elemente Werte melden, die mit dieser Metrik in Verbindung stehen.', + 'errors.authenticationErrorMessage': + 'Bitte melden Sie sich an, um Ihre Daten anzuzeigen.', + 'errors.noMetricsFound': + 'Keine Metriken für die angegebene Metrik-ID gefunden.', + 'errors.multipleMetricsFound': + 'Mehrere Metriken für die angegebene Metrik-ID gefunden. Genau eine erwartet.', + + // Metric translations + 'metric.github.open_prs.title': 'GitHub PRs offen', + 'metric.github.open_prs.description': + 'Aktuelle Anzahl offener Pull Requests für ein bestimmtes GitHub-Repository.', + 'metric.jira.open_issues.title': 'Jira offene blockierende Tickets', + 'metric.jira.open_issues.description': + 'Hervorhebt die Anzahl der kritischen, blockierenden Probleme, die derzeit in Jira offen sind.', + 'metric.lastUpdated': 'Zuletzt aktualisiert: {{timestamp}}', + 'metric.lastUpdatedNotAvailable': 'Zuletzt aktualisiert: Nicht verfügbar', + 'metric.someEntitiesNotReportingValues': + 'Einige Elemente melden keine Werte, die mit dieser Metrik in Verbindung stehen.', + + // Threshold translations + 'thresholds.success': 'Erfolg', + 'thresholds.warning': 'Warnung', + 'thresholds.error': 'Fehler', + 'thresholds.noEntities': 'Keine Elemente im {{category}}-Zustand', + 'thresholds.entities_one': '{{count}} Element', + 'thresholds.entities_other': '{{count}} Elemente', + + // Entities page translations + 'entitiesPage.unknownMetric': 'Unbekannte Metrik', + 'entitiesPage.noDataFound': + 'Um Ihre Daten hier anzuzeigen, überprüfen Sie, ob Ihre Elemente Werte melden, die mit dieser Metrik in Verbindung stehen.', + 'entitiesPage.missingPermission': + 'Um die Metriken der Scorecard einzusehen, muss Ihnen der Administrator die erforderliche Berechtigung erteilen.', + 'entitiesPage.metricProviderNotRegistered': + 'Metrik-Anbieter mit ID {{metricId}} ist nicht registriert.', + 'entitiesPage.entitiesTable.title': 'Elemente', + 'entitiesPage.entitiesTable.unavailable': 'Nicht verfügbar', + 'entitiesPage.entitiesTable.titleWithCount': 'Elemente ({{count}})', + 'entitiesPage.entitiesTable.header.metric': 'Metrik', + 'entitiesPage.entitiesTable.header.value': 'Wert', + 'entitiesPage.entitiesTable.header.entity': 'Element', + 'entitiesPage.entitiesTable.header.owner': 'Eigentümer', + 'entitiesPage.entitiesTable.header.kind': 'Art', + 'entitiesPage.entitiesTable.header.lastUpdated': 'Zuletzt aktualisiert', + 'entitiesPage.entitiesTable.footer.allRows': 'Alle Zeilen', + 'entitiesPage.entitiesTable.footer.rows_one': '{{count}} Zeile', + 'entitiesPage.entitiesTable.footer.rows_other': '{{count}} Zeilen', + 'entitiesPage.entitiesTable.footer.of': 'von', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index 7ffd2cf7021..452a71c3694 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -24,24 +24,94 @@ import { scorecardTranslationRef } from './ref'; const scorecardTranslationEs = createTranslationMessages({ ref: scorecardTranslationRef, messages: { + // Empty state translations 'emptyState.title': 'Aún no se agregaron tarjetas de puntuación', 'emptyState.description': 'Las tarjetas de puntuación ayudan a monitorear el estado del componente de un vistazo. Para comenzar, explore la documentación para obtener pautas de configuración.', + 'emptyState.button': 'Ver documentación', 'emptyState.altText': 'No hay tarjetas de puntuación', + + // Permission required translations 'permissionRequired.title': 'Permiso faltante', 'permissionRequired.description': 'Para ver el complemento de tarjetas de puntuación, comuníquese con su administrador para que le otorgue el permiso {{permission}}.', + 'permissionRequired.button': 'Leer más', 'permissionRequired.altText': 'Permiso requerido', + + // Not found state + 'notFound.title': '404 No pudimos encontrar esa página', + 'notFound.description': + 'Intente agregar un archivo {{indexFile}} en la raíz del directorio de documentación de este repositorio.', + 'notFound.readMore': 'Leer más', + 'notFound.goBack': 'Volver', + 'notFound.contactSupport': 'Contactar soporte', + 'notFound.altText': 'Página no encontrada', + + // Error messages 'errors.entityMissingProperties': 'Entidad a la que le faltan las propiedades requeridas para la búsqueda en la tarjeta de puntuación', 'errors.invalidApiResponse': 'Formato de respuesta no válido de la API de la tarjeta de puntuación', 'errors.fetchError': 'Error al extraer las tarjetas de puntuación: {{error}}', + 'errors.metricDataUnavailable': 'Datos de métricas no disponibles', 'errors.invalidThresholds': 'Umbrales no válidos', 'errors.missingPermission': 'Permiso faltante', + 'errors.noDataFound': 'No se encontraron datos', + 'errors.authenticationError': 'Error de autenticación', 'errors.missingPermissionMessage': 'Para ver las métricas de la tarjeta de puntuación, su administrador debe otorgarle el permiso requerido.', + 'errors.userNotFoundInCatalogMessage': + 'Entidad de usuario no encontrada en el catálogo', + 'errors.noDataFoundMessage': + 'Para ver tus datos aquí, comprueba que tus entidades estén reportando valores relacionados con esta métrica.', + 'errors.authenticationErrorMessage': 'Inicie sesión para ver sus datos.', + 'errors.noMetricsFound': + 'No se encontraron métricas para la ID de métrica especificada.', + 'errors.multipleMetricsFound': + 'Se encontraron múltiples métricas para la ID de métrica especificada. Se esperaba exactamente una.', + + // Metric translations + 'metric.github.open_prs.title': 'GitHub PRs abiertas', + 'metric.github.open_prs.description': + 'Recuento actual de Pull Requests abiertas para un repositorio de GitHub dado.', + 'metric.jira.open_issues.title': 'Jira tickets bloqueantes abiertos', + 'metric.jira.open_issues.description': + 'Destaca el número de problemas críticos y bloqueantes que están actualmente abiertos en Jira.', + 'metric.lastUpdated': 'Última actualización: {{timestamp}}', + 'metric.lastUpdatedNotAvailable': 'Última actualización: No disponible', + 'metric.someEntitiesNotReportingValues': + 'Algunas entidades no están reportando valores relacionados con esta métrica.', + + // Threshold translations + 'thresholds.success': 'Éxito', + 'thresholds.warning': 'Advertencia', + 'thresholds.error': 'Error', + 'thresholds.noEntities': 'No hay entidades en el estado {{category}}', + 'thresholds.entities_one': '{{count}} entidad', + 'thresholds.entities_other': '{{count}} entidades', + + // Entities page translations + 'entitiesPage.unknownMetric': 'Métrica desconocida', + 'entitiesPage.noDataFound': + 'Para ver tus datos aquí, comprueba que tus entidades estén reportando valores relacionados con esta métrica.', + 'entitiesPage.missingPermission': + 'Para ver las métricas de scorecard, tu administrador debe otorgarle el permiso requerido.', + 'entitiesPage.metricProviderNotRegistered': + 'Proveedor de métrica con ID {{metricId}} no registrado.', + 'entitiesPage.entitiesTable.title': 'Entidades', + 'entitiesPage.entitiesTable.unavailable': 'No disponible', + 'entitiesPage.entitiesTable.titleWithCount': 'Entidades ({{count}})', + 'entitiesPage.entitiesTable.header.metric': 'Métrica', + 'entitiesPage.entitiesTable.header.value': 'Valor', + 'entitiesPage.entitiesTable.header.entity': 'Entidad', + 'entitiesPage.entitiesTable.header.owner': 'Propietario', + 'entitiesPage.entitiesTable.header.kind': 'Tipo', + 'entitiesPage.entitiesTable.header.lastUpdated': 'Última actualización', + 'entitiesPage.entitiesTable.footer.allRows': 'Todas las filas', + 'entitiesPage.entitiesTable.footer.rows_one': '{{count}} fila', + 'entitiesPage.entitiesTable.footer.rows_other': '{{count}} filas', + 'entitiesPage.entitiesTable.footer.of': 'de', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index be8598750df..f944677f8d3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -24,16 +24,30 @@ import { scorecardTranslationRef } from './ref'; const scorecardTranslationFr = createTranslationMessages({ ref: scorecardTranslationRef, messages: { + // Empty state translations 'emptyState.title': "Aucune carte de score n'a encore été ajoutée", 'emptyState.description': 'Les tableaux de bord vous aident à surveiller l’état des composants en un coup d’œil. Pour commencer, explorez notre documentation pour obtenir des instructions de configuration.', 'emptyState.button': 'Voir la documentation', 'emptyState.altText': 'Pas de tableau de bord', + + // Permission required translations 'permissionRequired.title': 'Autorisations manquantes', 'permissionRequired.description': "Pour afficher le plugin Scorecard, contactez votre administrateur pour lui accorder l'autorisation {{permission}}.", 'permissionRequired.button': 'En savoir plus', 'permissionRequired.altText': 'Autorisation requise', + + // Not found state + 'notFound.title': "404 Nous n'avons pas trouvé cette page", + 'notFound.description': + "Essayez d'ajouter un fichier {{indexFile}} à la racine du répertoire docs de ce dépôt.", + 'notFound.readMore': 'En savoir plus', + 'notFound.goBack': 'Retour', + 'notFound.contactSupport': 'Contacter le support', + 'notFound.altText': 'Page introuvable', + + // Error messages 'errors.entityMissingProperties': "Entité manquant les propriétés requises pour la recherche dans la fiche d'évaluation", 'errors.invalidApiResponse': @@ -53,6 +67,11 @@ const scorecardTranslationFr = createTranslationMessages({ 'Pour voir vos données ici, vérifiez que vos entités communiquent les valeurs liées à cet indicateur.', 'errors.authenticationErrorMessage': 'Veuillez vous connecter pour afficher vos données.', + 'errors.noMetricsFound': + "Aucune métrique trouvée pour l'ID de métrique spécifié.", + 'errors.multipleMetricsFound': + "Plusieurs métriques trouvées pour l'ID de métrique spécifié. Une seule attendue.", + // Metric translations 'metric.github.open_prs.title': 'GitHub ouvre des PR', 'metric.github.open_prs.description': @@ -60,12 +79,40 @@ const scorecardTranslationFr = createTranslationMessages({ 'metric.jira.open_issues.title': 'Jira ouvre des tickets bloquants', 'metric.jira.open_issues.description': 'Met en évidence le nombre de problèmes critiques et bloquants actuellement ouverts dans Jira.', + 'metric.lastUpdated': 'Dernière mise à jour: {{timestamp}}', + 'metric.lastUpdatedNotAvailable': 'Dernière mise à jour: Non disponible', + 'metric.someEntitiesNotReportingValues': + 'Certaines entités ne communiquent pas de valeurs liées à cette métrique.', + + // Threshold translations 'thresholds.success': 'Succès', 'thresholds.warning': 'Attention', 'thresholds.error': 'Erreur', 'thresholds.noEntities': "Aucune entité dans l'état {{category}}", 'thresholds.entities_one': '{{count}} entité', 'thresholds.entities_other': '{{count}} entités', + + // Entities page translations + 'entitiesPage.unknownMetric': 'Métrique inconnue', + 'entitiesPage.noDataFound': + 'Pour voir vos données ici, vérifiez que vos entités communiquent les valeurs liées à cet indicateur.', + 'entitiesPage.missingPermission': + 'Pour voir les métriques de scorecard, votre administrateur doit vous donner la permission requise.', + 'entitiesPage.metricProviderNotRegistered': + 'Fournisseur de métrique avec ID {{metricId}} non enregistré.', + 'entitiesPage.entitiesTable.title': 'Entités', + 'entitiesPage.entitiesTable.unavailable': 'Non disponible', + 'entitiesPage.entitiesTable.titleWithCount': 'Entités ({{count}})', + 'entitiesPage.entitiesTable.header.metric': 'Métrique', + 'entitiesPage.entitiesTable.header.value': 'Valeur', + 'entitiesPage.entitiesTable.header.entity': 'Entité', + 'entitiesPage.entitiesTable.header.owner': 'Propriétaire', + 'entitiesPage.entitiesTable.header.kind': 'Type', + 'entitiesPage.entitiesTable.header.lastUpdated': 'Dernière mise à jour', + 'entitiesPage.entitiesTable.footer.allRows': 'Toutes les lignes', + 'entitiesPage.entitiesTable.footer.rows_one': '{{count}} ligne', + 'entitiesPage.entitiesTable.footer.rows_other': '{{count}} lignes', + 'entitiesPage.entitiesTable.footer.of': 'de', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 9e779394b60..ed3581ad31d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -24,16 +24,30 @@ import { scorecardTranslationRef } from './ref'; const scorecardTranslationIt = createTranslationMessages({ ref: scorecardTranslationRef, messages: { + // Empty state translations 'emptyState.title': 'Non è stata ancora aggiunta alcuna scheda punteggio', 'emptyState.description': "Le schede punteggio aiutano a monitorare a colpo d'occhio l'integrità dei componenti. Per iniziare, consultare la documentazione per le linee guida di configurazione.", 'emptyState.button': 'Visualizza la documentazione', 'emptyState.altText': 'Nessuna scheda punteggio', + + // Permission required translations 'permissionRequired.title': 'Autorizzazione mancante', 'permissionRequired.description': "Per visualizzare il plugin Scorecard, contattare l'amministratore per richiedere l'autorizzazione {{permission}}.", 'permissionRequired.button': 'Per saperne di più', 'permissionRequired.altText': 'Autorizzazione richiesta', + + // Not found state + 'notFound.title': '404 Pagina non trovata', + 'notFound.description': + 'Prova ad aggiungere un file {{indexFile}} nella root della directory docs di questo repository.', + 'notFound.readMore': 'Scopri di più', + 'notFound.goBack': 'Indietro', + 'notFound.contactSupport': 'Contatta il supporto', + 'notFound.altText': 'Pagina non trovata', + + // Error messages 'errors.entityMissingProperties': 'Entità priva delle proprietà richieste per la ricerca nella scheda punteggio', 'errors.invalidApiResponse': @@ -53,18 +67,52 @@ const scorecardTranslationIt = createTranslationMessages({ 'Per visualizzare i tuoi dati qui, verifica che le tue entità stiano riportando valori relativi a questa metrica.', 'errors.authenticationErrorMessage': 'Effettua il login per visualizzare i tuoi dati.', + 'errors.noMetricsFound': + "Nessuna metrica trovata per l'ID di metrica specificato.", + 'errors.multipleMetricsFound': + 'Multiple metrics found for the specified metric ID. Expected exactly one.', + + // Metric translations 'metric.github.open_prs.title': 'Richieste pull aperte su GitHub', 'metric.github.open_prs.description': 'Conteggio attuale delle richieste pull aperte per uno specifico repository GitHub.', 'metric.jira.open_issues.title': 'Ticket di blocco Jira aperti', 'metric.jira.open_issues.description': 'Evidenzia il numero di problemi critici e di blocco attualmente aperti in Jira.', + 'metric.lastUpdated': 'Ultimo aggiornamento: {{timestamp}}', + 'metric.lastUpdatedNotAvailable': 'Ultimo aggiornamento: Non disponibile', + 'metric.someEntitiesNotReportingValues': + 'Alcune entità non stanno riportando valori relativi a questa metrica.', + + // Threshold translations 'thresholds.success': 'Attività riuscita', 'thresholds.warning': 'Avviso', 'thresholds.error': 'Errore', 'thresholds.noEntities': 'Nessuna entità con stato {{category}}', 'thresholds.entities_one': '{{count}} entità', 'thresholds.entities_other': '{{count}} entità', + + // Entities page translations + 'entitiesPage.unknownMetric': 'Metrica sconosciuta', + 'entitiesPage.noDataFound': + 'Per visualizzare i tuoi dati qui, verifica che le tue entità stiano riportando valori relativi a questa metrica.', + 'entitiesPage.missingPermission': + "Per visualizzare le metriche della scheda punteggio, il tuo amministratore deve concedere l'autorizzazione richiesta.", + 'entitiesPage.metricProviderNotRegistered': + 'Provider di metrica con ID {{metricId}} non registrato.', + 'entitiesPage.entitiesTable.title': 'Entità', + 'entitiesPage.entitiesTable.unavailable': 'Non disponibile', + 'entitiesPage.entitiesTable.titleWithCount': 'Entità ({{count}})', + 'entitiesPage.entitiesTable.header.metric': 'Metrica', + 'entitiesPage.entitiesTable.header.value': 'Valore', + 'entitiesPage.entitiesTable.header.entity': 'Entità', + 'entitiesPage.entitiesTable.header.owner': 'Proprietario', + 'entitiesPage.entitiesTable.header.kind': 'Tipo', + 'entitiesPage.entitiesTable.header.lastUpdated': 'Ultimo aggiornamento', + 'entitiesPage.entitiesTable.footer.allRows': 'Tutte le righe', + 'entitiesPage.entitiesTable.footer.rows_one': '{{count}} riga', + 'entitiesPage.entitiesTable.footer.rows_other': '{{count}} righe', + 'entitiesPage.entitiesTable.footer.of': 'di', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index 2c2de8c7d22..2034e1e6552 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -24,16 +24,30 @@ import { scorecardTranslationRef } from './ref'; const scorecardTranslationJa = createTranslationMessages({ ref: scorecardTranslationRef, messages: { + // Empty state translations 'emptyState.title': 'スコアカードはまだ追加されていません', 'emptyState.description': 'スコアカードを使用すると、コンポーネントの健全性を一目で監視できます。まず、セットアップ手順に関するドキュメントを参照してください。', 'emptyState.button': 'ドキュメントの表示', 'emptyState.altText': 'スコアカードなし', + + // Permission required translations 'permissionRequired.title': '権限がありません', 'permissionRequired.description': 'スコアカードプラグインを表示するには、管理者に連絡して {{permission}} 権限を付与してもらうよう依頼してください。', 'permissionRequired.button': 'さらに表示する', 'permissionRequired.altText': '権限が必要', + + // Not found state + 'notFound.title': '404 ページが見つかりません', + 'notFound.description': + 'このリポジトリの docs ディレクトリのルートに {{indexFile}} ファイルを追加してみてください。', + 'notFound.readMore': '詳細を見る', + 'notFound.goBack': '戻る', + 'notFound.contactSupport': 'サポートに連絡', + 'notFound.altText': 'ページが見つかりません', + + // Error messages 'errors.entityMissingProperties': 'スコアカードの検索に必要なプロパティーがエンティティーにありません', 'errors.invalidApiResponse': 'スコアカード API からの応答形式が無効です', @@ -52,6 +66,12 @@ const scorecardTranslationJa = createTranslationMessages({ 'ここでデータを確認するには、エンティティがこの指標に関連する値を報告していることを確認してください。', 'errors.authenticationErrorMessage': 'データを確認するにはサインインしてください。', + 'errors.noMetricsFound': + '指定されたメトリクス ID に対するメトリクスが見つかりません。', + 'errors.multipleMetricsFound': + '指定されたメトリクス ID に対するメトリクスが複数見つかりました。1つのみが期待されています。', + + // Metric translations 'metric.github.open_prs.title': 'GitHub のオープン状態の PR', 'metric.github.open_prs.description': '特定の GitHub リポジトリーにおけるオープン状態のプルリクエストの数。', @@ -59,12 +79,40 @@ const scorecardTranslationJa = createTranslationMessages({ 'Jira のオープン状態の進行を妨げているチケット', 'metric.jira.open_issues.description': 'Jira で現在オープン状態になっている、重大かつ進行を妨げている課題の数を明示します。', + 'metric.lastUpdated': '最終更新日: {{timestamp}}', + 'metric.lastUpdatedNotAvailable': '最終更新日: 利用不可', + 'metric.someEntitiesNotReportingValues': + 'エンティティーがこの指標に関連する値を報告していません。', + + // Threshold translations 'thresholds.success': '成功', 'thresholds.warning': '警告', 'thresholds.error': 'エラー', 'thresholds.noEntities': '{{category}} 状態のエンティティーがありません', 'thresholds.entities_one': '{{count}} エンティティー', 'thresholds.entities_other': '{{count}} エンティティー', + + // Entities page translations + 'entitiesPage.unknownMetric': '不明なメトリクス', + 'entitiesPage.noDataFound': + 'ここでデータを確認するには、エンティティがこの指標に関連する値を報告していることを確認してください。', + 'entitiesPage.missingPermission': + 'スコアカードのメトリクスを表示するには、管理者に権限を付与してもらうよう依頼してください。', + 'entitiesPage.metricProviderNotRegistered': + 'ID {{metricId}} のメトリクスプロバイダーが登録されていません。', + 'entitiesPage.entitiesTable.title': 'エンティティー', + 'entitiesPage.entitiesTable.unavailable': '利用不可', + 'entitiesPage.entitiesTable.titleWithCount': 'エンティティー ({{count}})', + 'entitiesPage.entitiesTable.header.metric': 'メトリクス', + 'entitiesPage.entitiesTable.header.value': '値', + 'entitiesPage.entitiesTable.header.entity': 'エンティティー', + 'entitiesPage.entitiesTable.header.owner': '所有者', + 'entitiesPage.entitiesTable.header.kind': '種類', + 'entitiesPage.entitiesTable.header.lastUpdated': '最終更新日', + 'entitiesPage.entitiesTable.footer.allRows': 'すべての行', + 'entitiesPage.entitiesTable.footer.rows_one': '{{count}} 行', + 'entitiesPage.entitiesTable.footer.rows_other': '{{count}} 行', + 'entitiesPage.entitiesTable.footer.of': 'の', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index 24fc1186905..378ae2cd4a3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -31,6 +31,17 @@ export const scorecardMessages = { altText: 'No scorecards', }, + // Not found state (404) + notFound: { + title: "404 We couldn't find that page", + description: + 'Try adding an {{indexFile}} file in the root of the docs directory of this repository.', + readMore: 'Read more', + goBack: 'Go back', + contactSupport: 'Contact support', + altText: 'Page not found', + }, + // Permission required state permissionRequired: { title: 'Missing permission', @@ -57,6 +68,9 @@ export const scorecardMessages = { noDataFoundMessage: 'To see your data here, check that your entities are reporting values related to this metric.', authenticationErrorMessage: 'Please sign in to view your data.', + noMetricsFound: 'No metrics found for the specified metric ID.', + multipleMetricsFound: + 'Multiple metrics found for the specified metric ID. Expected exactly one.', }, // Metric translations @@ -71,6 +85,10 @@ export const scorecardMessages = { description: 'Highlights the number of critical, blocking issues that are currently open in Jira.', }, + lastUpdated: 'Last updated: {{timestamp}}', + lastUpdatedNotAvailable: 'Last updated: Not available', + someEntitiesNotReportingValues: + 'Some entities are not reporting values related to this metric.', }, // Threshold translations @@ -82,6 +100,36 @@ export const scorecardMessages = { entities_one: '{{count}} entity', entities_other: '{{count}} entities', }, + + // Entities page translations + entitiesPage: { + unknownMetric: 'Unknown metric', + noDataFound: + 'To see your data here, check that your entities are reporting values related to this metric.', + missingPermission: + 'To view the scorecard metrics, your administrator must grant you the required permission.', + metricProviderNotRegistered: + 'Metric provider with ID {{metricId}} is not registered.', + entitiesTable: { + title: 'Entities', + unavailable: 'Unavailable', + titleWithCount: 'Entities ({{count}})', + header: { + metric: 'Metric', + value: 'Value', + entity: 'Entity', + owner: 'Owner', + kind: 'Kind', + lastUpdated: 'Last updated', + }, + footer: { + allRows: 'All rows', + rows_one: '{{count}} row', + rows_other: '{{count}} rows', + of: 'of', + }, + }, + }, }; /** diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx new file mode 100644 index 00000000000..a69c5213027 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getLastUpdatedLabel } from '../entityTableUtils'; + +describe('entityTableUtils', () => { + describe('getLastUpdatedLabel', () => { + // Mock time: 2026-03-10T10:00:00Z + const mockToday = new Date('2026-03-10T10:00:00Z'); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockToday); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + // --- Falsy / invalid input --- + + it('should return "--" for falsy timestamp', () => { + expect(getLastUpdatedLabel('')).toBe('--'); + expect(getLastUpdatedLabel(null as unknown as string)).toBe('--'); + expect(getLastUpdatedLabel(undefined as unknown as string)).toBe('--'); + }); + + it('should return "--" for invalid date string', () => { + expect(getLastUpdatedLabel('not-a-date')).toBe('--'); + expect(getLastUpdatedLabel('Invalid Date')).toBe('--'); + expect(getLastUpdatedLabel(NaN)).toBe('--'); + }); + + // --- Less than 1 minute --- + + it('should return "1 minute ago" for a date less than 1 minute ago', () => { + // 30 seconds ago → minutesDiff = 0, falls into < 1 branch + const result = getLastUpdatedLabel('2026-03-10T09:59:30Z'); + expect(result).toBe('1 minute ago'); + }); + + // --- Minutes (1–59) --- + + it('should return "1 minute ago" for exactly 1 minute ago', () => { + const result = getLastUpdatedLabel('2026-03-10T09:59:00Z'); + expect(result).toBe('1 minute ago'); + }); + + it('should return "30 minutes ago" for 30 minutes ago', () => { + const result = getLastUpdatedLabel('2026-03-10T09:30:00Z'); + expect(result).toBe('30 minutes ago'); + }); + + it('should return "59 minutes ago" for 59 minutes ago (boundary before hours)', () => { + const result = getLastUpdatedLabel('2026-03-10T09:01:00Z'); + expect(result).toBe('59 minutes ago'); + }); + + // --- Hours (1–23) --- + + it('should return "1 hour ago" for exactly 1 hour ago', () => { + const result = getLastUpdatedLabel('2026-03-10T09:00:00Z'); + expect(result).toBe('1 hour ago'); + }); + + it('should return "5 hours ago" for 5 hours ago', () => { + const result = getLastUpdatedLabel('2026-03-10T05:00:00Z'); + expect(result).toBe('5 hours ago'); + }); + + it('should return "23 hours ago" for 23 hours ago (boundary before yesterday)', () => { + // 23 hours before mock time → still within same-day hour range (hoursDiff < 24) + const result = getLastUpdatedLabel('2026-03-09T11:00:00Z'); + expect(result).toBe('23 hours ago'); + }); + + // --- Yesterday --- + + it('should return "yesterday" for exactly 24 hours ago (yesterday)', () => { + const result = getLastUpdatedLabel('2026-03-09T10:00:00Z'); + expect(result).toBe('yesterday'); + }); + + it('should handle Date object input and return "yesterday"', () => { + const result = getLastUpdatedLabel(new Date('2026-03-09T10:00:00Z')); + expect(result).toBe('yesterday'); + }); + + it('should handle numeric timestamp input and return "yesterday"', () => { + const result = getLastUpdatedLabel( + new Date('2026-03-09T10:00:00Z').getTime(), + ); + expect(result).toBe('yesterday'); + }); + + // --- N days ago (2–6) --- + + it('should return "2 days ago" for 2 days ago', () => { + const result = getLastUpdatedLabel('2026-03-08T10:00:00Z'); + expect(result).toBe('2 days ago'); + }); + + it('should return "3 days ago" for 3 days ago', () => { + const result = getLastUpdatedLabel('2026-03-07T10:00:00Z'); + expect(result).toBe('3 days ago'); + }); + + it('should return "6 days ago" for exactly 6 days ago (upper boundary)', () => { + const result = getLastUpdatedLabel('2026-03-04T10:00:00Z'); + expect(result).toBe('6 days ago'); + }); + + // --- Formatted calendar date (7+ days) --- + + it('should return a formatted date for 7 days ago (crosses 6-day threshold)', () => { + const result = getLastUpdatedLabel('2026-03-03T10:00:00Z'); + expect(result).toBe('Mar 03, 2026'); + }); + + it('should return a formatted date for dates older than 7 days', () => { + const result = getLastUpdatedLabel('2026-03-01T10:00:00Z'); + expect(result).toBe('Mar 01, 2026'); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts b/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts index 77c7d4230b5..efa339164fb 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/constants.ts @@ -18,3 +18,49 @@ * Color used when there's an error with scorecard data (e.g., threshold evaluation error, metric fetch error) */ export const SCORECARD_ERROR_STATE_COLOR = 'rhdh.general.cardBorderColor'; + +interface HeadCell { + id: string; + label: string; + width: string; + sortable: boolean; +} + +export const SCORECARD_ENTITIES_TABLE_HEADERS: readonly HeadCell[] = [ + { + id: 'status', + label: 'entitiesPage.entitiesTable.header.metric', + width: '12%', + sortable: true, + }, + { + id: 'metricValue', + label: 'entitiesPage.entitiesTable.header.value', + width: '8%', + sortable: false, + }, + { + id: 'entityName', + label: 'entitiesPage.entitiesTable.header.entity', + width: '28%', + sortable: false, + }, + { + id: 'owner', + label: 'entitiesPage.entitiesTable.header.owner', + width: '20%', + sortable: false, + }, + { + id: 'entityKind', + label: 'entitiesPage.entitiesTable.header.kind', + width: '12%', + sortable: false, + }, + { + id: 'timestamp', + label: 'entitiesPage.entitiesTable.header.lastUpdated', + width: '20%', + sortable: false, + }, +]; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts new file mode 100644 index 00000000000..0bb1c1f6e3d --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts @@ -0,0 +1,100 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + differenceInCalendarDays, + isValid, + differenceInMinutes, + differenceInHours, + isYesterday, +} from 'date-fns'; + +export const formatDate = ( + date: Date, + options: Intl.DateTimeFormatOptions = {}, + locale?: string, +) => { + const currentLocale = locale || 'en'; + const currentTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + return new Intl.DateTimeFormat(currentLocale, { + timeZone: currentTimeZone, + ...options, + }).format(date); +}; + +export const formatRelativeTime = ( + value: number, + unit: Intl.RelativeTimeFormatUnit, + locale?: string, +) => { + const currentLocale = locale || 'en'; + + const rtf = new Intl.RelativeTimeFormat(currentLocale, { + numeric: 'auto', + }); + + return rtf.format(value, unit); +}; + +export function getLastUpdatedLabel( + timestamp: string | number | Date, + locale?: string, +) { + if (!timestamp) return '--'; + + const date = new Date(timestamp); + if (!isValid(date)) return '--'; + + const now = new Date(); + + const minutesDiff = differenceInMinutes(now, date); + + // < 1 minute → 1 minute ago + if (minutesDiff < 1) { + return formatRelativeTime(-1, 'minute', locale); + } + + // 1 - 59 minutes → N minutes ago + if (minutesDiff < 60) { + return formatRelativeTime(-minutesDiff, 'minute', locale); + } + + const hoursDiff = differenceInHours(now, date); + + // 1 – 24 hours → N hours ago + if (hoursDiff < 24) { + return formatRelativeTime(-hoursDiff, 'hour', locale); + } + + // Yesterday → yesterday + if (isYesterday(date)) { + return formatRelativeTime(-1, 'day', locale); + } + + const daysDiff = differenceInCalendarDays(now, date); + + // 2–6 days → N days ago + if (daysDiff <= 6) { + return formatRelativeTime(-daysDiff, 'day', locale); + } + + // 7+ days → formatted date + return formatDate( + date, + { year: 'numeric', month: 'short', day: '2-digit' }, + locale, + ); +} diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts index e31b9a24e0f..be95d51a8e8 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -19,5 +19,9 @@ export { getYOffsetForCenterLabel, } from './chartLabelUtils'; export { getThresholdRuleColor, resolveStatusColor } from './colorUtils'; -export { SCORECARD_ERROR_STATE_COLOR } from './constants'; +export { + SCORECARD_ERROR_STATE_COLOR, + SCORECARD_ENTITIES_TABLE_HEADERS, +} from './constants'; export { getStatusConfig } from './statusUtils'; +export { getLastUpdatedLabel } from './entityTableUtils'; diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 0e8bb5b378a..1cc8c6eed06 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -11520,6 +11520,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0" "@testing-library/user-event": "npm:^14.0.0" + date-fns: "npm:^4.1.0" msw: "npm:^1.0.0" react: "npm:^16.13.1 || ^17.0.0 || ^18.0.0" react-dom: "npm:^16.13.1 || ^17.0.0 || ^18.0.0" @@ -19255,6 +19256,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8 + languageName: node + linkType: hard + "date-format@npm:^4.0.14": version: 4.0.14 resolution: "date-format@npm:4.0.14"