From d6dcfcebbe682229c593a0216bedf6d794b0a97e Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Wed, 11 Mar 2026 09:16:36 +0530 Subject: [PATCH 01/12] feat(scorecard): add entities page for drilling down aggregated KPIs --- .../scorecard/.changeset/modern-signs-jog.md | 5 + .../scorecard/plugins/scorecard/package.json | 1 + .../plugins/scorecard/report-alpha.api.md | 21 ++ .../scorecard/plugins/scorecard/report.api.md | 28 ++ .../plugins/scorecard/src/api/index.ts | 138 ++++++++ .../src/components/Common/CardWrapper.tsx | 37 ++- .../EntitiesPageHeader.tsx | 21 ++ .../EntitiesTable/EntitiesRow.tsx | 73 +++++ .../EntitiesTable/EntitiesTable.tsx | 184 +++++++++++ .../EntitiesTable/EntitiesTableFooter.tsx | 149 +++++++++ .../EntitiesTable/EntitiesTableHeader.tsx | 59 ++++ .../EntitiesTable/EntitiesTablePagination.tsx | 103 ++++++ .../EntitiesTable/EntitiesTableStateRow.tsx | 83 +++++ .../EntitiesTable/EntitiesTableWrapper.tsx | 77 +++++ .../__tests__/EntitiesRow.test.tsx | 150 +++++++++ .../__tests__/EntitiesTable.test.tsx | 302 ++++++++++++++++++ .../__tests__/EntitiesTableFooter.test.tsx | 78 +++++ .../__tests__/EntitiesTableHeader.test.tsx | 93 ++++++ .../EntitiesTablePagination.test.tsx | 178 +++++++++++ .../__tests__/EntitiesTableStateRow.test.tsx | 146 +++++++++ .../__tests__/EntitiesTableWrapper.test.tsx | 68 ++++ .../EntitiesTable/cells/EntityNameCell.tsx | 66 ++++ .../EntitiesTable/cells/MetricStatusCell.tsx | 38 +++ .../EntitiesTable/cells/OwnerCell.tsx | 62 ++++ .../cells/__tests__/EntityNameCell.test.tsx | 120 +++++++ .../cells/__tests__/MetricStatusCell.test.tsx | 73 +++++ .../cells/__tests__/OwnerCell.test.tsx | 105 ++++++ .../ScorecardEntitiesPage.tsx | 95 ++++++ .../__tests__/ScorecardEntitiesPage.test.tsx | 168 ++++++++++ .../components/ScorecardEntitiesPage/index.ts | 17 + .../EmptyStatePanel.tsx | 8 +- .../ErrorStatePanel.tsx | 5 + .../ScorecardHomepageCard.tsx | 21 +- .../ScorecardHomepageCardComponent.tsx | 70 +++- .../__tests__/ScorecardHomepageCard.test.tsx | 34 +- .../ScorecardHomepageSection/index.ts | 1 + .../src/hooks/__tests__/useMetric.test.tsx | 128 ++++++++ .../__tests__/useMetricDisplayLabels.test.tsx | 98 ++++++ .../hooks/useAggregatedScorecardEntities.tsx | 99 ++++++ .../src/hooks/useEntityMetadataMap.ts | 124 +++++++ .../plugins/scorecard/src/hooks/useMetric.tsx | 67 ++++ .../src/hooks/useMetricDisplayLabels.tsx | 45 +++ .../src/hooks/useOwnershipEntityRefs.ts | 31 ++ .../scorecard/plugins/scorecard/src/plugin.ts | 15 + .../plugins/scorecard/src/translations/de.ts | 61 ++++ .../plugins/scorecard/src/translations/es.ts | 60 ++++ .../plugins/scorecard/src/translations/fr.ts | 37 +++ .../plugins/scorecard/src/translations/it.ts | 38 +++ .../plugins/scorecard/src/translations/ja.ts | 38 +++ .../plugins/scorecard/src/translations/ref.ts | 36 +++ .../utils/__tests__/entityTableUtils.test.tsx | 64 ++++ .../plugins/scorecard/src/utils/constants.ts | 46 +++ .../scorecard/src/utils/entityTableUtils.ts | 34 ++ .../plugins/scorecard/src/utils/index.ts | 6 +- workspaces/scorecard/yarn.lock | 8 + 55 files changed, 3877 insertions(+), 35 deletions(-) create mode 100644 workspaces/scorecard/.changeset/modern-signs-jog.md create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesPageHeader.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableHeader.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableWrapper.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesRow.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/index.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetric.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useAggregatedScorecardEntities.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useEntityMetadataMap.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useMetric.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useOwnershipEntityRefs.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts 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/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..54a3a1822ab 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -158,16 +158,37 @@ 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.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.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; + readonly 'entitiesPage.entitiesTableFooter.of': string; + readonly 'entitiesPage.entitiesTableFooter.allRows': string; + readonly 'entitiesPage.entitiesTableFooter.rows_one': string; + readonly 'entitiesPage.entitiesTableFooter.rows_other': string; } >; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 28d422b8fda..fb6c47ec868 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -11,11 +11,18 @@ import { TranslationResource } from '@backstage/frontend-plugin-api'; // @public export const EntityScorecardContent: () => JSX_2.Element; +// @public +export const ScorecardEntitiesPage: () => JSX_2.Element; + // @public export const ScorecardHomepageCard: ({ metricId, + showSubheader, + showInfo, }: { metricId: string; + showSubheader?: boolean | undefined; + showInfo?: boolean | undefined; }) => JSX_2.Element | null; // @public @@ -45,16 +52,37 @@ 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.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.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; + readonly 'entitiesPage.entitiesTableFooter.of': string; + readonly 'entitiesPage.entitiesTableFooter.allRows': string; + readonly 'entitiesPage.entitiesTableFooter.rows_one': string; + readonly 'entitiesPage.entitiesTableFooter.rows_other': string; } >; diff --git a/workspaces/scorecard/plugins/scorecard/src/api/index.ts b/workspaces/scorecard/plugins/scorecard/src/api/index.ts index fd28399f02c..52a81abf061 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/index.ts @@ -23,6 +23,8 @@ import type { Entity } from '@backstage/catalog-model'; import type { MetricResult, AggregatedMetricResult, + Metric, + EntityMetricDetailResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; export interface ScorecardApi { @@ -34,6 +36,32 @@ 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 metricId - The ID of the metric to get aggregated entities for + * @param page - The page number to retrieve + * @param pageSize - The number of entities per page + * @param ownershipEntityRefs - Optional array of ownership entity refs to filter entities by + * @param orderBy - Optional column to sort by + * @param order - Optional sort order + * @returns Promise resolving to an aggregated scorecard entities result + * @throws Error if the request fails or returns invalid data + */ + getAggregatedScorecardEntities(options: { + metricId: string; + page: number; + pageSize: number; + ownershipEntityRefs?: string[]; + orderBy?: string | null; + order?: 'asc' | 'desc'; + }): Promise; } export const scorecardApiRef = createApiRef({ @@ -159,4 +187,114 @@ 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 as any).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: { + metricId: string; + page: number; + pageSize: number; + ownershipEntityRefs?: string[]; + orderBy?: string | null; + order?: 'asc' | 'desc'; + }): 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?page=${page}&pageSize=${pageSize}`, + ); + 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..6eed0b25706 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 ( - + + + {info && ( + {info} + )} + { + return ; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx new file mode 100644 index 00000000000..9b8b8628e56 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.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 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 { MetricStatusCell } from './cells/MetricStatusCell'; +import { OwnerCell } from './cells/OwnerCell'; +import { EntityNameCell } from './cells/EntityNameCell'; + +export const EntitiesRow = ({ + entity, + entityMetadataMap, +}: { + entity: any; + entityMetadataMap: any; +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + + return ( + + `1px solid ${muiTheme.palette.grey[300]}`, + }} + > + + + + + + {entity.metricValue || entity.metricValue === 0 + ? entity.metricValue + : t('entitiesPage.entitiesTable.unavailable')} + + + + + + + + + + + {entity.entityKind} + + {getLastUpdatedLabel(entity.timestamp)} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx new file mode 100644 index 00000000000..3648d6f6293 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx @@ -0,0 +1,184 @@ +/* + * 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 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; +} + +export const EntitiesTable = ({ + metricId, + setMetricTitle, +}: 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, + }); + + 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: any) => ( + + ))} + + + + + + setPage(newPage)} + handleChangeRowsPerPage={handleChangeRowsPerPage} + /> + + + +
+
+ ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx new file mode 100644 index 00000000000..590aecb3839 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx @@ -0,0 +1,149 @@ +/* + * 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, +) => { + const defaultOptions = [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.entitiesTableFooter.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.entitiesTableFooter.rows_other', { + count: value.toString(), + }), + value, + })); + options.push({ + label: t('entitiesPage.entitiesTableFooter.allRows'), + value: totalCount, + }); + return options; + } + + if (validDefaults.length > 0) { + return validDefaults.map(value => ({ + label: t('entitiesPage.entitiesTableFooter.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); + + return ( + + `1px solid ${theme.palette.grey[300]}`, + overflow: 'hidden', + }, + }, + }, + }, + }} + /> + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableHeader.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableHeader.tsx new file mode 100644 index 00000000000..6a4dde4a530 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableHeader.tsx @@ -0,0 +1,59 @@ +/* + * 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 { 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)} + > + {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/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx new file mode 100644 index 00000000000..47ec59b4110 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx @@ -0,0 +1,103 @@ +/* + * 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; + handleChangePage: ( + event: MouseEvent, + newPage: number, + ) => void; +} + +export const EntitiesTablePagination = (props: any) => { + const theme = useTheme(); + 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 ( + + + {theme.direction === 'rtl' ? : } + + + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + {count === 0 ? 0 : (page - 1) * rowsPerPage + 1}- + {Math.min(page * rowsPerPage, count)}{' '} + {t('entitiesPage.entitiesTableFooter.of')} {count} + = Math.ceil(count / rowsPerPage)} + aria-label="next page" + > + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage)} + aria-label="last page" + > + {theme.direction === 'rtl' ? : } + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx new file mode 100644 index 00000000000..59fbafabe08 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx @@ -0,0 +1,83 @@ +/* + * 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 { WarningPanel } from '@backstage/core-components'; + +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; + const isNotFound = error?.message?.includes('NotFoundError'); + + let content = null; + if (isMissingPermission) { + content = t('entitiesPage.missingPermission'); + } else if (noEntitiesFound) { + content = t('entitiesPage.noDataFound'); + } else if (isNotFound) { + content = ( + + ); + } + + return ( + + + {content} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableWrapper.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableWrapper.tsx new file mode 100644 index 00000000000..9161a85585a --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableWrapper.tsx @@ -0,0 +1,77 @@ +/* + * 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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesRow.test.tsx new file mode 100644 index 00000000000..bc50639c042 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesRow.test.tsx @@ -0,0 +1,150 @@ +/* + * 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('../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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx new file mode 100644 index 00000000000..a1f3343ec36 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx @@ -0,0 +1,302 @@ +/* + * 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 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, + }); + }); + + 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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx new file mode 100644 index 00000000000..c043824a150 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx new file mode 100644 index 00000000000..85bd12d0da5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx new file mode 100644 index 00000000000..dda0c12a1f6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx new file mode 100644 index 00000000000..8ebd02f6379 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx @@ -0,0 +1,146 @@ +/* + * 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 render WarningPanel when error contains NotFoundError', () => { + render( + + + , + ); + + expect(screen.getByTestId('warning-panel')).toBeInTheDocument(); + expect(screen.getByTestId('warning-title')).toHaveTextContent( + 'Metric provider unknown.metric not registered', + ); + expect(screen.getByTestId('warning-message')).toHaveTextContent( + 'NotFoundError: Metric not found', + ); + }); + + 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/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx new file mode 100644 index 00000000000..67fcfa86aa9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx new file mode 100644 index 00000000000..d3918c8fb62 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx @@ -0,0 +1,66 @@ +/* + * 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/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx new file mode 100644 index 00000000000..49d09a5ca1c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx @@ -0,0 +1,38 @@ +/* + * 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; theme: any }) => { + return ( + + + {status ? status : '--'} + + ); + }, +); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx new file mode 100644 index 00000000000..e5cc7966739 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx @@ -0,0 +1,62 @@ +/* + * 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 } 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'; + +function resolveOwnerRef(owner: string) { + if (!owner) return ''; + if (owner.includes(':')) return owner; + return `group:default/${owner}`; +} + +export const OwnerCell = memo(({ ownerRef }: { ownerRef?: string }) => { + const entityLink = useRouteRef(entityRouteRef); + + const resolved = resolveOwnerRef(ownerRef ?? ''); + const { primaryTitle, secondaryTitle } = useEntityPresentation(resolved); + + if (!ownerRef) return <>--; + + const parsed = parseEntityRef(resolved); + const link = entityLink(parsed); + + return ( + + + {primaryTitle} + + + ); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx new file mode 100644 index 00000000000..42510dd6763 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx new file mode 100644 index 00000000000..6f4c4a25a27 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx new file mode 100644 index 00000000000..a3c2a1af1c9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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', () => ({ + parseEntityRef: jest.requireActual('@backstage/catalog-model').parseEntityRef, +})); + +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/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx new file mode 100644 index 00000000000..43fd0ebee06 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.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 { 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 { useTranslation } from '../../hooks/useTranslation'; + +import { EntitiesPageHeader } from './EntitiesPageHeader'; +import { EntitiesTable } from './EntitiesTable/EntitiesTable'; + +export const ScorecardEntitiesPage = () => { + const { metricId } = useParams<{ metricId?: string }>(); + + const [metricTitle, setMetricTitle] = useState(''); + + const { t } = useTranslation(); + + const titleKey = `metric.${metricId}.title`; + const title = t(titleKey as any, {}); + const finalTitle = title === titleKey ? metricTitle : title; + + 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/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx new file mode 100644 index 00000000000..4e02891da94 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx @@ -0,0 +1,168 @@ +/* + * 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 { ScorecardEntitiesPage } from '../ScorecardEntitiesPage'; + +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 mockEntitiesPageHeader = jest.fn(); +jest.mock('../EntitiesPageHeader', () => ({ + EntitiesPageHeader: (props: { title: string }) => { + mockEntitiesPageHeader(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('ScorecardEntitiesPage', () => { + 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('entities-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(mockEntitiesPageHeader).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(mockEntitiesPageHeader).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(mockEntitiesPageHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ title: 'github.open_prs' }), + ); + + act(() => { + screen.getByRole('button', { name: 'Set title' }).click(); + }); + + expect(mockEntitiesPageHeader).toHaveBeenLastCalledWith( + expect.objectContaining({ title: 'Metric Title from Table' }), + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/index.ts new file mode 100644 index 00000000000..15c9a830e16 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/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 { ScorecardEntitiesPage } from './ScorecardEntitiesPage'; 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..f899bcba7bc 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 MuiTooltip 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,62 @@ import { getThresholdRuleColor, resolveStatusColor, SCORECARD_ERROR_STATE_COLOR, + getLastUpdatedLabel, } from '../../utils'; import { useTranslation } from '../../hooks/useTranslation'; import { ResponsivePieChart } from './ResponsivePieChart'; +const InfoComponent = ({ timestamp }: { timestamp: string }) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const lastUpdatedLabel = getLastUpdatedLabel(timestamp); + + return ( + + + {t('metric.lastUpdated' as any, { timestamp: lastUpdatedLabel })} + + } + 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 +113,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/ScorecardHomepageSection/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts index f00af6c56e0..0a0e248f2e4 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts @@ -15,3 +15,4 @@ */ export { ScorecardHomepageCard } from './ScorecardHomepageCard'; +export { ScorecardEntitiesPage } from '../ScorecardEntitiesPage'; 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..459e17e266f --- /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 => { + void fn(); + 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..7a0135ac3ba --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useEntityMetadataMap.ts @@ -0,0 +1,124 @@ +/* + * 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'; + +type EntityMetadata = { + title?: string; + description?: string; + kind?: string; +}; + +type EntityMetadataMap = Record; + +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; +}; + +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(() => { + return refs + .map(ref => { + try { + const { kind, namespace, name } = parseEntityRef(ref); + return { + kind, + 'metadata.name': name, + 'metadata.namespace': namespace ?? 'default', + }; + } catch (error) { + return null; + } + }) + .filter(Boolean) as Array<{ + kind: string; + 'metadata.name': string; + 'metadata.namespace': string; + }>; + }, [refs]); + + useEffect(() => { + let cancelled = false; + + if (refFilters.length === 0) { + setEntityMetadataMap({}); + 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) => { + const entityRef = stringifyEntityRef(entity); + nextMap[entityRef] = { + title: entity?.metadata?.title?.trim(), + description: entity?.metadata?.description?.trim(), + kind: entity?.kind, + }; + }); + setEntityMetadataMap(nextMap); + } catch (error) { + if (!cancelled) { + setEntityMetadataMap({}); + } + } + }; + + fetchEntities(); + + return () => { + cancelled = true; + }; + }, [catalogApi, refFilters]); + + return { entityMetadataMap }; +}; 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/plugin.ts b/workspaces/scorecard/plugins/scorecard/src/plugin.ts index 1a968a44946..ee5d469f666 100644 --- a/workspaces/scorecard/plugins/scorecard/src/plugin.ts +++ b/workspaces/scorecard/plugins/scorecard/src/plugin.ts @@ -80,3 +80,18 @@ export const ScorecardHomepageCard = scorecardPlugin.provide( }, }), ); + +/** + * Scorecard entities page. + * @public + */ +export const ScorecardEntitiesPage = scorecardPlugin.provide( + createRoutableExtension({ + name: 'ScorecardEntitiesPage', + component: () => + import('./components/ScorecardHomepageSection').then( + m => m.ScorecardEntitiesPage, + ), + mountPoint: rootRouteRef, + }), +); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index d6f8b8ca254..a64bebc0758 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -24,22 +24,83 @@ 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', + + // 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.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.entitiesTableFooter.allRows': 'Alle Zeilen', + 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} Zeile', + 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} Zeilen', + 'entitiesPage.entitiesTableFooter.of': 'von', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index 7ffd2cf7021..52fe2a5234e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -24,24 +24,84 @@ 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', + + // 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.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.entitiesTableFooter.allRows': 'Todas las filas', + 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} fila', + 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} filas', + 'entitiesPage.entitiesTableFooter.of': 'de', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index be8598750df..e7bb6072490 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -24,16 +24,21 @@ 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', + + // Error messages 'errors.entityMissingProperties': "Entité manquant les propriétés requises pour la recherche dans la fiche d'évaluation", 'errors.invalidApiResponse': @@ -53,6 +58,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 +70,39 @@ 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.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.entitiesTableFooter.allRows': 'Toutes les lignes', + 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} ligne', + 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} lignes', + 'entitiesPage.entitiesTableFooter.of': 'de', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 9e779394b60..27a41aba0fe 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -24,16 +24,21 @@ 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', + + // Error messages 'errors.entityMissingProperties': 'Entità priva delle proprietà richieste per la ricerca nella scheda punteggio', 'errors.invalidApiResponse': @@ -53,18 +58,51 @@ 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.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.entitiesTableFooter.allRows': 'Tutte le righe', + 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} riga', + 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} righe', + 'entitiesPage.entitiesTableFooter.of': 'di', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index 2c2de8c7d22..daa7da7b1de 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -24,16 +24,21 @@ 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': '権限が必要', + + // Error messages 'errors.entityMissingProperties': 'スコアカードの検索に必要なプロパティーがエンティティーにありません', 'errors.invalidApiResponse': 'スコアカード API からの応答形式が無効です', @@ -52,6 +57,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 +70,39 @@ const scorecardTranslationJa = createTranslationMessages({ 'Jira のオープン状態の進行を妨げているチケット', 'metric.jira.open_issues.description': 'Jira で現在オープン状態になっている、重大かつ進行を妨げている課題の数を明示します。', + 'metric.lastUpdated': '最終更新日: {{timestamp}}', + '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.entitiesTableFooter.allRows': 'すべての行', + 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} 行', + 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} 行', + 'entitiesPage.entitiesTableFooter.of': 'の', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index 24fc1186905..492ec8871c6 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -57,6 +57,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 +74,9 @@ export const scorecardMessages = { description: 'Highlights the number of critical, blocking issues that are currently open in Jira.', }, + lastUpdated: 'Last updated: {{timestamp}}', + someEntitiesNotReportingValues: + 'Some entities are not reporting values related to this metric.', }, // Threshold translations @@ -82,6 +88,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', + }, + }, + entitiesTableFooter: { + 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..ebd602a8c87 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx @@ -0,0 +1,64 @@ +/* + * 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', () => { + const mockToday = new Date('2026-03-10T10:00:00Z'); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockToday); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should return "Today" if the date is today', () => { + const result = getLastUpdatedLabel('2026-03-10T08:00:00Z'); + expect(result).toBe('Today'); + }); + + it('should return "1 day ago" for yesterday', () => { + const result = getLastUpdatedLabel('2026-03-09T10:00:00Z'); + expect(result).toBe('1 day ago'); + }); + + it('should return "3 days ago" for dates within 6 days', () => { + const result = getLastUpdatedLabel('2026-03-07T10:00:00Z'); + expect(result).toBe('3 days ago'); + }); + + it('should return formatted date for dates older than 6 days', () => { + const result = getLastUpdatedLabel('2026-03-01T10:00:00Z'); + expect(result).toBe('01 Mar 2026'); + }); + + it('should handle Date object input', () => { + const result = getLastUpdatedLabel(new Date('2026-03-09T10:00:00Z')); + expect(result).toBe('1 day ago'); + }); + + it('should handle timestamp input', () => { + const result = getLastUpdatedLabel( + new Date('2026-03-09T10:00:00Z').getTime(), + ); + expect(result).toBe('1 day ago'); + }); + }); +}); 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..e34ff59b4a0 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts @@ -0,0 +1,34 @@ +/* + * 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 { isToday, differenceInCalendarDays, format } from 'date-fns'; + +export function getLastUpdatedLabel(timestamp: string | number | Date) { + const date = new Date(timestamp); + const today = new Date(); + + if (isToday(date)) { + return 'Today'; + } + + const diff = differenceInCalendarDays(today, date); + + if (diff <= 6) { + return `${diff} day${diff > 1 ? 's' : ''} ago`; + } + + return format(date, 'dd MMM yyyy'); +} 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" From 62fd6596889146050b8cf6eacfede4f40069c1e4 Mon Sep 17 00:00:00 2001 From: Husne Shabbir Date: Thu, 12 Mar 2026 10:38:42 +0530 Subject: [PATCH 02/12] fix(scorecard): improve e2e tests - ARIA snapshots, POM, translations, a11y (#4) - Use homePage.getCard() for ARIA snapshot assertions (scope to scorecard article) - Add getThresholdsSnapshot link with /url for drill-down; keep getMissingPermissionSnapshot without link - Add getLastUpdatedLabel and verifyLastUpdatedTooltip in HomePage (POM, translation-based) - Rename test to 'Verify threshold and last updated tooltips' - Filter button-name violations in accessibility helper for icon-only tooltip buttons Made-with: Cursor Co-authored-by: HusneShabbir --- .../app-legacy/e2e-tests/pages/HomePage.ts | 14 +++- .../app-legacy/e2e-tests/scorecard.test.ts | 14 ++-- .../e2e-tests/utils/accessibility.ts | 10 +-- .../e2e-tests/utils/translationUtils.ts | 15 +++- .../scorecard/plugins/scorecard/README.md | 13 ++++ .../aggregatedScorecardEntitiesData.ts | 59 ++++++++++++++++ .../plugins/scorecard/src/api/index.ts | 10 ++- .../EntitiesTable/EntitiesRow.tsx | 13 ++-- .../EntitiesTable/EntitiesTable.tsx | 4 +- .../EntitiesTable/EntitiesTableFooter.tsx | 5 +- .../EntitiesTable/EntitiesTableHeader.tsx | 2 + .../EntitiesTable/EntitiesTablePagination.tsx | 26 +++---- .../EntitiesTable/EntitiesTableStateRow.tsx | 2 +- .../EntitiesTable/cells/MetricStatusCell.tsx | 9 +-- .../EntitiesTable/cells/OwnerCell.tsx | 26 +++---- .../ScorecardEntitiesPage.tsx | 4 +- .../__tests__/ScorecardEntitiesPage.test.tsx | 12 +++- .../plugins/scorecard/src/components/types.ts | 8 +++ .../src/hooks/__tests__/useMetric.test.tsx | 2 +- .../src/hooks/useEntityMetadataMap.ts | 70 +++++++++---------- .../plugins/scorecard/src/hooks/useMetric.tsx | 8 +-- 21 files changed, 224 insertions(+), 102 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard/__fixtures__/aggregatedScorecardEntitiesData.ts 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..9b7caa793c9 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -196,7 +196,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 +204,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 +260,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 +268,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 +293,9 @@ 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 = '24 Jan 2026'; + await mockAggregatedScorecardResponse( page, githubAggregatedResponse, @@ -312,6 +314,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 +325,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..10897d24cb2 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,16 @@ export function getEntityCount( return evaluateMessage(key, count); } +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 +135,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/plugins/scorecard/README.md b/workspaces/scorecard/plugins/scorecard/README.md index dabccd4ac78..9436771b9a0 100644 --- a/workspaces/scorecard/plugins/scorecard/README.md +++ b/workspaces/scorecard/plugins/scorecard/README.md @@ -3,6 +3,19 @@ The Scorecard plugin provides a configurable framework to visualize Key Performance Indicators (KPIs) in Backstage. This frontend plugin integrates with the Scorecard backend to deliver Scorecards. The plugin supports both the **legacy** Backstage frontend and the **New Frontend System (NFS)**. Use the main package for legacy apps and the `/alpha` export for NFS apps. NFS supports only 1 module as of now (the catalog module that adds the Scorecard entity tab). +**Features:** + +- **Entity scorecard tab** — View scorecard metrics on catalog entity pages (components, websites, etc.). +- **Scorecard homepage card** — Show aggregated KPIs on the home page (e.g. GitHub open PRs, Jira open issues). +- **Scorecard Entities page** — Drill down from an aggregated metric to see the list of entities contributing to that metric, with entity-level values and status, so you can identify services impacting the KPI and investigate issues. + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/scorecard](http://localhost:3000/scorecard). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. ## For Administrators diff --git a/workspaces/scorecard/plugins/scorecard/__fixtures__/aggregatedScorecardEntitiesData.ts b/workspaces/scorecard/plugins/scorecard/__fixtures__/aggregatedScorecardEntitiesData.ts new file mode 100644 index 00000000000..c9276f1f996 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/__fixtures__/aggregatedScorecardEntitiesData.ts @@ -0,0 +1,59 @@ +/* + * 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 const mockAggregatedScorecardEntitiesData = ( + metricId: string, + page: number, + pageSize: number, +) => { + return { + metricId, + metricMetadata: { + title: 'Example Metric', + description: 'Example Metric Description', + type: 'number', + }, + entities: [ + { + entityRef: 'component:default/example-service', + entityName: 'example-service', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 10, + timestamp: '2025-01-01T10:00:00.000Z', + status: 'success', + }, + { + entityRef: 'component:default/example-service-2', + entityName: 'example-service-2', + entityNamespace: 'default', + entityKind: 'Component', + owner: 'group:default/platform', + metricValue: 20, + timestamp: '2025-01-01T10:00:00.000Z', + status: 'error', + }, + ], + pagination: { + page: page, + pageSize: pageSize, + total: 2, + totalPages: 1, + isCapped: false, + }, + }; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/api/index.ts b/workspaces/scorecard/plugins/scorecard/src/api/index.ts index 52a81abf061..d241f9d59ad 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/index.ts @@ -220,7 +220,7 @@ export class ScorecardApiClient implements ScorecardApi { Array.isArray(data) || typeof data !== 'object' || !('metrics' in data) || - !Array.isArray((data as any).metrics) + !Array.isArray(data.metrics) ) { throw new TypeError('Invalid response format from metrics API'); } @@ -257,8 +257,14 @@ export class ScorecardApiClient implements ScorecardApi { const baseUrl = await this.getBaseUrl(); const url = new URL( - `${baseUrl}/metrics/${metricId}/catalog/aggregations/entities?page=${page}&pageSize=${pageSize}`, + `${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); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx index 9b8b8628e56..bcc3cc273dc 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx @@ -14,12 +14,15 @@ * 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 { MetricStatusCell } from './cells/MetricStatusCell'; import { OwnerCell } from './cells/OwnerCell'; @@ -29,8 +32,8 @@ export const EntitiesRow = ({ entity, entityMetadataMap, }: { - entity: any; - entityMetadataMap: any; + entity: EntityMetricDetail; + entityMetadataMap: EntityMetadataMap; }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -45,7 +48,7 @@ export const EntitiesRow = ({ }} > - + @@ -67,7 +70,9 @@ export const EntitiesRow = ({ {entity.entityKind} - {getLastUpdatedLabel(entity.timestamp)} + + {entity.timestamp ? getLastUpdatedLabel(entity.timestamp) : '--'} + ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx index 3648d6f6293..433c3b73ae8 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx @@ -16,6 +16,8 @@ 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'; @@ -151,7 +153,7 @@ export const EntitiesTable = ({ {!loadingDataEntities && entities.length > 0 && - entities.map((entity: any) => ( + entities.map((entity: EntityMetricDetail) => ( string, + defaultOptions: number[] = [5, 10, 20], ) => { - const defaultOptions = [5, 10, 20]; - const maxDefaultOption = Math.max(...defaultOptions); if (defaultOptions.includes(totalCount)) { @@ -90,7 +89,7 @@ export const EntitiesTableFooter: FC = ({ }) => { const { t } = useTranslation(); - const rowsPerPageOptions = generateRowsPerPageOptions(count, t); + const rowsPerPageOptions = generateRowsPerPageOptions(count, t, [5, 10, 20]); return ( onSortRequest(header.id)} + IconComponent={KeyboardArrowUpIcon} > {t(header.label as any, { key: header.label })} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx index 47ec59b4110..e1e2e6df689 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx @@ -30,14 +30,14 @@ export interface EntitiesTablePaginationProps { count: number; page: number; rowsPerPage: number; - handleChangePage: ( - event: MouseEvent, - newPage: number, - ) => void; + onPageChange: (event: MouseEvent, newPage: number) => void; } -export const EntitiesTablePagination = (props: any) => { +export const EntitiesTablePagination = ( + props: EntitiesTablePaginationProps, +) => { const theme = useTheme(); + const isRtl = theme.direction === 'rtl'; const { count, page, rowsPerPage, onPageChange } = props; const { t } = useTranslation(); @@ -64,18 +64,14 @@ export const EntitiesTablePagination = (props: any) => { disabled={page === 1} aria-label="first page" > - {theme.direction === 'rtl' ? : } + {isRtl ? : } - {theme.direction === 'rtl' ? ( - - ) : ( - - )} + {isRtl ? : } {count === 0 ? 0 : (page - 1) * rowsPerPage + 1}- {Math.min(page * rowsPerPage, count)}{' '} @@ -85,18 +81,14 @@ export const EntitiesTablePagination = (props: any) => { disabled={page >= Math.ceil(count / rowsPerPage)} aria-label="next page" > - {theme.direction === 'rtl' ? ( - - ) : ( - - )} + {isRtl ? : } = Math.ceil(count / rowsPerPage)} aria-label="last page" > - {theme.direction === 'rtl' ? : } + {isRtl ? : } ); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx index 59fbafabe08..be9bdaf1fef 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx @@ -66,7 +66,7 @@ export const EntitiesTableStateRow = ({ diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx index 49d09a5ca1c..028e8f69e67 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx @@ -19,19 +19,20 @@ import { memo } from 'react'; import Box from '@mui/material/Box'; export const MetricStatusCell = memo( - ({ status, theme }: { status: string; theme: any }) => { + ({ status, theme }: { status: string | undefined; theme: any }) => { return ( - {status ? status : '--'} + {status || '--'} ); }, diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx index e5cc7966739..ff958e890c6 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx @@ -17,7 +17,7 @@ import { memo } from 'react'; import { Link } from '@backstage/core-components'; -import { parseEntityRef } from '@backstage/catalog-model'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; import { useRouteRef } from '@backstage/core-plugin-api'; import { entityRouteRef, @@ -26,25 +26,27 @@ import { import Tooltip from '@mui/material/Tooltip'; -function resolveOwnerRef(owner: string) { - if (!owner) return ''; - if (owner.includes(':')) return owner; - return `group:default/${owner}`; -} - export const OwnerCell = memo(({ ownerRef }: { ownerRef?: string }) => { const entityLink = useRouteRef(entityRouteRef); - const resolved = resolveOwnerRef(ownerRef ?? ''); - const { primaryTitle, secondaryTitle } = useEntityPresentation(resolved); + const parsedEntityRef = ownerRef + ? parseEntityRef(ownerRef, { + defaultKind: 'group', + defaultNamespace: 'default', + }) + : null; + const stringifiedEntityRef = parsedEntityRef + ? stringifyEntityRef(parsedEntityRef) + : ''; + const { primaryTitle, secondaryTitle } = + useEntityPresentation(stringifiedEntityRef); if (!ownerRef) return <>--; - const parsed = parseEntityRef(resolved); - const link = entityLink(parsed); + const link = entityLink(parsedEntityRef!); return ( - + { return ( diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx index 4e02891da94..bbf82721b7e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx @@ -58,7 +58,7 @@ jest.mock('../EntitiesTable/EntitiesTable', () => ({ }) => { mockEntitiesTable(props); return ( -
+
+ )} + {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/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx index 433c3b73ae8..04de65b283f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx @@ -40,11 +40,13 @@ 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); @@ -75,6 +77,11 @@ export const EntitiesTable = ({ order, }); + const isNotFound = entitiesError?.message?.includes('NotFoundError'); + if (isNotFound) { + setMetricNotFound?.(true); + } + useEffect(() => { setMetricTitle(aggregatedScorecardEntities?.metricMetadata?.title ?? ''); }, [aggregatedScorecardEntities?.metricMetadata?.title, setMetricTitle]); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx index 5631e18c5bf..8f97f049920 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableFooter.tsx @@ -34,7 +34,7 @@ const generateRowsPerPageOptions = ( if (defaultOptions.includes(totalCount)) { const validOptions = defaultOptions.filter(option => option <= totalCount); return validOptions.map(value => ({ - label: t('entitiesPage.entitiesTableFooter.rows_other', { + label: t('entitiesPage.entitiesTable.footer.rows_other', { count: value.toString(), }), value, @@ -45,13 +45,13 @@ const generateRowsPerPageOptions = ( if (validDefaults.length > 0 && totalCount <= maxDefaultOption) { const options = validDefaults.map(value => ({ - label: t('entitiesPage.entitiesTableFooter.rows_other', { + label: t('entitiesPage.entitiesTable.footer.rows_other', { count: value.toString(), }), value, })); options.push({ - label: t('entitiesPage.entitiesTableFooter.allRows'), + label: t('entitiesPage.entitiesTable.footer.allRows'), value: totalCount, }); return options; @@ -59,7 +59,7 @@ const generateRowsPerPageOptions = ( if (validDefaults.length > 0) { return validDefaults.map(value => ({ - label: t('entitiesPage.entitiesTableFooter.rows_other', { + label: t('entitiesPage.entitiesTable.footer.rows_other', { count: value.toString(), }), value, diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx index e1e2e6df689..64b7784604f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTablePagination.tsx @@ -75,7 +75,7 @@ export const EntitiesTablePagination = ( {count === 0 ? 0 : (page - 1) * rowsPerPage + 1}- {Math.min(page * rowsPerPage, count)}{' '} - {t('entitiesPage.entitiesTableFooter.of')} {count} + {t('entitiesPage.entitiesTable.footer.of')} {count} = Math.ceil(count / rowsPerPage)} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx index be9bdaf1fef..b712d7e429a 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTableStateRow.tsx @@ -16,8 +16,6 @@ import { useEffect } from 'react'; -import { WarningPanel } from '@backstage/core-components'; - import TableCell from '@mui/material/TableCell'; import TableRow from '@mui/material/TableRow'; @@ -54,23 +52,12 @@ export const EntitiesTableStateRow = ({ const isMissingPermission = error?.message?.includes('NotAllowedError'); const noEntitiesFound = !isMissingPermission && !error && noEntities; - const isNotFound = error?.message?.includes('NotFoundError'); let content = null; if (isMissingPermission) { content = t('entitiesPage.missingPermission'); } else if (noEntitiesFound) { content = t('entitiesPage.noDataFound'); - } else if (isNotFound) { - content = ( - - ); } return ( diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx index a1f3343ec36..d01d87b9caf 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx @@ -45,6 +45,12 @@ jest.mock('../../../../hooks/useAggregatedScorecardEntities', () => ({ 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[]) => @@ -139,6 +145,11 @@ describe('EntitiesTable', () => { loadingData: false, error: undefined, }); + mockUseAggregatedScorecard.mockReturnValue({ + aggregatedScorecard: { metadata: { title: 'Open PRs' } }, + loadingData: false, + error: undefined, + }); }); it('should render wrapper, header, table body, and footer', () => { diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx index 8ebd02f6379..b9a801b7f0e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx @@ -93,26 +93,6 @@ describe('EntitiesTableStateRow', () => { expect(screen.getByText('No data found')).toBeInTheDocument(); }); - it('should render WarningPanel when error contains NotFoundError', () => { - render( - - - , - ); - - expect(screen.getByTestId('warning-panel')).toBeInTheDocument(); - expect(screen.getByTestId('warning-title')).toHaveTextContent( - 'Metric provider unknown.metric not registered', - ); - expect(screen.getByTestId('warning-message')).toHaveTextContent( - 'NotFoundError: Metric not found', - ); - }); - it('should call setMetricTitle when metric title is resolved', () => { const setMetricTitle = jest.fn(); mockUseMetricDisplayLabels.mockReturnValue({ diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx index a3c2a1af1c9..c5299d03efa 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx @@ -39,9 +39,9 @@ jest.mock('@backstage/core-plugin-api', () => ({ useRouteRef: () => mockEntityLink, })); -jest.mock('@backstage/catalog-model', () => ({ - parseEntityRef: jest.requireActual('@backstage/catalog-model').parseEntityRef, -})); +jest.mock('@backstage/catalog-model', () => + jest.requireActual('@backstage/catalog-model'), +); const mockUseEntityPresentation = jest.fn(); jest.mock('@backstage/plugin-catalog-react', () => ({ diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx index fdd2c8dd78b..acbf45d51a4 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx @@ -23,6 +23,7 @@ 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 { EntitiesPageHeader } from './EntitiesPageHeader'; @@ -32,6 +33,7 @@ export const ScorecardEntitiesPage = () => { const { metricId } = useParams<{ metricId?: string }>(); const [metricTitle, setMetricTitle] = useState(''); + const [metricNotFound, setMetricNotFound] = useState(false); const { t } = useTranslation(); @@ -39,6 +41,16 @@ export const ScorecardEntitiesPage = () => { const title = t(titleKey as any, {}); const finalTitle = title === titleKey ? metricTitle : title; + if (metricNotFound) { + return ( + + + + + + ); + } + return ( { { - const { metricIds } = options; + const { metricId } = options; const { t } = useTranslation(); const scorecardApi = useApi(scorecardApiRef); @@ -36,7 +36,7 @@ export const useMetric = (options: UseMetricOptions) => { const { error, loading, value } = useAsync(async () => { try { const { metrics } = await scorecardApi.getMetrics({ - metricIds, + metricIds: [metricId], }); if (!Array.isArray(metrics) || metrics.length === 0) { @@ -54,7 +54,7 @@ export const useMetric = (options: UseMetricOptions) => { }), ); } - }, [scorecardApi, metricIds, t]); + }, [scorecardApi, metricId, t]); return useMemo( () => ({ 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/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index a64bebc0758..a2f95035b31 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -38,6 +38,15 @@ const scorecardTranslationDe = createTranslationMessages({ '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.', @@ -97,10 +106,10 @@ const scorecardTranslationDe = createTranslationMessages({ 'entitiesPage.entitiesTable.header.owner': 'Eigentümer', 'entitiesPage.entitiesTable.header.kind': 'Art', 'entitiesPage.entitiesTable.header.lastUpdated': 'Zuletzt aktualisiert', - 'entitiesPage.entitiesTableFooter.allRows': 'Alle Zeilen', - 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} Zeile', - 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} Zeilen', - 'entitiesPage.entitiesTableFooter.of': 'von', + '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 52fe2a5234e..708a2f10a38 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -38,6 +38,15 @@ const scorecardTranslationEs = createTranslationMessages({ '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', @@ -98,10 +107,10 @@ const scorecardTranslationEs = createTranslationMessages({ 'entitiesPage.entitiesTable.header.owner': 'Propietario', 'entitiesPage.entitiesTable.header.kind': 'Tipo', 'entitiesPage.entitiesTable.header.lastUpdated': 'Última actualización', - 'entitiesPage.entitiesTableFooter.allRows': 'Todas las filas', - 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} fila', - 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} filas', - 'entitiesPage.entitiesTableFooter.of': 'de', + '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 e7bb6072490..547416c4620 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -38,6 +38,15 @@ const scorecardTranslationFr = createTranslationMessages({ '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", @@ -99,10 +108,10 @@ const scorecardTranslationFr = createTranslationMessages({ 'entitiesPage.entitiesTable.header.owner': 'Propriétaire', 'entitiesPage.entitiesTable.header.kind': 'Type', 'entitiesPage.entitiesTable.header.lastUpdated': 'Dernière mise à jour', - 'entitiesPage.entitiesTableFooter.allRows': 'Toutes les lignes', - 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} ligne', - 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} lignes', - 'entitiesPage.entitiesTableFooter.of': 'de', + '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 27a41aba0fe..54ea08ace69 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -38,6 +38,15 @@ const scorecardTranslationIt = createTranslationMessages({ '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', @@ -99,10 +108,10 @@ const scorecardTranslationIt = createTranslationMessages({ 'entitiesPage.entitiesTable.header.owner': 'Proprietario', 'entitiesPage.entitiesTable.header.kind': 'Tipo', 'entitiesPage.entitiesTable.header.lastUpdated': 'Ultimo aggiornamento', - 'entitiesPage.entitiesTableFooter.allRows': 'Tutte le righe', - 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} riga', - 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} righe', - 'entitiesPage.entitiesTableFooter.of': 'di', + '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 daa7da7b1de..a1541c1c80a 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -38,6 +38,15 @@ const scorecardTranslationJa = createTranslationMessages({ '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': 'スコアカードの検索に必要なプロパティーがエンティティーにありません', @@ -99,10 +108,10 @@ const scorecardTranslationJa = createTranslationMessages({ 'entitiesPage.entitiesTable.header.owner': '所有者', 'entitiesPage.entitiesTable.header.kind': '種類', 'entitiesPage.entitiesTable.header.lastUpdated': '最終更新日', - 'entitiesPage.entitiesTableFooter.allRows': 'すべての行', - 'entitiesPage.entitiesTableFooter.rows_one': '{{count}} 行', - 'entitiesPage.entitiesTableFooter.rows_other': '{{count}} 行', - 'entitiesPage.entitiesTableFooter.of': 'の', + '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 492ec8871c6..bf127cfcd89 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', @@ -110,12 +121,12 @@ export const scorecardMessages = { kind: 'Kind', lastUpdated: 'Last updated', }, - }, - entitiesTableFooter: { - allRows: 'All rows', - rows_one: '{{count}} row', - rows_other: '{{count}} rows', - of: 'of', + 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 index ebd602a8c87..9c2ebe4406d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx @@ -60,5 +60,32 @@ describe('entityTableUtils', () => { ); expect(result).toBe('1 day ago'); }); + + 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', () => { + expect(getLastUpdatedLabel('not-a-date')).toBe('--'); + expect(getLastUpdatedLabel('Invalid Date')).toBe('--'); + expect(getLastUpdatedLabel(NaN)).toBe('--'); + }); + + it('should return "2 days ago" for two days ago (plural)', () => { + const result = getLastUpdatedLabel('2026-03-08T10:00:00Z'); + expect(result).toBe('2 days ago'); + }); + + it('should return "6 days ago" for exactly 6 days ago (boundary)', () => { + const result = getLastUpdatedLabel('2026-03-04T10:00:00Z'); + expect(result).toBe('6 days ago'); + }); + + it('should return formatted date for 7 days ago (beyond 6-day threshold)', () => { + const result = getLastUpdatedLabel('2026-03-03T10:00:00Z'); + expect(result).toBe('03 Mar 2026'); + }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts index e34ff59b4a0..7f3971b4b5e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts @@ -14,10 +14,15 @@ * limitations under the License. */ -import { isToday, differenceInCalendarDays, format } from 'date-fns'; +import { isToday, differenceInCalendarDays, format, isValid } from 'date-fns'; export function getLastUpdatedLabel(timestamp: string | number | Date) { + if (!timestamp) return '--'; + const date = new Date(timestamp); + + if (!isValid(date)) return '--'; + const today = new Date(); if (isToday(date)) { From cc51acd3ef0dcc125926c04f90465bb49d4c52fb Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Thu, 19 Mar 2026 22:07:38 +0530 Subject: [PATCH 04/12] address review comment --- .../scorecard/plugins/scorecard/report.api.md | 6 ++-- .../plugins/scorecard/src/api/index.ts | 31 +++++----------- .../src/components/Common/CardWrapper.tsx | 1 + .../ScorecardHomepageCardComponent.tsx | 10 +++--- .../ScorecardHomepageSection/index.ts | 1 - .../EntitiesTable/EntitiesRow.tsx | 0 .../EntitiesTable/EntitiesTable.tsx | 2 +- .../EntitiesTable/EntitiesTableFooter.tsx | 0 .../EntitiesTable/EntitiesTableHeader.tsx | 0 .../EntitiesTable/EntitiesTablePagination.tsx | 0 .../EntitiesTable/EntitiesTableStateRow.tsx | 0 .../EntitiesTable/EntitiesTableWrapper.tsx | 3 +- .../__tests__/EntitiesRow.test.tsx | 0 .../__tests__/EntitiesTable.test.tsx | 0 .../__tests__/EntitiesTableFooter.test.tsx | 0 .../__tests__/EntitiesTableHeader.test.tsx | 0 .../EntitiesTablePagination.test.tsx | 0 .../__tests__/EntitiesTableStateRow.test.tsx | 0 .../__tests__/EntitiesTableWrapper.test.tsx | 0 .../EntitiesTable/cells/EntityNameCell.tsx | 5 ++- .../EntitiesTable/cells/MetricStatusCell.tsx | 0 .../EntitiesTable/cells/OwnerCell.tsx | 5 ++- .../cells/__tests__/EntityNameCell.test.tsx | 0 .../cells/__tests__/MetricStatusCell.test.tsx | 0 .../cells/__tests__/OwnerCell.test.tsx | 0 .../ScorecardPage.tsx} | 6 ++-- .../ScorecardPageHeader.tsx} | 2 +- .../__tests__/ScorecardPage.test.tsx} | 36 +++++++++---------- .../index.ts | 2 +- .../plugins/scorecard/src/components/types.ts | 9 +++++ .../scorecard/src/pages/ScorecardPage.tsx | 17 +++++++++ .../scorecard/plugins/scorecard/src/plugin.ts | 11 +++--- 32 files changed, 77 insertions(+), 70 deletions(-) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesRow.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesTable.tsx (99%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesTableFooter.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesTableHeader.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesTablePagination.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesTableStateRow.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/EntitiesTableWrapper.tsx (97%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesRow.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesTable.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/cells/EntityNameCell.tsx (94%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/cells/MetricStatusCell.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/cells/OwnerCell.tsx (94%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/EntitiesTable/cells/__tests__/OwnerCell.test.tsx (100%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage/ScorecardEntitiesPage.tsx => ScorecardPage/ScorecardPage.tsx} (95%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage/EntitiesPageHeader.tsx => ScorecardPage/ScorecardPageHeader.tsx} (91%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx => ScorecardPage/__tests__/ScorecardPage.test.tsx} (80%) rename workspaces/scorecard/plugins/scorecard/src/components/{ScorecardEntitiesPage => ScorecardPage}/index.ts (90%) create mode 100644 workspaces/scorecard/plugins/scorecard/src/pages/ScorecardPage.tsx diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index ed6e9ca9e07..16af346b771 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -11,9 +11,6 @@ import { TranslationResource } from '@backstage/frontend-plugin-api'; // @public export const EntityScorecardContent: () => JSX_2.Element; -// @public -export const ScorecardEntitiesPage: () => JSX_2.Element; - // @public export const ScorecardHomepageCard: ({ metricId, @@ -25,6 +22,9 @@ export const ScorecardHomepageCard: ({ showInfo?: boolean | undefined; }) => JSX_2.Element | null; +// @public +export const ScorecardPage: () => JSX_2.Element; + // @public export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; diff --git a/workspaces/scorecard/plugins/scorecard/src/api/index.ts b/workspaces/scorecard/plugins/scorecard/src/api/index.ts index d241f9d59ad..1666466a780 100644 --- a/workspaces/scorecard/plugins/scorecard/src/api/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/api/index.ts @@ -27,6 +27,8 @@ import type { 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. @@ -45,23 +47,13 @@ export interface ScorecardApi { getMetrics(options: { metricIds: string[] }): Promise<{ metrics: Metric[] }>; /** * Retrieves aggregated scorecard entities. - * @param metricId - The ID of the metric to get aggregated entities for - * @param page - The page number to retrieve - * @param pageSize - The number of entities per page - * @param ownershipEntityRefs - Optional array of ownership entity refs to filter entities by - * @param orderBy - Optional column to sort by - * @param order - Optional sort order + * @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: { - metricId: string; - page: number; - pageSize: number; - ownershipEntityRefs?: string[]; - orderBy?: string | null; - order?: 'asc' | 'desc'; - }): Promise; + getAggregatedScorecardEntities( + options: GetAggregatedScorecardEntitiesOptions, + ): Promise; } export const scorecardApiRef = createApiRef({ @@ -234,14 +226,9 @@ export class ScorecardApiClient implements ScorecardApi { } } - async getAggregatedScorecardEntities(options: { - metricId: string; - page: number; - pageSize: number; - ownershipEntityRefs?: string[]; - orderBy?: string | null; - order?: 'asc' | 'desc'; - }): Promise { + async getAggregatedScorecardEntities( + options: GetAggregatedScorecardEntitiesOptions, + ): Promise { const { metricId, page, diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx index 6eed0b25706..dc8a4a92ae5 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Common/CardWrapper.tsx @@ -53,6 +53,7 @@ export const CardWrapper = ({ width: width ?? '100%', border: muiTheme => `1px solid ${muiTheme.palette.grey[300]}`, overflow: 'auto', + height: '100%', }} role={role} > diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx index f899bcba7bc..c732ed7d23a 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx @@ -21,7 +21,7 @@ import type { AggregatedMetricResult } from '@red-hat-developer-hub/backstage-pl import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; -import MuiTooltip from '@mui/material/Tooltip'; +import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; @@ -46,7 +46,7 @@ const InfoComponent = ({ timestamp }: { timestamp: string }) => { return ( - {t('metric.lastUpdated' as any, { timestamp: lastUpdatedLabel })} @@ -72,7 +72,7 @@ const InfoComponent = ({ timestamp }: { timestamp: string }) => { sx={{ color: theme.palette.text.secondary, fontSize: '1.75rem' }} /> - + ); }; @@ -116,7 +116,7 @@ export const ScorecardHomepageCardComponent = ({ {...(showSubheader ? { subheader: ( - {t('thresholds.entities', { count: scorecard.result.total })} - + ), } : {})} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts index 0a0e248f2e4..f00af6c56e0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/index.ts @@ -15,4 +15,3 @@ */ export { ScorecardHomepageCard } from './ScorecardHomepageCard'; -export { ScorecardEntitiesPage } from '../ScorecardEntitiesPage'; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesRow.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTable.tsx similarity index 99% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTable.tsx index 04de65b283f..50b2f5b54c0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/EntitiesTable.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesTable.tsx @@ -121,7 +121,7 @@ export const EntitiesTable = ({ return ( - +
= ({ }} > {title} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesRow.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesRow.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesRow.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTable.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTable.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTable.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableFooter.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTablePagination.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableStateRow.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableWrapper.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/EntityNameCell.tsx similarity index 94% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/EntityNameCell.tsx index d3918c8fb62..ad5b56a8939 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/EntityNameCell.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/EntityNameCell.tsx @@ -52,11 +52,10 @@ export const EntityNameCell = ({ {displayName} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/MetricStatusCell.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/MetricStatusCell.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/MetricStatusCell.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/OwnerCell.tsx similarity index 94% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/OwnerCell.tsx index ff958e890c6..1fce827e261 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/OwnerCell.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/OwnerCell.tsx @@ -50,11 +50,10 @@ export const OwnerCell = memo(({ ownerRef }: { ownerRef?: string }) => { {primaryTitle} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/EntityNameCell.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/MetricStatusCell.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx similarity index 100% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/cells/__tests__/OwnerCell.test.tsx diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPage.tsx similarity index 95% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPage.tsx index acbf45d51a4..d8cd0bde8f1 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/ScorecardEntitiesPage.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPage.tsx @@ -26,10 +26,10 @@ import { ScorecardHomepageCard } from '../ScorecardHomepageSection/ScorecardHome import NotFoundState from '../Common/NotFoundState'; import { useTranslation } from '../../hooks/useTranslation'; -import { EntitiesPageHeader } from './EntitiesPageHeader'; +import { ScorecardPageHeader } from './ScorecardPageHeader'; import { EntitiesTable } from './EntitiesTable/EntitiesTable'; -export const ScorecardEntitiesPage = () => { +export const ScorecardPage = () => { const { metricId } = useParams<{ metricId?: string }>(); const [metricTitle, setMetricTitle] = useState(''); @@ -53,7 +53,7 @@ export const ScorecardEntitiesPage = () => { return ( - diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesPageHeader.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPageHeader.tsx similarity index 91% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesPageHeader.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPageHeader.tsx index 634af1f38f0..d487a9e8ade 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/EntitiesPageHeader.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/ScorecardPageHeader.tsx @@ -16,6 +16,6 @@ import { Header as BackstageHeader } from '@backstage/core-components'; -export const EntitiesPageHeader = ({ title }: { title: string }) => { +export const ScorecardPageHeader = ({ title }: { title: string }) => { return ; }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/__tests__/ScorecardPage.test.tsx similarity index 80% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/__tests__/ScorecardPage.test.tsx index bbf82721b7e..3d2b4cb0c20 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/__tests__/ScorecardEntitiesPage.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/__tests__/ScorecardPage.test.tsx @@ -17,7 +17,7 @@ import { act, render, screen } from '@testing-library/react'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { ScorecardEntitiesPage } from '../ScorecardEntitiesPage'; +import { ScorecardPage } from '../ScorecardPage'; const mockUseParams = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -42,11 +42,11 @@ jest.mock('@backstage/core-components', () => ({ ), })); -const mockEntitiesPageHeader = jest.fn(); -jest.mock('../EntitiesPageHeader', () => ({ - EntitiesPageHeader: (props: { title: string }) => { - mockEntitiesPageHeader(props); - return
{props.title}
; +const mockScorecardPageHeader = jest.fn(); +jest.mock('../ScorecardPageHeader', () => ({ + ScorecardPageHeader: (props: { title: string }) => { + mockScorecardPageHeader(props); + return
{props.title}
; }, })); @@ -94,7 +94,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); -describe('ScorecardEntitiesPage', () => { +describe('ScorecardPage', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -102,11 +102,11 @@ describe('ScorecardEntitiesPage', () => { it('should render page structure with header, content, table and scorecard card', () => { mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); - render(, { wrapper: TestWrapper }); + render(, { wrapper: TestWrapper }); expect(screen.getByTestId('page')).toBeInTheDocument(); expect(screen.getByTestId('content')).toBeInTheDocument(); - expect(screen.getByTestId('entities-page-header')).toBeInTheDocument(); + expect(screen.getByTestId('scorecard-page-header')).toBeInTheDocument(); expect(screen.getByTestId('entities-table')).toBeInTheDocument(); expect(screen.getByTestId('scorecard-homepage-card')).toBeInTheDocument(); }); @@ -114,9 +114,9 @@ describe('ScorecardEntitiesPage', () => { it('should pass metricId to header when metricTitle is empty', () => { mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); - render(, { wrapper: TestWrapper }); + render(, { wrapper: TestWrapper }); - expect(mockEntitiesPageHeader).toHaveBeenCalledWith( + expect(mockScorecardPageHeader).toHaveBeenCalledWith( expect.objectContaining({ title: 'github.open_prs' }), ); }); @@ -124,9 +124,9 @@ describe('ScorecardEntitiesPage', () => { it('should show Unknown metric in header when metricId is undefined', () => { mockUseParams.mockReturnValue({ metricId: undefined }); - render(, { wrapper: TestWrapper }); + render(, { wrapper: TestWrapper }); - expect(mockEntitiesPageHeader).toHaveBeenCalledWith( + expect(mockScorecardPageHeader).toHaveBeenCalledWith( expect.objectContaining({ title: 'Unknown metric' }), ); }); @@ -134,7 +134,7 @@ describe('ScorecardEntitiesPage', () => { it('should pass metricId and setMetricTitle to EntitiesTable', () => { mockUseParams.mockReturnValue({ metricId: 'jira.blocking_tickets' }); - render(, { wrapper: TestWrapper }); + render(, { wrapper: TestWrapper }); expect(mockEntitiesTable).toHaveBeenCalledWith( expect.objectContaining({ @@ -147,7 +147,7 @@ describe('ScorecardEntitiesPage', () => { it('should pass metricId, showSubheader false, and showInfo false to ScorecardHomepageCard', () => { mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); - render(, { wrapper: TestWrapper }); + render(, { wrapper: TestWrapper }); expect(mockScorecardHomepageCard).toHaveBeenCalledWith({ metricId: 'github.open_prs', @@ -159,9 +159,9 @@ describe('ScorecardEntitiesPage', () => { it('should update header title when setMetricTitle is called from EntitiesTable', () => { mockUseParams.mockReturnValue({ metricId: 'github.open_prs' }); - render(, { wrapper: TestWrapper }); + render(, { wrapper: TestWrapper }); - expect(mockEntitiesPageHeader).toHaveBeenLastCalledWith( + expect(mockScorecardPageHeader).toHaveBeenLastCalledWith( expect.objectContaining({ title: 'github.open_prs' }), ); @@ -169,7 +169,7 @@ describe('ScorecardEntitiesPage', () => { screen.getByRole('button', { name: 'Set title' }).click(); }); - expect(mockEntitiesPageHeader).toHaveBeenLastCalledWith( + expect(mockScorecardPageHeader).toHaveBeenLastCalledWith( expect.objectContaining({ title: 'Metric Title from Table' }), ); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/index.ts similarity index 90% rename from workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/index.ts rename to workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/index.ts index 15c9a830e16..1c0a54720a6 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardEntitiesPage/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ScorecardEntitiesPage } from './ScorecardEntitiesPage'; +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 9f4c9b189b2..c5c52a10f27 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/types.ts +++ b/workspaces/scorecard/plugins/scorecard/src/components/types.ts @@ -27,3 +27,12 @@ export type EntityMetadata = { }; 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/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 ee5d469f666..deb460d9c74 100644 --- a/workspaces/scorecard/plugins/scorecard/src/plugin.ts +++ b/workspaces/scorecard/plugins/scorecard/src/plugin.ts @@ -82,16 +82,13 @@ export const ScorecardHomepageCard = scorecardPlugin.provide( ); /** - * Scorecard entities page. + * Scorecard page. * @public */ -export const ScorecardEntitiesPage = scorecardPlugin.provide( +export const ScorecardPage = scorecardPlugin.provide( createRoutableExtension({ - name: 'ScorecardEntitiesPage', - component: () => - import('./components/ScorecardHomepageSection').then( - m => m.ScorecardEntitiesPage, - ), + name: 'ScorecardPage', + component: () => import('./pages/ScorecardPage').then(m => m.ScorecardPage), mountPoint: rootRouteRef, }), ); From 1d14240dfa518ad3a56bb1615c3ff58915224a5f Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 09:04:31 +0530 Subject: [PATCH 05/12] add correct timestamp for last updated fields --- .../app-legacy/e2e-tests/scorecard.test.ts | 6 +- .../e2e-tests/utils/translationUtils.ts | 18 +++ .../scorecard/packages/app-legacy/src/App.tsx | 6 +- .../aggregatedScorecardEntitiesData.ts | 99 ++++++++++++++-- .../plugins/scorecard/dev/legacy.tsx | 31 +++++ .../scorecard/plugins/scorecard/dev/mocks.ts | 32 +++++ .../plugins/scorecard/report-alpha.api.md | 27 ++--- .../scorecard/plugins/scorecard/report.api.md | 23 ++-- .../ScorecardHomepageCardComponent.tsx | 8 +- .../EntitiesTable/EntitiesRow.tsx | 6 +- .../__tests__/EntitiesRow.test.tsx | 4 + .../scorecard/src/hooks/useLanguage.ts | 25 ++++ .../plugins/scorecard/src/translations/de.ts | 1 + .../plugins/scorecard/src/translations/es.ts | 1 + .../plugins/scorecard/src/translations/fr.ts | 1 + .../plugins/scorecard/src/translations/it.ts | 1 + .../plugins/scorecard/src/translations/ja.ts | 1 + .../plugins/scorecard/src/translations/ref.ts | 1 + .../utils/__tests__/entityTableUtils.test.tsx | 109 +++++++++++++----- .../scorecard/src/utils/entityTableUtils.ts | 81 +++++++++++-- 20 files changed, 401 insertions(+), 80 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard/src/hooks/useLanguage.ts 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 9b7caa793c9..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'; @@ -294,7 +295,10 @@ test.describe('Scorecard Plugin Tests', () => { }); test('Verify threshold and last updated tooltips', async () => { - const lastUpdatedFormatted = '24 Jan 2026'; + const lastUpdatedFormatted = formatLastUpdatedDate( + '2026-01-24T14:10:32.858Z', + currentLocale, + ); await mockAggregatedScorecardResponse( page, 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 10897d24cb2..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,24 @@ 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, 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: { @@ -27,31 +31,106 @@ export const mockAggregatedScorecardEntitiesData = ( type: 'number', }, entities: [ + // 1 minute ago { - entityRef: 'component:default/example-service', - entityName: 'example-service', + 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: '2025-01-01T10:00:00.000Z', + 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/example-service-2', - entityName: 'example-service-2', + entityRef: 'component:default/service-invalid', + entityName: 'service-invalid', entityNamespace: 'default', entityKind: 'Component', owner: 'group:default/platform', - metricValue: 20, - timestamp: '2025-01-01T10:00:00.000Z', + metricValue: 0, + timestamp: 'invalid-date', status: 'error', }, ], pagination: { - page: page, - pageSize: pageSize, - total: 2, + 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..d6983727670 100644 --- a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx +++ b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx @@ -26,16 +26,20 @@ import type { Entity } from '@backstage/catalog-model'; import type { MetricResult, AggregatedMetricResult, + Metric, + EntityMetricDetailResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { scorecardPlugin, EntityScorecardContent } 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'; const mockComponentEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -55,11 +59,38 @@ 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; + } } createDevApp() 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/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 126551d00e6..9d119312d76 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; @@ -138,9 +138,9 @@ export const scorecardEntityContent: OverridableExtensionDefinition<{ export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { - readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; + readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -148,9 +148,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -171,10 +171,11 @@ export const scorecardTranslationRef: TranslationRef< 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.error': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -182,19 +183,19 @@ export const scorecardTranslationRef: TranslationRef< 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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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; } >; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 16af346b771..fc2c6c05f48 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -32,9 +32,9 @@ export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { - readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; + readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -42,9 +42,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -65,10 +65,11 @@ export const scorecardTranslationRef: TranslationRef< 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.error': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -76,19 +77,19 @@ export const scorecardTranslationRef: TranslationRef< 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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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; } >; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx index c732ed7d23a..5d3e762cf50 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCardComponent.tsx @@ -37,19 +37,23 @@ import { } 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); + const lastUpdatedLabel = getLastUpdatedLabel(timestamp, locale); return ( - {t('metric.lastUpdated' as any, { timestamp: lastUpdatedLabel })} + {lastUpdatedLabel !== '--' + ? t('metric.lastUpdated' as any, { timestamp: lastUpdatedLabel }) + : t('metric.lastUpdatedNotAvailable')} } placement="top" diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx index bcc3cc273dc..0f23b2cfe1e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/EntitiesRow.tsx @@ -23,6 +23,7 @@ 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'; @@ -37,6 +38,7 @@ export const EntitiesRow = ({ }) => { const theme = useTheme(); const { t } = useTranslation(); + const locale = useLanguage(); return ( {entity.entityKind} - {entity.timestamp ? getLastUpdatedLabel(entity.timestamp) : '--'} + {entity.timestamp + ? getLastUpdatedLabel(entity.timestamp, locale) + : '--'} ); 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 index bc50639c042..51c7e93832c 100644 --- 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 @@ -26,6 +26,10 @@ jest.mock('../../../../hooks/useTranslation', () => ({ }), })); +jest.mock('../../../../hooks/useLanguage', () => ({ + useLanguage: () => 'en', +})); + jest.mock('../cells/MetricStatusCell', () => ({ MetricStatusCell: ({ status }: { status: string }) => ( {status} 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/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index a2f95035b31..139496e0079 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -78,6 +78,7 @@ const scorecardTranslationDe = createTranslationMessages({ '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.', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index 708a2f10a38..452a71c3694 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -79,6 +79,7 @@ const scorecardTranslationEs = createTranslationMessages({ '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.', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index 547416c4620..f944677f8d3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -80,6 +80,7 @@ const scorecardTranslationFr = createTranslationMessages({ '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.', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 54ea08ace69..ed3581ad31d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -80,6 +80,7 @@ const scorecardTranslationIt = createTranslationMessages({ '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.', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index a1541c1c80a..2034e1e6552 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -80,6 +80,7 @@ const scorecardTranslationJa = createTranslationMessages({ 'metric.jira.open_issues.description': 'Jira で現在オープン状態になっている、重大かつ進行を妨げている課題の数を明示します。', 'metric.lastUpdated': '最終更新日: {{timestamp}}', + 'metric.lastUpdatedNotAvailable': '最終更新日: 利用不可', 'metric.someEntitiesNotReportingValues': 'エンティティーがこの指標に関連する値を報告していません。', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index bf127cfcd89..378ae2cd4a3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -86,6 +86,7 @@ export const scorecardMessages = { '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.', }, diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx index 9c2ebe4406d..a69c5213027 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/entityTableUtils.test.tsx @@ -18,6 +18,7 @@ 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(() => { @@ -29,63 +30,109 @@ describe('entityTableUtils', () => { jest.useRealTimers(); }); - it('should return "Today" if the date is today', () => { - const result = getLastUpdatedLabel('2026-03-10T08:00:00Z'); - expect(result).toBe('Today'); + // --- 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 "1 day ago" for yesterday', () => { - const result = getLastUpdatedLabel('2026-03-09T10:00:00Z'); - expect(result).toBe('1 day ago'); + it('should return "--" for invalid date string', () => { + expect(getLastUpdatedLabel('not-a-date')).toBe('--'); + expect(getLastUpdatedLabel('Invalid Date')).toBe('--'); + expect(getLastUpdatedLabel(NaN)).toBe('--'); }); - it('should return "3 days ago" for dates within 6 days', () => { - const result = getLastUpdatedLabel('2026-03-07T10:00:00Z'); - expect(result).toBe('3 days ago'); + // --- 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'); }); - it('should return formatted date for dates older than 6 days', () => { - const result = getLastUpdatedLabel('2026-03-01T10:00:00Z'); - expect(result).toBe('01 Mar 2026'); + // --- 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'); }); - it('should handle Date object input', () => { + // --- 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('1 day ago'); + expect(result).toBe('yesterday'); }); - it('should handle timestamp input', () => { + it('should handle numeric timestamp input and return "yesterday"', () => { const result = getLastUpdatedLabel( new Date('2026-03-09T10:00:00Z').getTime(), ); - expect(result).toBe('1 day ago'); + expect(result).toBe('yesterday'); }); - 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('--'); - }); + // --- N days ago (2–6) --- - it('should return "--" for invalid date', () => { - expect(getLastUpdatedLabel('not-a-date')).toBe('--'); - expect(getLastUpdatedLabel('Invalid Date')).toBe('--'); - expect(getLastUpdatedLabel(NaN)).toBe('--'); - }); - - it('should return "2 days ago" for two days ago (plural)', () => { + 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 "6 days ago" for exactly 6 days ago (boundary)', () => { + 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'); }); - it('should return formatted date for 7 days ago (beyond 6-day threshold)', () => { + // --- 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('03 Mar 2026'); + 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/entityTableUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts index 7f3971b4b5e..0bb1c1f6e3d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/entityTableUtils.ts @@ -14,26 +14,87 @@ * limitations under the License. */ -import { isToday, differenceInCalendarDays, format, isValid } from 'date-fns'; +import { + differenceInCalendarDays, + isValid, + differenceInMinutes, + differenceInHours, + isYesterday, +} from 'date-fns'; -export function getLastUpdatedLabel(timestamp: string | number | Date) { +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 today = new Date(); + 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); + } - if (isToday(date)) { - return 'Today'; + // Yesterday → yesterday + if (isYesterday(date)) { + return formatRelativeTime(-1, 'day', locale); } - const diff = differenceInCalendarDays(today, date); + const daysDiff = differenceInCalendarDays(now, date); - if (diff <= 6) { - return `${diff} day${diff > 1 ? 's' : ''} ago`; + // 2–6 days → N days ago + if (daysDiff <= 6) { + return formatRelativeTime(-daysDiff, 'day', locale); } - return format(date, 'dd MMM yyyy'); + // 7+ days → formatted date + return formatDate( + date, + { year: 'numeric', month: 'short', day: '2-digit' }, + locale, + ); } From 259e71f7fe4b9ce8350ee4879f13a04252d74f35 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 10:06:27 +0530 Subject: [PATCH 06/12] fix api reports --- .../scorecard/plugins/scorecard/report.api.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index fc2c6c05f48..f6309692a44 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -32,9 +32,9 @@ export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { + readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; - readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -42,9 +42,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -68,8 +68,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; - readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.warning': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -77,19 +77,19 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.noDataFound': string; readonly 'entitiesPage.unknownMetric': string; readonly 'entitiesPage.metricProviderNotRegistered': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': 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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': string; } >; From c23162c4b62b3bf795ee881aa86183ad611a1860 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 10:11:43 +0530 Subject: [PATCH 07/12] fix api reports --- .../scorecard/plugins/scorecard/report.api.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index f6309692a44..fc2c6c05f48 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -32,9 +32,9 @@ export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { - readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; + readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -42,9 +42,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -68,8 +68,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; - readonly 'thresholds.error': string; readonly 'thresholds.warning': string; + readonly 'thresholds.error': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -77,19 +77,19 @@ export const scorecardTranslationRef: TranslationRef< 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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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; } >; From e17e9c8be677c3cb62e16392677c4f0eb75003c9 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 10:24:50 +0530 Subject: [PATCH 08/12] fix: update scorecard plugin API report to match CI-generated output Update key ordering in scorecardTranslationRef type within report.api.md to match what API Extractor generates in the CI environment. Made-with: Cursor --- .../scorecard/plugins/scorecard/report.api.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index fc2c6c05f48..f6309692a44 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -32,9 +32,9 @@ export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { + readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; - readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -42,9 +42,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -68,8 +68,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; - readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.warning': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -77,19 +77,19 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.noDataFound': string; readonly 'entitiesPage.unknownMetric': string; readonly 'entitiesPage.metricProviderNotRegistered': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': 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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': string; } >; From f99efc6074932534a5563afe6da59acf71ef51ee Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 10:34:23 +0530 Subject: [PATCH 09/12] fix api report --- .../scorecard/plugins/scorecard/report.api.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index f6309692a44..fc2c6c05f48 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -32,9 +32,9 @@ export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { - readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; + readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -42,9 +42,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -68,8 +68,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; - readonly 'thresholds.error': string; readonly 'thresholds.warning': string; + readonly 'thresholds.error': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -77,19 +77,19 @@ export const scorecardTranslationRef: TranslationRef< 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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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; } >; From 3f884cd01c87f7663689f0dd6db79fc9b08d2a44 Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 10:42:34 +0530 Subject: [PATCH 10/12] fix api report --- workspaces/scorecard/plugins/scorecard/report-alpha.api.md | 4 ++-- workspaces/scorecard/plugins/scorecard/report.api.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 9d119312d76..bcdcccb597b 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -174,8 +174,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; - readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.warning': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -186,11 +186,11 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.entitiesTable.title': string; readonly 'entitiesPage.entitiesTable.unavailable': string; readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; readonly 'entitiesPage.entitiesTable.footer.of': string; readonly 'entitiesPage.entitiesTable.footer.allRows': string; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index fc2c6c05f48..06f7451a988 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -68,8 +68,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; - readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.warning': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; @@ -80,11 +80,11 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.entitiesTable.title': string; readonly 'entitiesPage.entitiesTable.unavailable': string; readonly 'entitiesPage.entitiesTable.titleWithCount': 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.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; readonly 'entitiesPage.entitiesTable.footer.of': string; readonly 'entitiesPage.entitiesTable.footer.allRows': string; From 6295e233193fe655f81e615124b9951dd0153001 Mon Sep 17 00:00:00 2001 From: Husne Shabbir Date: Tue, 24 Mar 2026 11:06:10 +0530 Subject: [PATCH 11/12] chore(scorecard): refresh API reports for plugin-scorecard (#6) Regenerate report.api.md and report-alpha.api.md after TranslationRef key ordering changes so build:api-reports:only --ci passes. Made-with: Cursor Co-authored-by: HusneShabbir --- .../plugins/scorecard/report-alpha.api.md | 18 +++++++++--------- .../scorecard/plugins/scorecard/report.api.md | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index bcdcccb597b..2f1a9e78e7c 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -138,9 +138,9 @@ export const scorecardEntityContent: OverridableExtensionDefinition<{ export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { + readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; - readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -148,9 +148,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -183,19 +183,19 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.noDataFound': string; readonly 'entitiesPage.unknownMetric': string; readonly 'entitiesPage.metricProviderNotRegistered': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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.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.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 06f7451a988..f6309692a44 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -32,9 +32,9 @@ export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', { + readonly 'emptyState.button': string; readonly 'emptyState.title': string; readonly 'emptyState.description': string; - readonly 'emptyState.button': string; readonly 'emptyState.altText': string; readonly 'notFound.title': string; readonly 'notFound.description': string; @@ -42,9 +42,9 @@ export const scorecardTranslationRef: TranslationRef< readonly 'notFound.readMore': string; readonly 'notFound.goBack': string; readonly 'notFound.contactSupport': string; + readonly 'permissionRequired.button': string; readonly 'permissionRequired.title': string; readonly 'permissionRequired.description': string; - readonly 'permissionRequired.button': string; readonly 'permissionRequired.altText': string; readonly 'errors.entityMissingProperties': string; readonly 'errors.invalidApiResponse': string; @@ -77,19 +77,19 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.noDataFound': string; readonly 'entitiesPage.unknownMetric': string; readonly 'entitiesPage.metricProviderNotRegistered': string; - readonly 'entitiesPage.entitiesTable.title': string; - readonly 'entitiesPage.entitiesTable.unavailable': string; - readonly 'entitiesPage.entitiesTable.titleWithCount': 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.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.title': string; + readonly 'entitiesPage.entitiesTable.unavailable': string; + readonly 'entitiesPage.entitiesTable.titleWithCount': string; } >; From b2e5211118f5bccccbf6f7cc40e65712f46ab18c Mon Sep 17 00:00:00 2001 From: Eswaraiahsapram Date: Tue, 24 Mar 2026 11:34:59 +0530 Subject: [PATCH 12/12] fix dev legacy file --- .../plugins/scorecard/dev/legacy.tsx | 117 +++++++++++++++--- 1 file changed, 101 insertions(+), 16 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx index d6983727670..b0cfa414c6e 100644 --- a/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx +++ b/workspaces/scorecard/plugins/scorecard/dev/legacy.tsx @@ -14,12 +14,23 @@ * 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'; @@ -29,8 +40,17 @@ import type { Metric, EntityMetricDetailResponse, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { CatalogEntityPage } from '@backstage/plugin-catalog'; -import { scorecardPlugin, EntityScorecardContent } from '../src/plugin'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +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'; @@ -40,6 +60,7 @@ import { } from '../__fixtures__/scorecardData'; import { mockAggregatedScorecardSuccessData } from '../__fixtures__/aggregatedScorecardData'; import { mockAggregatedScorecardEntitiesData } from '../__fixtures__/aggregatedScorecardEntitiesData'; +import { mockCatalogApi } from './mocks'; const mockComponentEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -93,6 +114,19 @@ class MockScorecardApi implements ScorecardApi { } } +const ScorecardWrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + createDevApp() .registerPlugin(scorecardPlugin) .addTranslationResource(scorecardTranslations) @@ -101,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();