diff --git a/workspaces/scorecard/.changeset/shy-parts-build.md b/workspaces/scorecard/.changeset/shy-parts-build.md new file mode 100644 index 0000000000..d0e475eb63 --- /dev/null +++ b/workspaces/scorecard/.changeset/shy-parts-build.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Introduce a MetricGroupCard component that aggregates related metrics into threshold-based bucket tiles with a filterable data sources dialog. ScorecardEntityContentGridView to render grouped metrics as MetricGroupCards and ungrouped metrics as individual Scorecard cards using a Masonry layout. Add i18n keys across all supported locales and comprehensive unit tests for all new components. diff --git a/workspaces/scorecard/plugins/scorecard/README.md b/workspaces/scorecard/plugins/scorecard/README.md index 5356935f61..3dbbff75de 100644 --- a/workspaces/scorecard/plugins/scorecard/README.md +++ b/workspaces/scorecard/plugins/scorecard/README.md @@ -8,6 +8,7 @@ The plugin supports both the **legacy** Backstage frontend and the **New Fronten - **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). Supports **`statusGrouped`** (multi-slice pie) and **`average`** (weighted health donut) KPI types configured under **`scorecard.aggregationKPIs`**. - **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. +- **Metric group cards (grid layout)** — Group related metrics into collapsible cards with threshold bucket tiles, a filterable/sortable data sources dialog, and a Masonry grid layout. Enabled via app-config.yaml. ## Getting started @@ -122,7 +123,46 @@ To align with the legacy EntityPage (Scorecard on component pages and default en 5. Start the NFS app (e.g. `yarn start`), go to **Catalog**, open an entity. The **Scorecard** tab appears for entities that match your `allowedFilters` (or all entities if the extension config is omitted or empty). -6. (Optional) Enable homepage Scorecard widgets by adding `scorecardHomeModule` to app features (see step 2) and configuring home page extensions in `app-config.yaml`: +6. (Optional) Enable the **grid layout** with metric group cards. The grid layout extension is disabled by default. Enable it and define `groups` in `app-config.yaml` to organize metrics into themed cards: + + ```yaml + app: + extensions: + - scorecard-layout:catalog/scorecard-entity-layout-grid: + config: + groups: + codeQuality: + title: 'Code Quality' + description: 'SonarQube code quality metrics' + metrics: + - sonarqube.reliability_issues + - sonarqube.code_coverage + - sonarqube.security_issues + operations: + title: 'Operations' + metrics: + - github.open_prs + - jira.open_issues + ``` + + **Groups config schema:** + + | Field | Type | Required | Description | + | ------------- | ---------- | -------- | ---------------------------------------------------- | + | `title` | `string` | Yes | Display title for the group card. | + | `description` | `string` | No | Optional description shown below the title. | + | `metrics` | `string[]` | Yes | Ordered list of metric IDs to include in this group. | + + **Behavior:** + + - Metrics listed in `groups` are rendered as **MetricGroupCard** components. Each card shows threshold bucket tiles (e.g. Passing, Warning, Failing) with counts. Clicking a tile opens a **data sources dialog** pre-filtered to that status. + - The data sources dialog displays a sortable, filterable table with columns: Plugin, Check, Value, Status, and Last Synced. A threshold legend allows toggling visibility by status. + - Metrics **not** listed in any group are rendered as individual Scorecard cards below the group cards. + - All cards are arranged in a responsive **Masonry** grid (1 column on mobile, 2 on tablet, 3 on desktop). + - If `groups` is empty or omitted, the grid layout falls back to the default `EntityScorecardContent` view (individual cards for all metrics). + - When multiple layout extensions are enabled, the Scorecard tab renders a toggle to switch between them. + +7. (Optional) Enable homepage Scorecard widgets by adding `scorecardHomeModule` to app features (see step 2) and configuring home page extensions in `app-config.yaml`: ```yaml app: @@ -222,13 +262,14 @@ The following modules and extensions are available from `@red-hat-developer-hub/ | Module | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scorecardHomeModule` | Registers Scorecard homepage widgets for the home plugin (`AggregatedCardWithDeprecatedMetricId`, `AggregatedCardWithDefaultAggregation`, `AggregatedCardWithJiraOpenIssues`, `AggregatedCardWithGithubOpenPrs`, `AggregatedCardWithGithubFilecheckLicense`, `AggregatedCardWithGithubFilecheckCodeowners` and `AggregatedCardWithGithubOpenPrsWeighted`). | -| `scorecardCatalogModule` | Registers the Scorecard entity tab with the catalog plugin. Add to your app's `features`. Which entities show the tab is configured via `app.extensions` (see step 3). | +| `scorecardCatalogModule` | Registers the Scorecard entity tab and the grid layout extension with the catalog plugin. Add to your app's `features`. Which entities show the tab is configured via `app.extensions` (see step 3). The grid layout is disabled by default; enable and configure it with `groups` (see step 6). | | `scorecardTranslationsModule` | Registers Scorecard translations with the app. Add to your app's `features`. | **Extensions** - `api:scorecard` — Scorecard API (provided by the plugin; auto-discovered when the plugin is installed). - `entity-content:catalog/entity-content-scorecard` — Scorecard tab on catalog entity pages. Configure with `allowedFilters` in `app.extensions` to limit by kind and optionally type. +- `scorecard-layout:catalog/scorecard-entity-layout-grid` — Grid layout with metric group cards (disabled by default). Enable via `app.extensions` and define `groups` to organize metrics into themed cards (see step 6). - `home-page-widget:home/scorecard-deprecated-metric-id` — Homepage widget using deprecated metricId property (Jira open issues). - `home-page-widget:home/scorecard-default-aggregation` — Homepage widget using default aggregation config (GitHub open PRs). - `home-page-widget:home/scorecard-jira-open-issues` — Homepage widget showing Jira open blocking tickets. diff --git a/workspaces/scorecard/plugins/scorecard/package.json b/workspaces/scorecard/plugins/scorecard/package.json index baf9742394..f1814d3bc4 100644 --- a/workspaces/scorecard/plugins/scorecard/package.json +++ b/workspaces/scorecard/plugins/scorecard/package.json @@ -61,7 +61,9 @@ "@backstage/plugin-permission-react": "^0.4.41", "@backstage/plugin-user-settings": "^0.9.1", "@backstage/theme": "^0.7.2", + "@backstage/ui": "^0.13.2", "@mui/icons-material": "5.18.0", + "@mui/lab": "5.0.0-alpha.177", "@mui/material": "5.18.0", "@mui/styles": "5.18.0", "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", @@ -85,7 +87,6 @@ "@backstage/plugin-catalog": "^2.0.1", "@backstage/plugin-user-settings": "^0.9.1", "@backstage/test-utils": "^1.7.16", - "@backstage/ui": "^0.13.2", "@red-hat-developer-hub/backstage-plugin-theme": "^0.13.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 876196c2dc..d90bcdbfa5 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -216,6 +216,17 @@ export const scorecardTranslationRef: TranslationRef< readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; + readonly 'dataSourcesDialog.title': string; + readonly 'dataSourcesDialog.close': string; + readonly 'dataSourcesDialog.unknownPlugin': string; + readonly 'dataSourcesDialog.statusTooltip': string; + readonly 'dataSourcesDialog.columns.plugin': string; + readonly 'dataSourcesDialog.columns.check': string; + readonly 'dataSourcesDialog.columns.value': string; + readonly 'dataSourcesDialog.columns.status': string; + readonly 'dataSourcesDialog.columns.lastSynced': string; + readonly 'metricGroupCard.menuAriaLabel': string; + readonly 'metricGroupCard.viewDataSources': string; readonly 'entitiesPage.missingPermission': string; readonly 'entitiesPage.noDataFound': string; readonly 'entitiesPage.unknownMetric': string; @@ -224,8 +235,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.entitiesTable.unavailable': string; readonly 'entitiesPage.entitiesTable.titleWithCount': string; readonly 'entitiesPage.entitiesTable.header.lastUpdated': string; - readonly 'entitiesPage.entitiesTable.header.status': string; readonly 'entitiesPage.entitiesTable.header.value': string; + readonly 'entitiesPage.entitiesTable.header.status': string; readonly 'entitiesPage.entitiesTable.header.entity': string; readonly 'entitiesPage.entitiesTable.header.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 31909b82b4..c9864e5c4a 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -116,6 +116,17 @@ export const scorecardTranslationRef: TranslationRef< readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; + readonly 'dataSourcesDialog.title': string; + readonly 'dataSourcesDialog.close': string; + readonly 'dataSourcesDialog.unknownPlugin': string; + readonly 'dataSourcesDialog.statusTooltip': string; + readonly 'dataSourcesDialog.columns.plugin': string; + readonly 'dataSourcesDialog.columns.check': string; + readonly 'dataSourcesDialog.columns.value': string; + readonly 'dataSourcesDialog.columns.status': string; + readonly 'dataSourcesDialog.columns.lastSynced': string; + readonly 'metricGroupCard.menuAriaLabel': string; + readonly 'metricGroupCard.viewDataSources': string; readonly 'entitiesPage.missingPermission': string; readonly 'entitiesPage.noDataFound': string; readonly 'entitiesPage.unknownMetric': string; @@ -124,8 +135,8 @@ export const scorecardTranslationRef: TranslationRef< readonly 'entitiesPage.entitiesTable.unavailable': string; readonly 'entitiesPage.entitiesTable.titleWithCount': string; readonly 'entitiesPage.entitiesTable.header.lastUpdated': string; - readonly 'entitiesPage.entitiesTable.header.status': string; readonly 'entitiesPage.entitiesTable.header.value': string; + readonly 'entitiesPage.entitiesTable.header.status': string; readonly 'entitiesPage.entitiesTable.header.entity': string; readonly 'entitiesPage.entitiesTable.header.owner': string; readonly 'entitiesPage.entitiesTable.header.kind': string; diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/scorecardLayoutExtensions.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/scorecardLayoutExtensions.tsx index da68e5db30..56135feb6e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/scorecardLayoutExtensions.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/scorecardLayoutExtensions.tsx @@ -34,23 +34,3 @@ export const scorecardEntityLayoutGrid = ).then(m => m.ScorecardEntityContentGridView), }, }); - -/** - * List view layout extension for the Scorecard entity tab. - * - * Extension ID: scorecard-layout:catalog/scorecard-entity-layout-list - * In UI Epic RHIDP-13950, this will be replaced with ScorecardEntityListView - * @alpha - */ -export const scorecardEntityLayoutList = - ScorecardEntityContentLayoutBlueprint.make({ - name: 'scorecard-entity-layout-list', - disabled: true, - params: { - title: 'List', - loader: () => - import( - '../../components/Scorecard/ScorecardEntityContentGridView' - ).then(m => m.ScorecardEntityContentGridView), - }, - }); diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx index 7955017b93..ec837d1045 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx @@ -33,10 +33,7 @@ import { aggregatedCardWithGithubOpenPrsWeightedWidget, } from './extensions/homePageCards'; import { scorecardPage } from './extensions/scorecardPage'; -import { - scorecardEntityLayoutGrid, - scorecardEntityLayoutList, -} from './extensions/scorecardLayoutExtensions'; +import { scorecardEntityLayoutGrid } from './extensions/scorecardLayoutExtensions'; /** * Extension for Scorecard translations. @@ -61,20 +58,16 @@ export default createFrontendPlugin({ }); /** - * Catalog module that injects the Scorecard tab and into Catalog entity pages. + * Catalog module that injects the Scorecard tab into Catalog entity pages. * - * Also ships grid and list layout extensions (disabled by default). - * Enable them in app-config.yaml to get a layout toggle in the entity tab. + * Also ships a grid layout extension (disabled by default). + * Enable it in app-config.yaml to get a layout toggle in the entity tab. * * @alpha */ export const scorecardCatalogModule = createFrontendModule({ pluginId: 'catalog', - extensions: [ - scorecardEntityContent, - scorecardEntityLayoutGrid, - scorecardEntityLayoutList, - ], + extensions: [scorecardEntityContent, scorecardEntityLayoutGrid], }); /** diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx new file mode 100644 index 0000000000..78a42a92a4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/DataSourcesDialog.tsx @@ -0,0 +1,438 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { SortDescriptor } from '@backstage/ui'; +import { + Cell, + CellText, + ColumnConfig, + Dialog, + DialogBody, + DialogFooter, + DialogHeader, + Flex, + Table, + TableItem, + Text, + useTable, + Button, + Column, +} from '@backstage/ui'; +import Box from '@mui/material/Box'; +import MuiTooltip from '@mui/material/Tooltip'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { useLanguage } from '../../hooks/useLanguage'; +import { + getStatusConfig, + getTranslatedStatus, + getLastUpdatedLabel, + extractPluginName, + resolveMetricTranslation, +} from '../../utils'; +import { buildThresholdBuckets } from './thresholdBucketUtils'; +import { StatusIcon } from './StatusIcon'; +import { ThresholdLegend } from './ThresholdLegend'; + +interface DataSourcesDialogProps { + open: boolean; + onClose: () => void; + title: string; + metrics: MetricResult[]; + initialFilters?: string[]; +} + +interface SourceRow extends TableItem { + plugin: string; + checkTitle: string; + checkDescription: string; + value: string; + evaluationKey: string | null; + statusLabel: string; + statusIcon: string; + statusColor: string; + lastSynced: string; + thresholdExpression: string | null; +} + +export const DataSourcesDialog = ({ + open, + onClose, + title, + metrics, + initialFilters, +}: DataSourcesDialogProps) => { + const { t } = useTranslation(); + const locale = useLanguage(); + + const buckets = useMemo( + () => buildThresholdBuckets(metrics, t), + [metrics, t], + ); + + const rows = useMemo( + () => + metrics.map((metric, index) => { + const evaluationKey = + metric.result?.thresholdResult?.evaluation ?? null; + + const thresholdRules = + metric.result?.thresholdResult?.definition?.rules; + + const statusConfig = getStatusConfig({ + evaluation: evaluationKey, + thresholdStatus: metric.result?.thresholdResult?.status, + metricStatus: metric.status, + thresholdRules, + }); + + const matchedRule = evaluationKey + ? thresholdRules?.find(r => r.key === evaluationKey) + : undefined; + + return { + id: String(index), + plugin: extractPluginName( + metric.id, + t('dataSourcesDialog.unknownPlugin'), + ), + checkTitle: metric.id, + checkDescription: resolveMetricTranslation( + t, + metric.id, + 'description', + metric.metadata.description, + ), + value: + metric.result?.value !== null && metric.result?.value !== undefined + ? String(metric.result.value) + : '—', + evaluationKey, + statusLabel: evaluationKey + ? getTranslatedStatus(evaluationKey, t) + : '—', + statusIcon: statusConfig.icon ?? '', + statusColor: statusConfig.color, + lastSynced: metric.result?.timestamp + ? getLastUpdatedLabel(metric.result.timestamp, locale) + : '—', + thresholdExpression: matchedRule?.expression ?? null, + }; + }), + [metrics, t, locale], + ); + + const [activeFilters, setActiveFilters] = useState>(new Set()); + const [sortDescriptor, setSortDescriptor] = useState( + null, + ); + + useEffect(() => { + if (open) { + setActiveFilters(new Set(initialFilters ?? [])); + setSortDescriptor(null); + } + }, [open, initialFilters]); + + const handleToggleFilter = useCallback((key: string) => { + setActiveFilters(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const filteredRows = useMemo( + () => + activeFilters.size > 0 + ? rows.filter( + r => r.evaluationKey !== null && activeFilters.has(r.evaluationKey), + ) + : rows, + [rows, activeFilters], + ); + + /** + * Renders the column configuration for the table. + * @returns The column configuration for the table. + */ + const columnConfig = useMemo[]>(() => { + const headerStyle = { + fontSize: '0.875rem', + fontWeight: 700, + cursor: 'pointer', + } as const; + + const renderHeader = + ( + id: string, + label: string, + opts?: { + isRowHeader?: boolean; + width?: ColumnConfig['width']; + }, + ) => + () => + ( + + + {label} + + + ); + + return [ + { + id: 'plugin', + label: t('dataSourcesDialog.columns.plugin'), + header: renderHeader('plugin', t('dataSourcesDialog.columns.plugin'), { + width: '1fr' as ColumnConfig['width'], + }), + cell: item => , + isSortable: true, + width: '1fr' as ColumnConfig['width'], + }, + { + id: 'check', + label: t('dataSourcesDialog.columns.check'), + header: renderHeader('check', t('dataSourcesDialog.columns.check'), { + isRowHeader: true, + width: '2.5fr' as ColumnConfig['width'], + }), + cell: item => ( + + + + {item.checkTitle} + + + {item.checkDescription} + + + + ), + isSortable: true, + isRowHeader: true, + width: '2.5fr' as ColumnConfig['width'], + }, + { + id: 'value', + label: t('dataSourcesDialog.columns.value'), + header: renderHeader('value', t('dataSourcesDialog.columns.value'), { + width: '0.7fr' as ColumnConfig['width'], + }), + cell: item => , + isSortable: true, + width: '0.7fr' as ColumnConfig['width'], + }, + { + id: 'status', + label: t('dataSourcesDialog.columns.status'), + header: renderHeader('status', t('dataSourcesDialog.columns.status'), { + width: '1fr' as ColumnConfig['width'], + }), + cell: item => { + const tooltipText = + item.thresholdExpression && item.evaluationKey + ? t('dataSourcesDialog.statusTooltip', { + value: item.value, + status: item.statusLabel, + expression: item.thresholdExpression, + } as any) + : ''; + return ( + + + + + + {item.statusLabel} + + + + + ); + }, + isSortable: true, + width: '1fr' as ColumnConfig['width'], + }, + { + id: 'lastSynced', + label: t('dataSourcesDialog.columns.lastSynced'), + header: renderHeader( + 'lastSynced', + t('dataSourcesDialog.columns.lastSynced'), + { width: '1.2fr' as ColumnConfig['width'] }, + ), + cell: item => , + isSortable: true, + width: '1.2fr' as ColumnConfig['width'], + }, + ]; + }, [t]); + + const { tableProps } = useTable({ + mode: 'complete', + data: filteredRows, + paginationOptions: { type: 'none' }, + sort: sortDescriptor, + onSortChange: setSortDescriptor, + sortFn: (data, sort) => { + const colToField: Record = { + plugin: 'plugin', + check: 'checkTitle', + value: 'value', + status: 'statusLabel', + lastSynced: 'lastSynced', + }; + const field = colToField[sort.column as string] ?? 'plugin'; + const sorted = [...data]; + const dir = sort.direction === 'ascending' ? 1 : -1; + sorted.sort((a, b) => { + if (field === 'value') { + const numA = parseFloat(a.value); + const numB = parseFloat(b.value); + const aIsNum = !Number.isNaN(numA); + const bIsNum = !Number.isNaN(numB); + if (aIsNum && bIsNum) return dir * (numA - numB); + if (aIsNum) return -dir; + if (bIsNum) return dir; + return 0; + } + const valA = String(a[field] ?? ''); + const valB = String(b[field] ?? ''); + return dir * valA.localeCompare(valB); + }); + return sorted; + }, + }); + + const handleOverlayClick = useCallback( + (e: MouseEvent) => { + const dialog = document.querySelector('[role="dialog"]'); + if (dialog && !dialog.contains(e.target as Node)) { + onClose(); + } + }, + [onClose], + ); + + useEffect(() => { + if (!open) return undefined; + document.addEventListener('mousedown', handleOverlayClick); + return () => document.removeEventListener('mousedown', handleOverlayClick); + }, [open, handleOverlayClick]); + + return ( + <> + {open && ( + + )} + !isOpen && onClose()} + width={900} + > + + {t('dataSourcesDialog.title', { title } as any)} + + + + + + + + + + + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx new file mode 100644 index 0000000000..7ec51438c6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCard.tsx @@ -0,0 +1,115 @@ +/* + * 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, useCallback, useMemo } from 'react'; + +import Box from '@mui/material/Box'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { buildThresholdBuckets } from './thresholdBucketUtils'; +import { ThresholdBucketTile } from './ThresholdBucketTile'; +import { MetricGroupCardMenu } from './MetricGroupCardMenu'; +import type { MenuAction } from './MetricGroupCardMenu'; +import { DataSourcesDialog } from './DataSourcesDialog'; +import type { MetricGroupCardProps } from './types'; +import { CardWrapper } from '../Common/CardWrapper'; + +const MAX_TILES_PER_ROW = 3; + +export const MetricGroupCard = ({ + title, + description, + metrics, +}: MetricGroupCardProps) => { + const { t } = useTranslation(); + const buckets = buildThresholdBuckets(metrics, t); + const [dataSourcesOpen, setDataSourcesOpen] = useState(false); + const [initialFilters, setInitialFilters] = useState([]); + + const handleOpenDataSources = useCallback(() => { + setInitialFilters([]); + setDataSourcesOpen(true); + }, []); + + const handleOpenWithFilter = useCallback((filterKey: string) => { + setInitialFilters([filterKey]); + setDataSourcesOpen(true); + }, []); + + const handleCloseDataSources = useCallback( + () => setDataSourcesOpen(false), + [], + ); + + const menuActions = useMemo( + () => [ + { + id: 'view-data-sources', + label: t('metricGroupCard.viewDataSources'), + icon: , + onClick: handleOpenDataSources, + }, + ], + [t, handleOpenDataSources], + ); + + return ( + <> + + + } + > + + {buckets.map(bucket => ( + handleOpenWithFilter(bucket.key)} + /> + ))} + + + + {dataSourcesOpen && ( + + )} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCardMenu.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCardMenu.tsx new file mode 100644 index 0000000000..d92bb6627e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/MetricGroupCardMenu.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ReactNode } from 'react'; +import { useState, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; + +export interface MenuAction { + id: string; + label: string; + icon: ReactNode; + onClick: () => void; +} + +interface MetricGroupCardMenuProps { + ariaLabel: string; + actions: MenuAction[]; +} + +export const MetricGroupCardMenu = ({ + ariaLabel, + actions, +}: MetricGroupCardMenuProps) => { + const [anchorEl, setAnchorEl] = useState(null); + + const handleOpen = useCallback( + (e: React.MouseEvent) => setAnchorEl(e.currentTarget), + [], + ); + + const handleClose = useCallback(() => setAnchorEl(null), []); + + const handleAction = useCallback( + (action: MenuAction) => () => { + handleClose(); + action.onClick(); + }, + [handleClose], + ); + + if (actions.length === 0) return null; + + return ( + + + + + + + {actions.map(action => ( + + {action.icon} + + + ))} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/StatusIcon.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/StatusIcon.tsx new file mode 100644 index 0000000000..b430e7c3d7 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/StatusIcon.tsx @@ -0,0 +1,40 @@ +/* + * 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 { useTheme } from '@mui/material/styles'; + +import { resolveStatusColor } from '../../utils'; +import { ScorecardIcon } from '../ScorecardIcon/ScorecardIcon'; + +interface StatusIconProps { + icon: string; + color: string; +} + +export function StatusIcon({ icon, color }: StatusIconProps) { + const theme = useTheme(); + const resolvedColor = resolveStatusColor(theme, color); + + if (!icon) return null; + + return ( + + ); +} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/ThresholdBucketTile.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/ThresholdBucketTile.tsx new file mode 100644 index 0000000000..ed686bd9d6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/ThresholdBucketTile.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 { useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { useTheme, alpha, darken } from '@mui/material/styles'; + +import { resolveStatusColor } from '../../utils'; +import type { ThresholdBucket } from './types'; + +interface ThresholdBucketTileProps { + bucket: ThresholdBucket; + onClick?: () => void; +} + +export const ThresholdBucketTile = ({ + bucket, + onClick, +}: ThresholdBucketTileProps) => { + const theme = useTheme(); + const resolvedColor = resolveStatusColor(theme, bucket.color); + const isInteractive = Boolean(onClick); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (onClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick(); + } + }, + [onClick], + ); + + return ( + + + {bucket.count} + + + {bucket.label} + + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/ThresholdLegend.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/ThresholdLegend.tsx new file mode 100644 index 0000000000..0b3fea27e9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/ThresholdLegend.tsx @@ -0,0 +1,117 @@ +/* + * 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 { useCallback } from 'react'; + +import { Text } from '@backstage/ui'; + +import Box from '@mui/material/Box'; +import { useTheme, alpha } from '@mui/material/styles'; + +import { resolveStatusColor } from '../../utils'; +import type { ThresholdBucket } from './types'; + +interface ThresholdLegendProps { + buckets: ThresholdBucket[]; + activeFilters: Set; + onToggleFilter: (key: string) => void; +} + +export function ThresholdLegend({ + buckets, + activeFilters, + onToggleFilter, +}: ThresholdLegendProps) { + const theme = useTheme(); + + const handleKeyDown = useCallback( + (key: string) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleFilter(key); + } + }, + [onToggleFilter], + ); + + if (buckets.length === 0) return null; + + return ( + + {buckets.map(bucket => { + const resolvedColor = resolveStatusColor(theme, bucket.color); + const isActive = activeFilters.has(bucket.key); + return ( + onToggleFilter(bucket.key)} + onKeyDown={handleKeyDown(bucket.key)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 1, + border: '1px solid', + borderColor: isActive ? resolvedColor : 'divider', + borderRadius: 5, + px: 1.5, + py: 0.5, + flexShrink: 0, + whiteSpace: 'nowrap', + cursor: 'pointer', + backgroundColor: isActive + ? alpha(resolvedColor, 0.08) + : 'transparent', + transition: 'all 0.15s ease', + '&:hover': { + borderColor: resolvedColor, + backgroundColor: alpha(resolvedColor, 0.04), + }, + '&:focus-visible': { + outline: `2px solid ${resolvedColor}`, + outlineOffset: 1, + }, + }} + > + + + {bucket.label} {bucket.expression} + + + ); + })} + + ); +} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx new file mode 100644 index 0000000000..8dadf923ae --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/DataSourcesDialog.test.tsx @@ -0,0 +1,296 @@ +/* + * 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, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { DataSourcesDialog } from '../DataSourcesDialog'; + +const mockTableProps = { + 'aria-label': 'table', +}; + +jest.mock('@backstage/ui', () => ({ + Dialog: ({ + isOpen, + children, + }: { + isOpen: boolean; + children: React.ReactNode; + onOpenChange?: (open: boolean) => void; + width?: number; + }) => + isOpen ? ( +
{children}
+ ) : ( +
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogBody: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Table: ({ columnConfig, ...props }: any) => ( +
+ {columnConfig?.map((col: any) => ( + + {col.label} + + ))} + {props['aria-rowcount'] ?? 0} +
+ ), + useTable: jest.fn(() => ({ tableProps: mockTableProps })), + Cell: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + CellText: ({ title }: { title: string }) => {title}, + Text: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Flex: ({ children }: { children: React.ReactNode }) =>
{children}
, + Column: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Button: ({ + children, + onPress, + }: { + children: React.ReactNode; + onPress?: () => void; + variant?: string; + }) => ( + + ), +})); + +jest.mock('@mui/material/Box', () => ({ + __esModule: true, + default: ({ children, ...props }: any) =>
{children}
, +})); + +jest.mock('@mui/material/Tooltip', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('../../../hooks/useLanguage', () => ({ + useLanguage: () => 'en', +})); + +jest.mock('../../../utils', () => ({ + getStatusConfig: () => ({ + color: 'success.main', + icon: 'scorecardSuccessStatusIcon', + }), + getTranslatedStatus: (key: string) => key, + getLastUpdatedLabel: () => '1 hour ago', + extractPluginName: () => 'Sonarqube', + resolveMetricTranslation: ( + _t: any, + _id: string, + _field: string, + fallback?: string, + ) => fallback ?? '', +})); + +jest.mock('../thresholdBucketUtils', () => ({ + buildThresholdBuckets: () => [], +})); + +jest.mock('../StatusIcon', () => ({ + StatusIcon: ({ icon, color }: { icon: string; color: string }) => ( + + ), +})); + +jest.mock('../ThresholdLegend', () => ({ + ThresholdLegend: () =>
, +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const mockMetrics: MetricResult[] = [ + { + id: 'sonarqube.reliability_issues', + status: 'success', + metadata: { + title: 'SonarQube Reliability Issues', + description: 'Count of open bugs in SonarQube.', + type: 'number', + history: true, + }, + result: { + value: 8, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { + rules: [ + { key: 'success', expression: '<1' }, + { key: 'warning', expression: '1-5' }, + { key: 'error', expression: '>5' }, + ], + }, + status: 'success', + evaluation: 'error', + }, + }, + }, + { + id: 'sonarqube.code_coverage', + status: 'success', + metadata: { + title: 'SonarQube Code Coverage', + description: 'Code coverage percentage.', + type: 'number', + history: true, + }, + result: { + value: 72, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { + rules: [ + { key: 'success', expression: '>=80' }, + { key: 'warning', expression: '60-79' }, + { key: 'error', expression: '<60' }, + ], + }, + status: 'success', + evaluation: 'warning', + }, + }, + }, +]; + +const defaultProps = { + open: true, + onClose: jest.fn(), + title: 'Code Quality', + metrics: mockMetrics, +}; + +describe('DataSourcesDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render dialog content when open is false', () => { + render(, { + wrapper: TestWrapper, + }); + + expect(screen.getByTestId('dialog-closed')).toBeInTheDocument(); + expect(screen.queryByTestId('dialog-header')).not.toBeInTheDocument(); + }); + + it('should render dialog with title when open is true', () => { + render(, { wrapper: TestWrapper }); + + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-header')).toHaveTextContent( + 'Code Quality sources', + ); + }); + + it('should render table with correct column headers', () => { + render(, { wrapper: TestWrapper }); + + expect(screen.getByTestId('col-plugin')).toHaveTextContent('PLUGIN'); + expect(screen.getByTestId('col-check')).toHaveTextContent('CHECK'); + expect(screen.getByTestId('col-value')).toHaveTextContent('VALUE'); + expect(screen.getByTestId('col-status')).toHaveTextContent('STATUS'); + expect(screen.getByTestId('col-lastSynced')).toHaveTextContent( + 'LAST SYNCED', + ); + }); + + it('should render metric rows with plugin name, check title, value, status', () => { + const { useTable } = jest.requireMock('@backstage/ui'); + let capturedData: any[] = []; + useTable.mockImplementation(({ data }: any) => { + capturedData = data; + return { tableProps: mockTableProps }; + }); + + render(, { wrapper: TestWrapper }); + + expect(capturedData).toHaveLength(2); + expect(capturedData[0].plugin).toBe('Sonarqube'); + expect(capturedData[0].checkTitle).toBe('sonarqube.reliability_issues'); + expect(capturedData[0].value).toBe('8'); + expect(capturedData[0].statusLabel).toBe('error'); + expect(capturedData[1].plugin).toBe('Sonarqube'); + expect(capturedData[1].value).toBe('72'); + }); + + it('should call onClose when Close button is pressed', () => { + const onClose = jest.fn(); + render(, { + wrapper: TestWrapper, + }); + + fireEvent.click(screen.getByTestId('close-button')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("should show '—' for null/undefined values", () => { + const metricsWithNull: MetricResult[] = [ + { + id: 'sonarqube.null_metric', + status: 'error', + metadata: { + title: 'Null Metric', + description: 'A metric with no result value.', + type: 'number', + history: false, + }, + result: { + value: null as unknown as number, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { rules: [{ key: 'success', expression: '<1' }] }, + status: 'success', + evaluation: null as unknown as string, + }, + }, + }, + ]; + + const { useTable } = jest.requireMock('@backstage/ui'); + let capturedData: any[] = []; + useTable.mockImplementation(({ data }: any) => { + capturedData = data; + return { tableProps: mockTableProps }; + }); + + render(, { + wrapper: TestWrapper, + }); + + expect(capturedData[0].value).toBe('—'); + expect(capturedData[0].statusLabel).toBe('—'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx new file mode 100644 index 0000000000..f1cb43be26 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCard.test.tsx @@ -0,0 +1,231 @@ +/* + * 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, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { MetricGroupCard } from '../MetricGroupCard'; +import type { ThresholdBucket } from '../types'; + +const mockBuckets: ThresholdBucket[] = [ + { + key: 'success', + label: 'Passing', + expression: '<1', + count: 3, + color: 'success.main', + }, + { + key: 'warning', + label: 'Warning', + expression: '1-5', + count: 2, + color: 'warning.main', + }, + { + key: 'error', + label: 'Failing', + expression: '>5', + count: 1, + color: 'error.main', + }, +]; + +jest.mock('../thresholdBucketUtils', () => ({ + buildThresholdBuckets: jest.fn(() => mockBuckets), +})); + +jest.mock('../ThresholdBucketTile', () => ({ + ThresholdBucketTile: ({ + bucket, + onClick, + }: { + bucket: ThresholdBucket; + onClick?: () => void; + }) => ( +
+ {bucket.label} ({bucket.count}) +
+ ), +})); + +jest.mock('../MetricGroupCardMenu', () => ({ + MetricGroupCardMenu: ({ + actions, + }: { + actions: Array<{ id: string; label: string; onClick: () => void }>; + }) => ( +
+ {actions.map(action => ( + + ))} +
+ ), +})); + +jest.mock('../DataSourcesDialog', () => ({ + DataSourcesDialog: ({ + open, + title, + initialFilters, + }: { + open: boolean; + onClose: () => void; + title: string; + metrics: MetricResult[]; + initialFilters?: string[]; + }) => ( +
+ {String(open)} + {title} + {JSON.stringify(initialFilters)} +
+ ), +})); + +jest.mock('../../Common/CardWrapper', () => ({ + CardWrapper: ({ + title, + description, + children, + info, + }: { + title: string; + description?: string; + children: React.ReactNode; + info?: React.ReactNode; + }) => ( +
+ {title} + {description && {description}} +
{info}
+
{children}
+
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const mockMetric: MetricResult = { + id: 'sonarqube.reliability_issues', + status: 'success', + metadata: { + title: 'SonarQube Reliability Issues', + description: 'Count of open bugs in SonarQube.', + type: 'number', + history: true, + }, + result: { + value: 8, + timestamp: '2026-07-01T08:29:09.683Z', + thresholdResult: { + definition: { + rules: [ + { key: 'success', expression: '<1' }, + { key: 'warning', expression: '1-5' }, + { key: 'error', expression: '>5' }, + ], + }, + status: 'success', + evaluation: 'error', + }, + }, +}; + +describe('MetricGroupCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render title and description in CardWrapper', () => { + render( + , + { wrapper: TestWrapper }, + ); + + expect(screen.getByTestId('card-title')).toHaveTextContent('Code Quality'); + expect(screen.getByTestId('card-description')).toHaveTextContent( + 'Overall code health', + ); + }); + + it('should render ThresholdBucketTile for each bucket', () => { + render(, { + wrapper: TestWrapper, + }); + + expect(screen.getByTestId('tile-success')).toBeInTheDocument(); + expect(screen.getByTestId('tile-warning')).toBeInTheDocument(); + expect(screen.getByTestId('tile-error')).toBeInTheDocument(); + }); + + it('should open DataSourcesDialog when menu "View data sources" is clicked', () => { + render(, { + wrapper: TestWrapper, + }); + + expect(screen.queryByTestId('data-sources-dialog')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('menu-action-view-data-sources')); + + expect(screen.getByTestId('data-sources-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-filters')).toHaveTextContent('[]'); + }); + + it('should open DataSourcesDialog with filter when a tile is clicked', () => { + render(, { + wrapper: TestWrapper, + }); + + expect(screen.queryByTestId('data-sources-dialog')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('tile-warning')); + + expect(screen.getByTestId('data-sources-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-filters')).toHaveTextContent( + '["warning"]', + ); + }); + + it('should adapt grid columns to min(buckets.length, 3)', () => { + const { container } = render( + , + { wrapper: TestWrapper }, + ); + + const grid = container.querySelector('[data-testid="card-children"] > div'); + expect(grid).toHaveStyle({ gridTemplateColumns: 'repeat(3, 1fr)' }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCardMenu.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCardMenu.test.tsx new file mode 100644 index 0000000000..fd8a0956ed --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/MetricGroupCardMenu.test.tsx @@ -0,0 +1,109 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { MetricGroupCardMenu } from '../MetricGroupCardMenu'; +import type { MenuAction } from '../MetricGroupCardMenu'; + +const theme = createTheme(); + +function renderWithTheme(ui: React.ReactElement) { + return render({ui}); +} + +const mockActions: MenuAction[] = [ + { + id: 'edit', + label: 'Edit scorecard', + icon: , + onClick: jest.fn(), + }, + { + id: 'delete', + label: 'Delete scorecard', + icon: , + onClick: jest.fn(), + }, +]; + +describe('MetricGroupCardMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when actions array is empty', () => { + const { container } = renderWithTheme( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render IconButton with the provided aria-label', () => { + renderWithTheme( + , + ); + + const button = screen.getByLabelText('Card options'); + expect(button).toBeInTheDocument(); + expect(button.tagName.toLowerCase()).toBe('button'); + }); + + it('should open menu when IconButton clicked and show action labels', async () => { + const user = userEvent.setup(); + renderWithTheme( + , + ); + + expect(screen.queryByText('Edit scorecard')).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText('Card options')); + + expect(screen.getByText('Edit scorecard')).toBeInTheDocument(); + expect(screen.getByText('Delete scorecard')).toBeInTheDocument(); + }); + + it('should call action onClick when menu item clicked', async () => { + const user = userEvent.setup(); + renderWithTheme( + , + ); + + await user.click(screen.getByLabelText('Card options')); + await user.click(screen.getByText('Edit scorecard')); + + expect(mockActions[0].onClick).toHaveBeenCalledTimes(1); + }); + + it('should close menu after action click', async () => { + const user = userEvent.setup(); + renderWithTheme( + , + ); + + await user.click(screen.getByLabelText('Card options')); + expect(screen.getByText('Edit scorecard')).toBeInTheDocument(); + + await user.click(screen.getByText('Delete scorecard')); + + await waitFor(() => { + expect(screen.queryByText('Edit scorecard')).not.toBeInTheDocument(); + }); + expect(mockActions[1].onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/StatusIcon.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/StatusIcon.test.tsx new file mode 100644 index 0000000000..c7810cb82e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/StatusIcon.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { StatusIcon } from '../StatusIcon'; + +const mockResolveStatusColor = jest.fn(); +jest.mock('../../../utils', () => ({ + resolveStatusColor: function resolveStatusColor() { + return mockResolveStatusColor.apply(null, arguments); + }, +})); + +const mockScorecardIcon = jest.fn((_props: Record) => ( + +)); +jest.mock('../../ScorecardIcon/ScorecardIcon', () => ({ + ScorecardIcon: (props: Record) => mockScorecardIcon(props), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('StatusIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockResolveStatusColor.mockReturnValue('#2e7d32'); + }); + + it('should return null when icon is empty string', () => { + const { container } = render(, { + wrapper: TestWrapper, + }); + expect(container.firstChild).toBeNull(); + }); + + it('should render ScorecardIcon with resolved color when icon is provided', () => { + const { getByTestId } = render( + , + { wrapper: TestWrapper }, + ); + expect(getByTestId('scorecard-icon')).toBeInTheDocument(); + expect(mockResolveStatusColor).toHaveBeenCalledWith( + expect.objectContaining({ palette: expect.any(Object) }), + 'success', + ); + }); + + it('should pass correct props to ScorecardIcon', () => { + mockResolveStatusColor.mockReturnValue('#d32f2f'); + + render(, { + wrapper: TestWrapper, + }); + + expect(mockScorecardIcon).toHaveBeenCalledWith( + expect.objectContaining({ + icon: 'warning', + size: 'small', + sx: { fontSize: 18, color: '#d32f2f' }, + }), + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/ThresholdBucketTile.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/ThresholdBucketTile.test.tsx new file mode 100644 index 0000000000..e80e7e5168 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/ThresholdBucketTile.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, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { ThresholdBucketTile } from '../ThresholdBucketTile'; + +jest.mock('../../../utils', () => ({ + resolveStatusColor: () => '#2e7d32', +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const baseBucket = { + key: 'passing', + label: 'Passing', + expression: '>= 80', + count: 42, + color: 'success', +}; + +describe('ThresholdBucketTile', () => { + it('should render bucket count and label', () => { + render(, { + wrapper: TestWrapper, + }); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Passing')).toBeInTheDocument(); + }); + + it('should have role="button" and tabIndex=0 when onClick is provided', () => { + const onClick = jest.fn(); + const { container } = render( + , + { wrapper: TestWrapper }, + ); + const tile = container.firstChild as HTMLElement; + expect(tile).toHaveAttribute('role', 'button'); + expect(tile).toHaveAttribute('tabindex', '0'); + }); + + it('should not have role="button" when onClick is undefined', () => { + const { container } = render(, { + wrapper: TestWrapper, + }); + const tile = container.firstChild as HTMLElement; + expect(tile).not.toHaveAttribute('role'); + }); + + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + const { container } = render( + , + { wrapper: TestWrapper }, + ); + fireEvent.click(container.firstChild as HTMLElement); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should call onClick on Enter key', () => { + const onClick = jest.fn(); + const { container } = render( + , + { wrapper: TestWrapper }, + ); + fireEvent.keyDown(container.firstChild as HTMLElement, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should call onClick on Space key', () => { + const onClick = jest.fn(); + const { container } = render( + , + { wrapper: TestWrapper }, + ); + fireEvent.keyDown(container.firstChild as HTMLElement, { key: ' ' }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should have aria-label with count and label when interactive', () => { + const onClick = jest.fn(); + const { container } = render( + , + { wrapper: TestWrapper }, + ); + const tile = container.firstChild as HTMLElement; + expect(tile).toHaveAttribute('aria-label', '42 Passing'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/ThresholdLegend.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/ThresholdLegend.test.tsx new file mode 100644 index 0000000000..b2683f2156 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/ThresholdLegend.test.tsx @@ -0,0 +1,165 @@ +/* + * 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, fireEvent } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +import { ThresholdLegend } from '../ThresholdLegend'; +import type { ThresholdBucket } from '../types'; + +jest.mock('../../../utils', () => ({ + resolveStatusColor: () => '#2e7d32', +})); + +jest.mock('@backstage/ui', () => ({ + Text: ({ children, ...props }: React.PropsWithChildren<{}>) => ( + {children} + ), +})); + +const theme = createTheme(); + +function renderWithTheme(ui: React.ReactElement) { + return render({ui}); +} + +const mockBuckets: ThresholdBucket[] = [ + { key: 'pass', label: 'Pass', expression: '>= 80', count: 5, color: 'green' }, + { + key: 'warn', + label: 'Warning', + expression: '>= 50', + count: 3, + color: 'orange', + }, + { key: 'fail', label: 'Fail', expression: '< 50', count: 2, color: 'red' }, +]; + +describe('ThresholdLegend', () => { + it('should return null when buckets is empty', () => { + const { container } = renderWithTheme( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render all bucket pills with label and expression', () => { + renderWithTheme( + , + ); + + expect(screen.getByText('Pass >= 80')).toBeInTheDocument(); + expect(screen.getByText('Warning >= 50')).toBeInTheDocument(); + expect(screen.getByText('Fail < 50')).toBeInTheDocument(); + }); + + it('should have role="button" and tabIndex=0 on each pill', () => { + renderWithTheme( + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(3); + buttons.forEach(button => { + expect(button).toHaveAttribute('tabindex', '0'); + }); + }); + + it('should call onToggleFilter with the bucket key on click', () => { + const onToggleFilter = jest.fn(); + renderWithTheme( + , + ); + + fireEvent.click(screen.getAllByRole('button')[0]); + expect(onToggleFilter).toHaveBeenCalledWith('pass'); + + fireEvent.click(screen.getAllByRole('button')[2]); + expect(onToggleFilter).toHaveBeenCalledWith('fail'); + }); + + it('should call onToggleFilter on Enter key press', () => { + const onToggleFilter = jest.fn(); + renderWithTheme( + , + ); + + fireEvent.keyDown(screen.getAllByRole('button')[1], { key: 'Enter' }); + expect(onToggleFilter).toHaveBeenCalledWith('warn'); + }); + + it('should call onToggleFilter on Space key press', () => { + const onToggleFilter = jest.fn(); + renderWithTheme( + , + ); + + fireEvent.keyDown(screen.getAllByRole('button')[0], { key: ' ' }); + expect(onToggleFilter).toHaveBeenCalledWith('pass'); + }); + + it('should set aria-pressed="true" for active filters', () => { + renderWithTheme( + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true'); + expect(buttons[2]).toHaveAttribute('aria-pressed', 'true'); + }); + + it('should set aria-pressed="false" for inactive filters', () => { + renderWithTheme( + , + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons[1]).toHaveAttribute('aria-pressed', 'false'); + expect(buttons[2]).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/thresholdBucketUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/thresholdBucketUtils.test.tsx new file mode 100644 index 0000000000..da7f71dc71 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/__tests__/thresholdBucketUtils.test.tsx @@ -0,0 +1,204 @@ +/* + * 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 { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { mockT } from '../../../test-utils/mockTranslations'; +import { buildThresholdBuckets } from '../thresholdBucketUtils'; + +jest.mock('../../../utils', () => ({ + getTranslatedStatus: jest.fn(), +})); + +jest.mock('../../../utils/thresholdUtils', () => ({ + getThresholdRuleColor: jest.fn(), +})); + +import { getTranslatedStatus } from '../../../utils'; +import { getThresholdRuleColor } from '../../../utils/thresholdUtils'; + +const mockedGetTranslatedStatus = getTranslatedStatus as jest.MockedFunction< + typeof getTranslatedStatus +>; +const mockedGetThresholdRuleColor = + getThresholdRuleColor as jest.MockedFunction; + +const thresholdRules = [ + { key: 'success', expression: '<1' }, + { key: 'warning', expression: '1-7' }, + { key: 'error', expression: '>7' }, +]; + +function createMetric( + id: string, + evaluation: string | null, + rules = thresholdRules, +): MetricResult { + return { + id, + status: 'success', + metadata: { + title: id, + description: `${id} description`, + type: 'number', + }, + result: { + value: 5, + timestamp: new Date().toISOString(), + thresholdResult: { + status: 'success', + definition: { rules }, + evaluation, + }, + }, + }; +} + +describe('buildThresholdBuckets', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetTranslatedStatus.mockImplementation(key => key ?? ''); + mockedGetThresholdRuleColor.mockImplementation((_rules, key) => { + const defaults: Record = { + success: 'success.main', + warning: 'warning.main', + error: 'error.main', + }; + return defaults[key]; + }); + }); + + it('should return empty array for empty metrics', () => { + const result = buildThresholdBuckets([], mockT as any); + expect(result).toEqual([]); + }); + + it('should collect unique threshold rules from multiple metrics', () => { + const metrics = [ + createMetric('m1', 'success'), + createMetric('m2', 'warning'), + createMetric('m3', 'success'), + ]; + + const result = buildThresholdBuckets(metrics, mockT as any); + + expect(result).toHaveLength(3); + expect(result.map(b => b.key)).toEqual(['success', 'warning', 'error']); + }); + + it('should count metrics per evaluation key correctly', () => { + const metrics = [ + createMetric('m1', 'success'), + createMetric('m2', 'success'), + createMetric('m3', 'warning'), + createMetric('m4', 'error'), + ]; + + const result = buildThresholdBuckets(metrics, mockT as any); + + const countsByKey = Object.fromEntries(result.map(b => [b.key, b.count])); + expect(countsByKey).toEqual({ + success: 2, + warning: 1, + error: 1, + }); + }); + + it('should handle metrics without threshold results', () => { + const metricNoResult: MetricResult = { + id: 'no-result', + status: 'error', + metadata: { title: 'no-result', description: '', type: 'number' }, + result: undefined as any, + }; + const metricNoThreshold: MetricResult = { + id: 'no-threshold', + status: 'success', + metadata: { title: 'no-threshold', description: '', type: 'number' }, + result: { + value: 1, + timestamp: new Date().toISOString(), + thresholdResult: undefined as any, + }, + }; + const metricWithData = createMetric('has-data', 'success'); + + const result = buildThresholdBuckets( + [metricNoResult, metricNoThreshold, metricWithData], + mockT as any, + ); + + expect(result).toHaveLength(3); + expect(result.find(b => b.key === 'success')?.count).toBe(1); + }); + + it('should use correct label from getTranslatedStatus', () => { + mockedGetTranslatedStatus.mockImplementation(key => { + const labels: Record = { + success: 'Translated Success', + warning: 'Translated Warning', + error: 'Translated Error', + }; + return labels[key ?? ''] ?? key ?? ''; + }); + + const metrics = [createMetric('m1', 'success')]; + const result = buildThresholdBuckets(metrics, mockT as any); + + expect(result[0].label).toBe('Translated Success'); + expect(result[1].label).toBe('Translated Warning'); + expect(result[2].label).toBe('Translated Error'); + expect(mockedGetTranslatedStatus).toHaveBeenCalledTimes(3); + }); + + it('should use correct color from getThresholdRuleColor', () => { + mockedGetThresholdRuleColor.mockImplementation((_rules, key) => { + const colors: Record = { + success: '#00ff00', + warning: '#ffaa00', + error: '#ff0000', + }; + return colors[key]; + }); + + const metrics = [createMetric('m1', 'success')]; + const result = buildThresholdBuckets(metrics, mockT as any); + + expect(result[0].color).toBe('#00ff00'); + expect(result[1].color).toBe('#ffaa00'); + expect(result[2].color).toBe('#ff0000'); + expect(mockedGetThresholdRuleColor).toHaveBeenCalledTimes(3); + }); + + it("should fall back to 'error.main' when getThresholdRuleColor returns undefined", () => { + mockedGetThresholdRuleColor.mockReturnValue(undefined); + + const metrics = [createMetric('m1', 'success')]; + const result = buildThresholdBuckets(metrics, mockT as any); + + result.forEach(bucket => { + expect(bucket.color).toBe('error.main'); + }); + }); + + it("should fall back to '' when rule.expression is undefined", () => { + const rulesWithoutExpression = [{ key: 'custom' }]; + const metric = createMetric('m1', 'custom', rulesWithoutExpression as any); + + const result = buildThresholdBuckets([metric], mockT as any); + + expect(result[0].expression).toBe(''); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/index.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/index.ts new file mode 100644 index 0000000000..5442fed3ac --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { MetricGroupCard } from './MetricGroupCard'; +export type { MetricGroupCardProps, ThresholdBucket } from './types'; +export type { MenuAction } from './MetricGroupCardMenu'; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/thresholdBucketUtils.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/thresholdBucketUtils.ts new file mode 100644 index 0000000000..771ed9d7dc --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/thresholdBucketUtils.ts @@ -0,0 +1,84 @@ +/* + * 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 { + MetricResult, + ThresholdRule, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { TranslationFunction } from '@backstage/core-plugin-api/alpha'; + +import type { scorecardTranslationRef } from '../../translations'; +import { getTranslatedStatus } from '../../utils'; +import { getThresholdRuleColor } from '../../utils/thresholdUtils'; +import type { ThresholdBucket } from './types'; + +/** + * Collects the unique, ordered set of threshold rules across a group of metrics. + */ +function collectThresholdRules(metrics: MetricResult[]): ThresholdRule[] { + const rules: ThresholdRule[] = []; + const seen = new Set(); + + for (const metric of metrics) { + for (const rule of metric.result?.thresholdResult?.definition?.rules ?? + []) { + if (!seen.has(rule.key)) { + seen.add(rule.key); + rules.push(rule); + } + } + } + + return rules; +} + +/** + * Counts metrics per threshold evaluation key. + */ +function countByEvaluation(metrics: MetricResult[]): Map { + const counts = new Map(); + + for (const metric of metrics) { + const evaluation = metric.result?.thresholdResult?.evaluation; + if (evaluation) { + counts.set(evaluation, (counts.get(evaluation) ?? 0) + 1); + } + } + + return counts; +} + +/** + * Aggregates a set of metrics into threshold buckets. + * + * Each bucket represents a threshold rule (e.g. "success", "warning", "error") + * and contains the count of metrics whose evaluation matched that rule. + */ +export function buildThresholdBuckets( + metrics: MetricResult[], + t: TranslationFunction, +): ThresholdBucket[] { + const rules = collectThresholdRules(metrics); + const counts = countByEvaluation(metrics); + + return rules.map(rule => ({ + key: rule.key, + label: getTranslatedStatus(rule.key, t), + expression: rule.expression ?? '', + count: counts.get(rule.key) ?? 0, + color: getThresholdRuleColor(rules, rule.key) ?? 'error.main', + })); +} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/types.ts b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/types.ts new file mode 100644 index 0000000000..fcd309ea4b --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/MetricGroupCard/types.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 type { MetricResult } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export interface MetricGroupCardProps { + title: string; + description?: string; + metrics: MetricResult[]; +} + +export type ThresholdBucket = { + key: string; + label: string; + expression: string; + count: number; + color: string; +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx index 2165938aa8..32eb796306 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -50,10 +50,14 @@ const EntityScorecardContentInner = () => { return ( {scorecards?.map((metric: MetricResult) => { // Check if metric data unavailable @@ -66,7 +70,7 @@ const EntityScorecardContentInner = () => { evaluation: metric.result?.thresholdResult?.evaluation, thresholdStatus: metric.result?.thresholdResult?.status, metricStatus: metric.status, - thresholdRules: metric.result.thresholdResult.definition?.rules, + thresholdRules: metric.result?.thresholdResult?.definition?.rules, }); const title = resolveMetricTranslation( diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx index adb3d1b982..a8b0c3de1b 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx @@ -198,7 +198,7 @@ const Scorecard = ({ role="article" title={cardTitle} description={description} - width="371px" + width="100%" > - - {String(displayValue)} - - - {label} - - - ); -} - -function GroupCard({ - title, - description, - metrics, -}: { - title: string; - description?: string; - metrics: MetricResult[]; -}) { - return ( - - - - {title} - - - {description && ( - - {description} - - )} - - - {metrics.map(metric => ( - - ))} - - - - ); -} - export const ScorecardEntityContentGridView = ({ groups, }: ScorecardLayoutProps) => { @@ -171,74 +68,65 @@ export const ScorecardEntityContentGridView = ({ const ungroupedMetrics = scorecards.filter(m => !groupedMetricIds.has(m.id)); + const groupCards = Object.entries(groups) + .map(([groupKey, groupConfig]) => { + const metricsInGroup = groupedMetrics.get(groupKey) || []; + if (metricsInGroup.length === 0) return null; + + return ( + + ); + }) + .filter(Boolean); + + const ungroupedCards = ungroupedMetrics.map((metric: MetricResult) => { + const metricDataError = hasMetricDataError(metric); + const thresholdErrorState = hasThresholdError(metric); + const statusConfig = getStatusConfig({ + evaluation: metric.result?.thresholdResult?.evaluation, + thresholdStatus: metric.result?.thresholdResult?.status, + metricStatus: metric.status, + thresholdRules: metric.result?.thresholdResult?.definition?.rules, + }); + + return ( + + + + ); + }); + return ( - - {Object.entries(groups).map(([groupKey, groupConfig]) => { - const metricsInGroup = groupedMetrics.get(groupKey) || []; - - if (metricsInGroup.length === 0) { - return null; - } - - const metricsWithLabels = metricsInGroup.map(m => ({ - ...m, - metadata: { - ...m.metadata, - title: resolveMetricTranslation(t, m.id, 'title', m.metadata.title), - }, - })); - - return ( - - ); - })} - - {ungroupedMetrics.map((metric: MetricResult) => { - const metricDataError = hasMetricDataError(metric); - const thresholdErrorState = hasThresholdError(metric); - const statusConfig = getStatusConfig({ - evaluation: metric.result?.thresholdResult?.evaluation, - thresholdStatus: metric.result?.thresholdResult?.status, - metricStatus: metric.status, - thresholdRules: metric.result?.thresholdResult?.definition?.rules, - }); - - return ( - - ); - })} - + + {groupCards} + {ungroupedCards} + ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx new file mode 100644 index 0000000000..f7e8ad52c9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/ScorecardEntityContentGridView.test.tsx @@ -0,0 +1,465 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import { ScorecardEntityContentGridView } from '../ScorecardEntityContentGridView'; +import { mockScorecardSuccessData } from '../../../../__fixtures__/scorecardData'; +import { useScorecards } from '../../../hooks/useScorecards'; +import { getStatusConfig } from '../../../utils'; + +jest.mock('@backstage/core-components', () => ({ + ResponseErrorPanel: ({ error }: { error: Error }) => ( +
{error.message}
+ ), +})); + +jest.mock('../../Common/NoScorecardsState', () => { + return function MockNoScorecardsState() { + return
Empty State
; + }; +}); + +jest.mock('../../Common/PermissionRequiredState', () => { + return function MockPermissionRequiredState() { + return ( +
Permission Required
+ ); + }; +}); + +jest.mock('../Scorecard', () => { + return function MockScorecard({ + cardTitle, + description, + value, + statusColor, + statusIcon, + thresholds, + isThresholdError, + thresholdError, + isMetricDataError, + metricDataError, + }: any) { + return ( +
+

{cardTitle}

+

{description}

+ Value: {value} + Status: {statusColor} + {statusIcon && Status Icon} + {thresholds && Thresholds} + {isThresholdError && ( + + Threshold Error: {thresholdError} + + )} + {isMetricDataError && ( + + Metric Error: {metricDataError} + + )} +
+ ); + }; +}); + +jest.mock('../EntityScorecardContent', () => ({ + EntityScorecardContent: () => ( +
EntityScorecardContent
+ ), +})); + +jest.mock('../../MetricGroupCard', () => ({ + MetricGroupCard: ({ + title, + description, + metrics, + }: { + title: string; + description?: string; + metrics: any[]; + }) => ( +
+ {title} + {description && ( + {description} + )} + {metrics.length} + {metrics.map((m: any) => ( + + {m.id} + + ))} +
+ ), +})); + +jest.mock('../../../hooks/useScorecards', () => ({ + useScorecards: jest.fn(), +})); + +jest.mock('../../../utils', () => ({ + getStatusConfig: jest.fn(), + resolveMetricTranslation: jest.fn( + (_t: any, _metricId: string, _field: string, fallback?: string) => + fallback ?? `metric.${_metricId}.${_field}`, + ), +})); + +jest.mock('../../../utils/statusUtils', () => ({ + hasMetricDataError: jest.fn(() => false), + hasThresholdError: jest.fn(() => false), +})); + +const useScorecardsMock = useScorecards as jest.Mock; +const getStatusConfigMock = getStatusConfig as jest.Mock; + +describe('ScorecardEntityContentGridView', () => { + beforeEach(() => { + jest.clearAllMocks(); + + getStatusConfigMock.mockReturnValue({ + color: 'green', + icon: 'CheckCircleIcon', + }); + }); + + it('should render loading state when data is loading', () => { + useScorecardsMock.mockReturnValue({ + data: undefined, + isLoading: true, + error: undefined, + }); + + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should render permission required state for NotAllowedError', () => { + useScorecardsMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error( + 'Failed to fetch scorecards: 403 Forbidden. {"error":{"name":"NotAllowedError"}}', + ), + }); + + render(); + + expect(screen.getByTestId('permission-required-state')).toBeInTheDocument(); + }); + + it('should render error panel for non-permission errors', () => { + useScorecardsMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Network error'), + }); + + render(); + + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + + it('should render empty state when scorecards is empty', () => { + useScorecardsMock.mockReturnValue({ + data: [], + isLoading: false, + error: undefined, + }); + + render(); + + expect(screen.getByTestId('no-scorecards-state')).toBeInTheDocument(); + }); + + it('should render empty state when scorecards is null', () => { + useScorecardsMock.mockReturnValue({ + data: null, + isLoading: false, + error: undefined, + }); + + render(); + + expect(screen.getByTestId('no-scorecards-state')).toBeInTheDocument(); + }); + + it('should fall back to EntityScorecardContent when groups is empty', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + render(); + + expect(screen.getByTestId('entity-scorecard-content')).toBeInTheDocument(); + }); + + it('should fall back to EntityScorecardContent when groups is undefined', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + render(); + + expect(screen.getByTestId('entity-scorecard-content')).toBeInTheDocument(); + }); + + it('should render MetricGroupCard for each non-empty group', async () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + codeQuality: { + title: 'Code Quality', + description: 'Code quality metrics', + metrics: ['github.open_prs'], + }, + operations: { + title: 'Operations', + metrics: ['jira.open_issues'], + }, + }; + + render(); + + await waitFor(() => { + const groupCards = screen.getAllByTestId('metric-group-card'); + expect(groupCards).toHaveLength(2); + }); + + expect(screen.getByText('Code Quality')).toBeInTheDocument(); + expect(screen.getByText('Operations')).toBeInTheDocument(); + expect( + screen.getByTestId('group-metric-github.open_prs'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('group-metric-jira.open_issues'), + ).toBeInTheDocument(); + }); + + it('should skip groups where no metrics match', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + codeQuality: { + title: 'Code Quality', + metrics: ['github.open_prs'], + }, + nonExistent: { + title: 'Non Existent', + metrics: ['does.not.exist', 'also.missing'], + }, + }; + + render(); + + const groupCards = screen.getAllByTestId('metric-group-card'); + expect(groupCards).toHaveLength(1); + expect(screen.getByText('Code Quality')).toBeInTheDocument(); + expect(screen.queryByText('Non Existent')).not.toBeInTheDocument(); + }); + + it('should render ungrouped metrics as individual Scorecard cards', async () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + codeQuality: { + title: 'Code Quality', + metrics: ['github.open_prs'], + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('metric-group-card')).toBeInTheDocument(); + }); + + const scorecardCards = screen.getAllByTestId('scorecard-card'); + expect(scorecardCards).toHaveLength(1); + expect(scorecardCards[0]).toHaveAttribute( + 'data-title', + 'Jira open blocking tickets', + ); + }); + + it('should render all metrics as ungrouped when no metric IDs match any group', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + empty: { + title: 'Empty Group', + metrics: ['nonexistent.metric'], + }, + }; + + render(); + + expect(screen.queryByTestId('metric-group-card')).not.toBeInTheDocument(); + const scorecardCards = screen.getAllByTestId('scorecard-card'); + expect(scorecardCards).toHaveLength(2); + }); + + it('should pass group description to MetricGroupCard', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + codeQuality: { + title: 'Code Quality', + description: 'All code quality metrics', + metrics: ['github.open_prs'], + }, + }; + + render(); + + expect(screen.getByText('All code quality metrics')).toBeInTheDocument(); + }); + + it('should preserve metric order within a group as defined in config', () => { + const threeMetrics = [ + ...mockScorecardSuccessData, + { + id: 'sonar.coverage', + status: 'success' as const, + metadata: { + title: 'Code Coverage', + description: 'Coverage percentage', + type: 'number', + history: true, + }, + result: { + value: 85, + timestamp: '2025-08-08T10:00:00Z', + thresholdResult: { + definition: { + rules: [{ key: 'success', expression: '>= 80' }], + }, + status: 'success' as const, + evaluation: 'success', + }, + }, + }, + ]; + + useScorecardsMock.mockReturnValue({ + data: threeMetrics, + isLoading: false, + error: undefined, + }); + + const groups = { + ordered: { + title: 'Ordered Group', + metrics: ['sonar.coverage', 'github.open_prs'], + }, + }; + + render(); + + const groupCard = screen.getByTestId('metric-group-card'); + expect(groupCard).toBeInTheDocument(); + + const metricIds = Array.from( + groupCard.querySelectorAll('[data-testid^="group-metric-"]'), + ) + .map(el => el.getAttribute('data-testid')!) + .filter(id => id !== 'group-metric-count'); + expect(metricIds).toEqual([ + 'group-metric-sonar.coverage', + 'group-metric-github.open_prs', + ]); + }); + + it('should call getStatusConfig for each ungrouped metric', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + codeQuality: { + title: 'Code Quality', + metrics: ['github.open_prs'], + }, + }; + + render(); + + expect(getStatusConfigMock).toHaveBeenCalledTimes(1); + expect(getStatusConfigMock).toHaveBeenCalledWith({ + evaluation: mockScorecardSuccessData[1].result.thresholdResult.evaluation, + thresholdStatus: + mockScorecardSuccessData[1].result.thresholdResult.status, + metricStatus: mockScorecardSuccessData[1].status, + thresholdRules: + mockScorecardSuccessData[1].result.thresholdResult.definition?.rules, + }); + }); + + it('should render both group cards and ungrouped cards in Masonry layout', () => { + useScorecardsMock.mockReturnValue({ + data: mockScorecardSuccessData, + isLoading: false, + error: undefined, + }); + + const groups = { + prs: { + title: 'Pull Requests', + metrics: ['github.open_prs'], + }, + }; + + render(); + + expect(screen.getByTestId('metric-group-card')).toBeInTheDocument(); + expect(screen.getAllByTestId('scorecard-card')).toHaveLength(1); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index db35af36e4..c1da353ce7 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -25,6 +25,16 @@ const scorecardTranslationDe = createTranslationMessages({ ref: scorecardTranslationRef, messages: { 'common.loading': 'Ladevorgang', + 'dataSourcesDialog.title': '{{title}} Quellen', + 'dataSourcesDialog.close': 'Schließen', + 'dataSourcesDialog.unknownPlugin': 'Unbekannt', + 'dataSourcesDialog.statusTooltip': + 'Wert {{value}} entspricht Schwellenwert {{status}} {{expression}}', + 'dataSourcesDialog.columns.plugin': 'PLUGIN', + 'dataSourcesDialog.columns.check': 'PRÜFUNG', + 'dataSourcesDialog.columns.value': 'WERT', + 'dataSourcesDialog.columns.status': 'STATUS', + 'dataSourcesDialog.columns.lastSynced': 'LETZTE SYNC', 'emptyState.altText': 'Keine Scorecards', 'emptyState.button': 'Dokumentation anzeigen', 'emptyState.description': @@ -71,6 +81,8 @@ const scorecardTranslationDe = createTranslationMessages({ 'Diese Scorecard verwendet einen Aggregationstyp, der von dieser Version des Plugins nicht unterstützt wird.', 'errors.userNotFoundInCatalogMessage': 'Benutzerentität im Katalog nicht gefunden.', + 'metricGroupCard.menuAriaLabel': 'Weitere Optionen', + 'metricGroupCard.viewDataSources': 'Quellen anzeigen', 'metric.averageCenterTooltipMaxLabel': 'Maximal erreichbare Punktzahl', 'metric.averageCenterTooltipTotalLabel': 'Gesamtpunktzahl', 'metric.averageCenterTooltipBreakdownRow_one': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index a809361ae5..3fbebc1c8f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -25,6 +25,16 @@ const scorecardTranslationEs = createTranslationMessages({ ref: scorecardTranslationRef, messages: { 'common.loading': 'Cargando', + 'dataSourcesDialog.title': '{{title}} fuentes', + 'dataSourcesDialog.close': 'Cerrar', + 'dataSourcesDialog.unknownPlugin': 'Desconocido', + 'dataSourcesDialog.statusTooltip': + 'Valor {{value}} coincide con umbral {{status}} {{expression}}', + 'dataSourcesDialog.columns.plugin': 'PLUGIN', + 'dataSourcesDialog.columns.check': 'VERIFICACIÓN', + 'dataSourcesDialog.columns.value': 'VALOR', + 'dataSourcesDialog.columns.status': 'ESTADO', + 'dataSourcesDialog.columns.lastSynced': 'ÚLTIMA SINCRONIZACIÓN', 'emptyState.altText': 'No hay tarjetas de puntuación', 'emptyState.button': 'Ver documentación', 'emptyState.description': @@ -72,6 +82,8 @@ const scorecardTranslationEs = createTranslationMessages({ 'Esta tarjeta de puntuación utiliza un tipo de agregación que no es compatible con esta versión del complemento.', 'errors.userNotFoundInCatalogMessage': 'No se encontró la entidad de usuario en el catálogo.', + 'metricGroupCard.menuAriaLabel': 'Más opciones', + 'metricGroupCard.viewDataSources': 'Ver fuentes', 'metric.averageCenterTooltipMaxLabel': 'Puntuación máxima posible', 'metric.averageCenterTooltipTotalLabel': 'Puntuación total', 'metric.averageCenterTooltipBreakdownRow_one': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index 7e94ac5e8a..8bf28c64f5 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -25,6 +25,16 @@ const scorecardTranslationFr = createTranslationMessages({ ref: scorecardTranslationRef, messages: { 'common.loading': 'Chargement', + 'dataSourcesDialog.title': '{{title}} sources', + 'dataSourcesDialog.close': 'Fermer', + 'dataSourcesDialog.unknownPlugin': 'Inconnu', + 'dataSourcesDialog.statusTooltip': + 'Valeur {{value}} correspond au seuil {{status}} {{expression}}', + 'dataSourcesDialog.columns.plugin': 'PLUGIN', + 'dataSourcesDialog.columns.check': 'VÉRIFICATION', + 'dataSourcesDialog.columns.value': 'VALEUR', + 'dataSourcesDialog.columns.status': 'STATUT', + 'dataSourcesDialog.columns.lastSynced': 'DERNIÈRE SYNCHRONISATION', 'emptyState.altText': 'Pas de tableaux de score', 'emptyState.button': 'Afficher la documentation', 'emptyState.description': @@ -73,6 +83,8 @@ const scorecardTranslationFr = createTranslationMessages({ "Ce tableau de bord utilise un type d'agrégation qui n'est pas pris en charge par cette version du plugin.", 'errors.userNotFoundInCatalogMessage': 'Entité utilisateur introuvable dans le catalogue.', + 'metricGroupCard.menuAriaLabel': "Plus d'options", + 'metricGroupCard.viewDataSources': 'Voir les sources', 'metric.averageCenterTooltipMaxLabel': 'Score maximal possible', 'metric.averageCenterTooltipTotalLabel': 'Score total', 'metric.averageCenterTooltipBreakdownRow_one': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 242ab68d98..b5fb55fb4c 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -25,6 +25,16 @@ const scorecardTranslationIt = createTranslationMessages({ ref: scorecardTranslationRef, messages: { 'common.loading': 'Caricamento', + 'dataSourcesDialog.title': '{{title}} sorgenti', + 'dataSourcesDialog.close': 'Chiudere', + 'dataSourcesDialog.unknownPlugin': 'Sconosciuto', + 'dataSourcesDialog.statusTooltip': + 'Valore {{value}} corrisponde alla soglia {{status}} {{expression}}', + 'dataSourcesDialog.columns.plugin': 'PLUGIN', + 'dataSourcesDialog.columns.check': 'VERIFICA', + 'dataSourcesDialog.columns.value': 'VALORE', + 'dataSourcesDialog.columns.status': 'STATO', + 'dataSourcesDialog.columns.lastSynced': 'ULTIMA SINCRONIZZAZIONE', 'emptyState.altText': 'Nessuna scorecard', 'emptyState.button': 'Visualizza documentazione', 'emptyState.description': @@ -73,6 +83,8 @@ const scorecardTranslationIt = createTranslationMessages({ 'Questa scorecard utilizza un tipo di aggregazione non supportato da questa versione del plugin.', 'errors.userNotFoundInCatalogMessage': 'Entità utente non trovata nel catalogo.', + 'metricGroupCard.menuAriaLabel': 'Altre opzioni', + 'metricGroupCard.viewDataSources': 'Visualizza sorgenti', 'metric.averageCenterTooltipMaxLabel': 'Punteggio massimo possibile', 'metric.averageCenterTooltipTotalLabel': 'Punteggio totale', 'metric.averageCenterTooltipBreakdownRow_one': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index aaf9571662..3b3b0d3cd1 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -25,6 +25,16 @@ const scorecardTranslationJa = createTranslationMessages({ ref: scorecardTranslationRef, messages: { 'common.loading': '読み込み中', + 'dataSourcesDialog.title': '{{title}} ソース', + 'dataSourcesDialog.close': '閉じる', + 'dataSourcesDialog.unknownPlugin': '不明', + 'dataSourcesDialog.statusTooltip': + '値 {{value}} はしきい値 {{status}} {{expression}} に一致します', + 'dataSourcesDialog.columns.plugin': 'PLUGIN', + 'dataSourcesDialog.columns.check': 'チェック', + 'dataSourcesDialog.columns.value': '値', + 'dataSourcesDialog.columns.status': 'ステータス', + 'dataSourcesDialog.columns.lastSynced': '最終同期', 'emptyState.altText': 'スコアカードなし', 'emptyState.button': 'ドキュメントの表示', 'emptyState.description': @@ -72,6 +82,8 @@ const scorecardTranslationJa = createTranslationMessages({ 'このスコアカードは、このバージョンのプラグインでサポートされていない集計タイプを使用しています。', 'errors.userNotFoundInCatalogMessage': 'カタログにユーザーエンティティーが見つかりません。', + 'metricGroupCard.menuAriaLabel': 'その他のオプション', + 'metricGroupCard.viewDataSources': 'ソースを表示', 'metric.averageCenterTooltipMaxLabel': '最高スコア', 'metric.averageCenterTooltipTotalLabel': '合計スコア', 'metric.averageCenterTooltipBreakdownRow_one': diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index d76080342c..4d6392014a 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -177,6 +177,28 @@ export const scorecardMessages = { entities_other: '{{count}} entities', }, + // Data sources dialog + dataSourcesDialog: { + title: '{{title}} sources', + close: 'Close', + unknownPlugin: 'Unknown', + statusTooltip: + 'Value {{value}} matches threshold {{status}} {{expression}}', + columns: { + plugin: 'PLUGIN', + check: 'CHECK', + value: 'VALUE', + status: 'STATUS', + lastSynced: 'LAST SYNCED', + }, + }, + + // Metric group card menu + metricGroupCard: { + menuAriaLabel: 'More options', + viewDataSources: 'View data sources', + }, + // Entities page translations entitiesPage: { unknownMetric: 'Unknown metric', diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts index 7fffbb3391..a521ee1f75 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -29,4 +29,7 @@ export { resolveStatusColor, } from './statusUtils'; export { getThresholdRuleColor, getThresholdRuleIcon } from './thresholdUtils'; -export { resolveMetricTranslation } from './translationUtils'; +export { + resolveMetricTranslation, + extractPluginName, +} from './translationUtils'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts index fade4f125f..170a889e2e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts @@ -52,3 +52,24 @@ export function resolveMetricTranslation( return fallback ?? translated; } + +/** + * Extracts the plugin name from a metric ID. + * The plugin name is the first segment of the metric ID. + * E.g. filecheck.codeowners -> Filecheck + * github.open_prs -> Github + * gitlab.open_issues -> Gitlab + * sonarqube.code_smells -> Sonarqube + * etc. + * @param metricId - The metric ID. + * @param fallback - The fallback value if the metric ID is undefined. + * @returns The plugin name. + */ +export function extractPluginName( + metricId: string | undefined, + fallback: string, +): string { + if (!metricId) return fallback; + const prefix = metricId.split('.')[0] ?? metricId; + return prefix.charAt(0).toUpperCase() + prefix.slice(1); +} diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 784f938919..aefcae74fc 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -6594,41 +6594,41 @@ __metadata: languageName: node linkType: hard -"@floating-ui/core@npm:^1.7.3": - version: 1.7.3 - resolution: "@floating-ui/core@npm:1.7.3" +"@floating-ui/core@npm:^1.7.5": + version: 1.7.5 + resolution: "@floating-ui/core@npm:1.7.5" dependencies: - "@floating-ui/utils": "npm:^0.2.10" - checksum: 10c0/edfc23800122d81df0df0fb780b7328ae6c5f00efbb55bd48ea340f4af8c5b3b121ceb4bb81220966ab0f87b443204d37105abdd93d94846468be3243984144c + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/f9c52205e198b231d63a387b09c659aab08c46a1899e0b0bbe147b8b4f048b546f15ba17cb5d2a471da9534f1883d979425e13e5c4ceee67be63e4b0abd4db5d languageName: node linkType: hard -"@floating-ui/dom@npm:^1.7.4": - version: 1.7.4 - resolution: "@floating-ui/dom@npm:1.7.4" +"@floating-ui/dom@npm:^1.7.6": + version: 1.7.6 + resolution: "@floating-ui/dom@npm:1.7.6" dependencies: - "@floating-ui/core": "npm:^1.7.3" - "@floating-ui/utils": "npm:^0.2.10" - checksum: 10c0/da6166c25f9b0729caa9f498685a73a0e28251613b35d27db8de8014bc9d045158a23c092b405321a3d67c2064909b6e2a7e6c1c9cc0f62967dca5779f5aef30 + "@floating-ui/core": "npm:^1.7.5" + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/5c098e0d7b58c9bc769f276cca1766994c2c9c70c92d091a61bba8b3e9be53c011e0a79a8457fc2fb2f3d91697a26eb52e0a4962ef936dc963b45f58613c212f languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.0.0": - version: 2.1.6 - resolution: "@floating-ui/react-dom@npm:2.1.6" +"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.0.8": + version: 2.1.8 + resolution: "@floating-ui/react-dom@npm:2.1.8" dependencies: - "@floating-ui/dom": "npm:^1.7.4" + "@floating-ui/dom": "npm:^1.7.6" peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 10c0/6654834a8e73ecbdbc6cad2ad8f7abc698ac7c1800ded4d61113525c591c03d2e3b59d3cf9205859221465ea38c87af4f9e6e204703c5b7a7e85332d1eef2e18 + checksum: 10c0/26260ca4bb23b57c73b824062505abf977a008ce6e0463bdacca74f7e49853c4cd1d2bbf1a77c6caa17fa37dfffda2c6c4cd07a8737ebd7474aaff7818401d75 languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.10": - version: 0.2.10 - resolution: "@floating-ui/utils@npm:0.2.10" - checksum: 10c0/e9bc2a1730ede1ee25843937e911ab6e846a733a4488623cd353f94721b05ec2c9ec6437613a2ac9379a94c2fd40c797a2ba6fa1df2716f5ce4aa6ddb1cf9ea4 +"@floating-ui/utils@npm:^0.2.11": + version: 0.2.11 + resolution: "@floating-ui/utils@npm:0.2.11" + checksum: 10c0/f4bcea1559bdbb721ecc8e8ead423ac58d6a5b6e70b602cf0810ba6ad4ed1c77211b207faa88b278a9042f0c743133de08a203ed6741c1b6443423332884d5b3 languageName: node linkType: hard @@ -8524,6 +8524,28 @@ __metadata: languageName: node linkType: hard +"@mui/base@npm:5.0.0-beta.40-1": + version: 5.0.0-beta.40-1 + resolution: "@mui/base@npm:5.0.0-beta.40-1" + dependencies: + "@babel/runtime": "npm:^7.23.9" + "@floating-ui/react-dom": "npm:^2.0.8" + "@mui/types": "npm:~7.2.15" + "@mui/utils": "npm:^5.17.1" + "@popperjs/core": "npm:^2.11.8" + clsx: "npm:^2.1.0" + prop-types: "npm:^15.8.1" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/3f00ef8b57f64e3c11958f061982995b99b753efe161743ee53b63d290aa683c7b894dcfba71b44361686c1ff342db6046737082a84020716a2965a662374ef8 + languageName: node + linkType: hard + "@mui/core-downloads-tracker@npm:^5.18.0": version: 5.18.0 resolution: "@mui/core-downloads-tracker@npm:5.18.0" @@ -8547,6 +8569,35 @@ __metadata: languageName: node linkType: hard +"@mui/lab@npm:5.0.0-alpha.177": + version: 5.0.0-alpha.177 + resolution: "@mui/lab@npm:5.0.0-alpha.177" + dependencies: + "@babel/runtime": "npm:^7.23.9" + "@mui/base": "npm:5.0.0-beta.40-1" + "@mui/system": "npm:^5.18.0" + "@mui/types": "npm:~7.2.15" + "@mui/utils": "npm:^5.17.1" + clsx: "npm:^2.1.0" + prop-types: "npm:^15.8.1" + peerDependencies: + "@emotion/react": ^11.5.0 + "@emotion/styled": ^11.3.0 + "@mui/material": ">=5.15.0" + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + "@types/react": + optional: true + checksum: 10c0/ccba45f3379e25dcf7c238e724d244962cdf13b9d6a5d5554af73e3da90ba548ffa300be7844229817a70fcf5d3dbf163300a1967e00a6cb5bf7c4ca80232523 + languageName: node + linkType: hard + "@mui/material@npm:5.18.0, @mui/material@npm:^5.12.2, @mui/material@npm:^5.14.18, @mui/material@npm:^5.18.0": version: 5.18.0 resolution: "@mui/material@npm:5.18.0" @@ -12453,6 +12504,7 @@ __metadata: "@backstage/theme": "npm:^0.7.2" "@backstage/ui": "npm:^0.13.2" "@mui/icons-material": "npm:5.18.0" + "@mui/lab": "npm:5.0.0-alpha.177" "@mui/material": "npm:5.18.0" "@mui/styles": "npm:5.18.0" "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^"