From 427182974ee0a0baa72a3a919f09a3e39761b34e Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Thu, 26 Mar 2026 17:56:26 +0100 Subject: [PATCH 1/8] Introduce icons Signed-off-by: Dominika Zemanovicova --- .../scorecard/.changeset/fancy-rice-rush.md | 8 ++ .../scorecard/packages/app-legacy/src/App.tsx | 8 ++ workspaces/scorecard/packages/app/src/App.tsx | 2 + .../packages/app/src/modules/icons/index.ts | 39 ++++++ .../plugins/scorecard-backend/config.d.ts | 47 ++++++- .../scorecard-backend/docs/thresholds.md | 62 +++++++++ .../mergeEntityAndProviderThresholds.test.ts | 14 +- .../plugins/scorecard-common/report.api.md | 1 + .../scorecard-common/src/types/threshold.ts | 34 +++++ .../thresholds/validateThresholds.test.ts | 111 +++++++++++++--- .../utils/thresholds/validateThresholds.ts | 25 +++- .../scorecard/plugins/scorecard/report.api.md | 9 ++ .../Scorecard/EntityScorecardContent.tsx | 2 +- .../src/components/Scorecard/Scorecard.tsx | 15 ++- .../__tests__/EntityScorecardContent.test.tsx | 4 +- .../Scorecard/__tests__/Scorecard.test.tsx | 31 +++-- .../ScorecardIcon/ScorecardIcon.tsx | 78 +++++++++++ .../scorecard/plugins/scorecard/src/index.ts | 4 + .../src/utils/__tests__/colorUtils.test.tsx | 124 ------------------ .../src/utils/__tests__/statusUtils.test.tsx | 104 ++++++++++++--- .../utils/__tests__/thresholdUtils.test.tsx | 102 ++++++++++++++ .../plugins/scorecard/src/utils/colorUtils.ts | 83 ------------ .../plugins/scorecard/src/utils/index.ts | 6 +- .../scorecard/src/utils/statusUtils.ts | 60 +++++++-- .../scorecard/src/utils/thresholdUtils.ts | 62 +++++++++ 25 files changed, 748 insertions(+), 287 deletions(-) create mode 100644 workspaces/scorecard/.changeset/fancy-rice-rush.md create mode 100644 workspaces/scorecard/packages/app/src/modules/icons/index.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx delete mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/colorUtils.test.tsx create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/thresholdUtils.test.tsx delete mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/colorUtils.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/thresholdUtils.ts diff --git a/workspaces/scorecard/.changeset/fancy-rice-rush.md b/workspaces/scorecard/.changeset/fancy-rice-rush.md new file mode 100644 index 00000000000..7b9e72a57b3 --- /dev/null +++ b/workspaces/scorecard/.changeset/fancy-rice-rush.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-common': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-node': minor +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Introduces custom threshold rule icons that can be configured in `app-config.yaml`. diff --git a/workspaces/scorecard/packages/app-legacy/src/App.tsx b/workspaces/scorecard/packages/app-legacy/src/App.tsx index 5a2896d6d84..83fe15bba90 100644 --- a/workspaces/scorecard/packages/app-legacy/src/App.tsx +++ b/workspaces/scorecard/packages/app-legacy/src/App.tsx @@ -57,6 +57,9 @@ import { getThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; import { ScorecardHomepageCard, ScorecardPage, + ScorecardErrorStatusIcon, + ScorecardSuccessStatusIcon, + ScorecardWarningStatusIcon, } from '@red-hat-developer-hub/backstage-plugin-scorecard'; import { ScalprumContext, ScalprumState } from '@scalprum/react-core'; @@ -274,6 +277,11 @@ const scalprumState: ScalprumState = { const app = createApp({ apis, + icons: { + scorecardSuccessStatusIcon: ScorecardSuccessStatusIcon, + scorecardWarningStatusIcon: ScorecardWarningStatusIcon, + scorecardErrorStatusIcon: ScorecardErrorStatusIcon, + }, themes: getThemes(), __experimentalTranslations: { availableLanguages: ['en', 'de', 'es', 'fr', 'it', 'ja'], diff --git a/workspaces/scorecard/packages/app/src/App.tsx b/workspaces/scorecard/packages/app/src/App.tsx index b2ea4008298..347638fd853 100644 --- a/workspaces/scorecard/packages/app/src/App.tsx +++ b/workspaces/scorecard/packages/app/src/App.tsx @@ -23,6 +23,7 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard/alpha'; import { signInModule } from './modules/signIn'; import { navModule } from './modules/nav'; +import { iconsModule } from './modules/icons'; /* * app: Backstage app using the New Frontend System (NFS). @@ -32,6 +33,7 @@ const app = createApp({ rhdhThemeModule, scorecardCatalogModule, scorecardTranslationsModule, + iconsModule, signInModule, navModule, ], diff --git a/workspaces/scorecard/packages/app/src/modules/icons/index.ts b/workspaces/scorecard/packages/app/src/modules/icons/index.ts new file mode 100644 index 00000000000..271499229ca --- /dev/null +++ b/workspaces/scorecard/packages/app/src/modules/icons/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { IconBundleBlueprint } from '@backstage/plugin-app-react'; + +import { + ScorecardErrorStatusIcon, + ScorecardSuccessStatusIcon, + ScorecardWarningStatusIcon, +} from '@red-hat-developer-hub/backstage-plugin-scorecard'; + +export const iconsModule = createFrontendModule({ + pluginId: 'app', + extensions: [ + IconBundleBlueprint.make({ + params: { + icons: { + scorecardSuccessStatusIcon: ScorecardSuccessStatusIcon, + scorecardWarningStatusIcon: ScorecardWarningStatusIcon, + scorecardErrorStatusIcon: ScorecardErrorStatusIcon, + }, + }, + }), + ], +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend/config.d.ts index 2f899aefbf6..e9a5bceb5ba 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/config.d.ts @@ -43,17 +43,58 @@ export interface Config { [metricName: string]: { /** Threshold configuration for the metric */ thresholds?: { + /** + * Rules describe how metric values are categorized and how that category is presented in the UI. + * They are evaluated in order and the first matching rule is applied. + */ rules?: Array<{ + /** + * Threshold category key that a metric value is assigned to when this rule + * matches (for example `success`, `warning`, `error`, or a custom key). + */ key: string; - /** Threshold expression - supports: >=, <=, >, <, ==, !=, - (range) */ + /** + * Threshold expression that determines whether a metric value matches this + * rule. Supports:`>=`, `<=`, `>`, `<`, `==`, `!=`, `-` (range). + * + * @example `<= 10` - Metric value must be less than or equal to 10. + * @example `10-60` - Metric value must be between 10 and 60 (inclusive). + */ expression: string; /** - * Color for this threshold rule. Can be a theme palette path (e.g., 'error.main') - * or a direct color value (e.g., '#ADD8E6', 'blue', 'rgb(255,255,0)') + * Color configuration - supports multiple formats: + * - theme palette reference (`success.main` / `warning.main` / `error.main`) + * - HEX code (e.g. '#FFA500') + * - RGB/RGBA (e.g. 'rgb(255, 0, 0)') + * + * Threshold rules 'success', 'warning' and 'error' have default colors. */ color?: string; + /** + * Icon configuration - supports multiple formats: + * - Backstage system icons: 'kind:component', 'kind:api', etc. + * - Material Design icons: 'settings', 'home', 'build', etc. + * - SVG strings: '...' + * - URLs: 'https://example.com/icon.png', '/assets/icon.svg' + * - Data URIs: 'data:image/svg+xml;base64,...' + * + * Threshold rules 'success', 'warning' and 'error' have default icons. + */ + icon?: string; }>; }; + /** + * Schedule for collecting this metric. If not set, the default hourly schedule is used. + * + * Default schedule: + * ```ts + * { + * frequency: { hours: 1 }, + * timeout: { minutes: 15 }, + * initialDelay: { minutes: 1 }, + * } + * ``` + */ schedule?: SchedulerServiceTaskScheduleDefinitionConfig; }; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md index 052486778bd..6265cd76b88 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -9,6 +9,7 @@ Thresholds are evaluated in order and the **first matching** threshold rule is a - **`key`**: The threshold category (e.g., `success`, `warning`, `error`, or custom keys) - **`expression`**: The condition that determines if a metric value matches this threshold - **`color`** (optional): The color to display for this threshold in the UI (see [Threshold Colors](#threshold-colors)) +- **`icon`** (optional): The icon to display for this threshold in the UI (see [Threshold Icons](#threshold-icons)) ## Threshold Configuration Options @@ -287,6 +288,67 @@ If no color is specified for a threshold rule, frontend will use these default c **Important:** Custom threshold keys (not `success`, `warning`, or `error`) **must** specify a `color` property. The configuration will fail validation if a custom key is used without a color. This requirement ensures that all thresholds can be properly visualized in the UI. +## Threshold Icons + +You can customize the icon displayed for each threshold rule in the scorecard UI by adding an `icon` property. + +### Icon Configuration + +Add an `icon` property to any threshold rule: + +```yaml +scorecard: + plugins: + myDatasource: + myMetric: + thresholds: + rules: + - key: success + expression: '<10' + icon: scorecardSuccessStatusIcon + - key: warning + expression: '10-50' + icon: '...' + - key: error + expression: '>50' + icon: 'customIcon' +``` + +### Supported Icon Formats + +The `icon` value is a string and can be one of: + +- **Backstage System Icon**: `kind:component`, `kind:api`, your `customIcon` registered with Backstage +- **Material Design Icon**: `settings`, `home`, `build` +- **SVG String**: `...` +- **Image URL**: `http(s)://...`, `/assets/icon.svg` +- **Data URI**: `data:image/svg+xml;base64,...` + +For information on registering custom icons with Backstage, see [Adding Icons](https://backstage.io/docs/conf/user-interface/icons/#adding-icons). +To use your custom Backstage System icons in RHDH for Scorecard, you will need to add `appIcons` key to the plugin that is exporting them: + +```yaml +dynamicPlugins: + rootDirectory: dynamic-plugins-root + frontend: + example.plugin-custom: + appIcons: + - name: customIcon + importName: CustomIcon +``` + +### Default icons + +If no icon is specified for a threshold rule, the frontend uses default icons for standard threshold rule keys: + +| Rule Key | Default icon constant | Material Design Icon | +| -------- | ---------------------------- | -------------------- | +| success | `scorecardSuccessStatusIcon` | CheckCircleOutline | +| warning | `scorecardWarningStatusIcon` | WarningAmber | +| error | `scorecardErrorStatusIcon` | DangerousOutlined | + +**Important:** Custom threshold keys (not `success`, `warning`, or `error`) **must** specify an `icon` property. The configuration will fail validation if a custom key is used without an icon. This requirement ensures that all thresholds can be properly visualized in the UI. + ## ThresholdEvaluator The `ThresholdEvaluator` service processes threshold rules and determines which threshold a metric value matches. diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/utils/mergeEntityAndProviderThresholds.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/utils/mergeEntityAndProviderThresholds.test.ts index 487d2817a65..cd354a3e5c5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/utils/mergeEntityAndProviderThresholds.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/utils/mergeEntityAndProviderThresholds.test.ts @@ -215,7 +215,12 @@ describe('mergeEntityAndProviderThresholds', () => { return { rules: [ { key: 'low', expression: '<10', color: 'success.main' }, - { key: 'high', expression: '10-20', color: '#FF0000' }, + { + key: 'high', + expression: '10-20', + color: '#FF0000', + icon: 'scorecardWarningStatusIcon', + }, { key: 'error', expression: '>20' }, ], }; @@ -237,7 +242,12 @@ describe('mergeEntityAndProviderThresholds', () => { expect(result).toEqual({ rules: [ { key: 'low', expression: '<10', color: 'success.main' }, - { key: 'high', expression: '10-60', color: '#FF0000' }, + { + key: 'high', + expression: '10-60', + color: '#FF0000', + icon: 'scorecardWarningStatusIcon', + }, { key: 'error', expression: '>60' }, ], }); diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index 3e9e1a00780..dee33af7960 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -150,6 +150,7 @@ export type ThresholdRule = { key: string; expression: string; color?: string; + icon?: string; }; // (No @packageDocumentation comment for this package) diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts index 314a727e765..30e020eeb61 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts @@ -19,9 +19,39 @@ * @public */ export type ThresholdRule = { + /** + * Threshold category key that a metric value is assigned to when this rule + * matches (for example `success`, `warning`, `error`, or a custom key). + */ key: string; + /** + * Threshold expression that determines whether a metric value matches this + * rule. Supports:`>=`, `<=`, `>`, `<`, `==`, `!=`, `-` (range). + * + * @example `<= 10` - Metric value must be less than or equal to 10. + * @example `10-60` - Metric value must be between 10 and 60 (inclusive). + */ expression: string; + /** + * Color configuration - supports multiple formats: + * - theme palette reference (`success.main` / `warning.main` / `error.main`) + * - HEX code (e.g. '#FFA500') + * - RGB/RGBA (e.g. 'rgb(255, 0, 0)') + * + * Threshold rules 'success', 'warning' and 'error' have default colors. + */ color?: string; + /** + * Icon configuration - supports multiple formats: + * - Backstage system icons: 'kind:component', 'kind:api', etc. + * - Material Design icons: 'settings', 'home', 'build', etc. + * - SVG strings: '...' + * - URLs: 'https://example.com/icon.png', '/assets/icon.svg' + * - Data URIs: 'data:image/svg+xml;base64,...' + * + * Threshold rules 'success', 'warning' and 'error' have default icons. + */ + icon?: string; }; /** @@ -29,6 +59,10 @@ export type ThresholdRule = { * @public */ export type ThresholdConfig = { + /** + * Rules describe how metric values are categorized and how that category is presented in the UI. + * They are evaluated in order and the first matching rule is applied. + */ rules: ThresholdRule[]; }; diff --git a/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.test.ts b/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.test.ts index a3cf9366ce2..328b8a4234a 100644 --- a/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.test.ts +++ b/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.test.ts @@ -61,13 +61,33 @@ describe('validateThresholds', () => { expect(() => validateThresholds(validConfig, 'number')).not.toThrow(); }); - it('should validate config with custom threshold keys and colors', () => { + it('should validate config with custom threshold keys, colors and icons', () => { const validConfig = { rules: [ - { key: 'critical', expression: '>80', color: '#d32f2f' }, - { key: 'high', expression: '60-79', color: '#ff9800' }, - { key: 'medium', expression: '40-59', color: '#ffc107' }, - { key: 'low', expression: '20-39', color: '#4caf50' }, + { + key: 'critical', + expression: '>80', + color: '#d32f2f', + icon: 'scorecardErrorStatusIcon', + }, + { + key: 'high', + expression: '60-79', + color: '#ff9800', + icon: '', + }, + { + key: 'medium', + expression: '40-59', + color: '#ffc107', + icon: 'kind:component', + }, + { + key: 'low', + expression: '20-39', + color: '#4caf50', + icon: 'https://raw.githubusercontent.com/redhat-developer/example/main/icons/scorecard-icon.svg', + }, { key: 'success', expression: '<20' }, ], }; @@ -236,13 +256,64 @@ describe('validateThresholds', () => { const config = { rules: [ { key: 'success', expression: '<20' }, - { key: 'critical', expression: '>=20' }, + { + key: 'critical', + expression: '>=20', + icon: 'scorecardErrorStatusIcon', + }, + ], + }; + + expect(() => validateThresholds(config, 'number')).toThrow( + new ThresholdConfigFormatError( + "Custom threshold key \"critical\" must specify a color or icon property. Only standard keys ('success', 'warning', 'error') have default colors and icons.", + ), + ); + }); + + it('should throw error for custom threshold key without icon', () => { + const config = { + rules: [ + { key: 'success', expression: '<20' }, + { + key: 'critical', + expression: '>=20', + color: '#FF0000', + }, + ], + }; + + expect(() => validateThresholds(config, 'number')).toThrow( + new ThresholdConfigFormatError( + "Custom threshold key \"critical\" must specify a color or icon property. Only standard keys ('success', 'warning', 'error') have default colors and icons.", + ), + ); + }); + + it('should throw error for invalid icon type', () => { + const config = { + rules: [ + { key: 'critical', expression: '>=20', color: '#FF0000', icon: 123 }, + ], + }; + + expect(() => validateThresholds(config, 'number')).toThrow( + new ThresholdConfigFormatError( + 'Invalid icon format for rule "critical": icon must be a non-empty string', + ), + ); + }); + + it('should throw error for empty icon string', () => { + const config = { + rules: [ + { key: 'critical', expression: '>=20', color: '#FF0000', icon: '' }, ], }; expect(() => validateThresholds(config, 'number')).toThrow( new ThresholdConfigFormatError( - "Custom threshold key \"critical\" must specify a color property. Only standard keys ('success', 'warning', 'error') have default colors.", + 'Invalid icon format for rule "critical": icon must be a non-empty string', ), ); }); @@ -251,50 +322,52 @@ describe('validateThresholds', () => { { description: 'missing # in hex color', config: { - rules: [{ key: 'custom', expression: '<5', color: 'FF5733' }], + rules: [{ key: 'success', expression: '<5', color: 'FF5733' }], }, expectedError: - 'Invalid color format for rule "custom": "FF5733" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', + 'Invalid color format for rule "success": "FF5733" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', }, { description: 'invalid hex characters', config: { - rules: [{ key: 'custom', expression: '<5', color: '#GGGGGG' }], + rules: [{ key: 'success', expression: '<5', color: '#GGGGGG' }], }, expectedError: - 'Invalid color format for rule "custom": "#GGGGGG" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', + 'Invalid color format for rule "success": "#GGGGGG" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', }, { description: 'invalid predefined constant', config: { - rules: [{ key: 'custom', expression: '<5', color: 'invalid.color' }], + rules: [{ key: 'success', expression: '<5', color: 'invalid.color' }], }, expectedError: - 'Invalid color format for rule "custom": "invalid.color" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', + 'Invalid color format for rule "success": "invalid.color" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', }, { description: 'empty color string', config: { - rules: [{ key: 'custom', expression: '<5', color: '' }], + rules: [{ key: 'success', expression: '<5', color: '' }], }, expectedError: - 'Invalid color format for rule "custom": color must be a non-empty string', + 'Invalid color format for rule "success": color must be a non-empty string', }, { description: 'non-string color', config: { - rules: [{ key: 'custom', expression: '<5', color: 123 } as any], + rules: [{ key: 'success', expression: '<5', color: 123 }], }, expectedError: - 'Invalid color format for rule "custom": color must be a non-empty string', + 'Invalid color format for rule "success": color must be a non-empty string', }, { description: 'RGB with missing comma', config: { - rules: [{ key: 'custom', expression: '<5', color: 'rgb(50, 87 37)' }], + rules: [ + { key: 'success', expression: '<5', color: 'rgb(50, 87 37)' }, + ], }, expectedError: - 'Invalid color format for rule "custom": "rgb(50, 87 37)" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', + 'Invalid color format for rule "success": "rgb(50, 87 37)" must be either a predefined constant (\'success.main\', \'warning.main\', \'error.main\'), a hex color (e.g., "#ADD8E6"), or an RGB/RGBA color (e.g., "rgb(255, 255, 0)")', }, ])( 'should throw error for invalid color: $description', diff --git a/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.ts b/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.ts index 8c5e9299884..37a14930f85 100644 --- a/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.ts +++ b/workspaces/scorecard/plugins/scorecard-node/src/utils/thresholds/validateThresholds.ts @@ -85,6 +85,21 @@ function validateRuleColor(rule: ThresholdRule): void { } } +/** + * Validates the icon format if present in a rule. + */ +function validateRuleIcon(rule: ThresholdRule): void { + if (!('icon' in rule)) { + return; + } + + if (typeof rule.icon !== 'string' || rule.icon.trim() === '') { + throw new ThresholdConfigFormatError( + `Invalid icon format for rule "${rule.key}": icon must be a non-empty string`, + ); + } +} + function isThresholdRule(rule: unknown): asserts rule is ThresholdRule { if ( typeof rule !== 'object' || @@ -127,15 +142,19 @@ export function validateThresholds( for (const rule of thresholds.rules) { isThresholdRule(rule); validateRuleColor(rule); + validateRuleIcon(rule); const standardThresholdRuleKeys = ['success', 'warning', 'error']; - if (!standardThresholdRuleKeys.includes(rule.key) && !('color' in rule)) { + if ( + !standardThresholdRuleKeys.includes(rule.key) && + (!('color' in rule) || !('icon' in rule)) + ) { throw new ThresholdConfigFormatError( `Custom threshold key "${ rule.key - }" must specify a color property. Only standard keys (${standardThresholdRuleKeys + }" must specify a color or icon property. Only standard keys (${standardThresholdRuleKeys .map(k => `'${k}'`) - .join(', ')}) have default colors.`, + .join(', ')}) have default colors and icons.`, ); } diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index f6309692a44..df5838e2efa 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -5,12 +5,17 @@ ```ts import { BackstagePlugin } from '@backstage/core-plugin-api'; import { JSX as JSX_2 } from 'react/jsx-runtime'; +import { default as ScorecardErrorStatusIcon } from '@mui/icons-material/DangerousOutlined'; +import { default as ScorecardSuccessStatusIcon } from '@mui/icons-material/CheckCircleOutline'; +import { default as ScorecardWarningStatusIcon } from '@mui/icons-material/WarningAmber'; import { TranslationRef } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; // @public export const EntityScorecardContent: () => JSX_2.Element; +export { ScorecardErrorStatusIcon }; + // @public export const ScorecardHomepageCard: ({ metricId, @@ -28,6 +33,8 @@ export const ScorecardPage: () => JSX_2.Element; // @public export const scorecardPlugin: BackstagePlugin<{}, {}, {}>; +export { ScorecardSuccessStatusIcon }; + // @public export const scorecardTranslationRef: TranslationRef< 'plugin.scorecard', @@ -96,5 +103,7 @@ export const scorecardTranslationRef: TranslationRef< // @public export const scorecardTranslations: TranslationResource<'plugin.scorecard'>; +export { ScorecardWarningStatusIcon }; + // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx index 282b1222594..a0394f02493 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -98,7 +98,7 @@ export const EntityScorecardContent = () => { cardTitle={finalTitle} description={finalDescription} statusColor={statusConfig.color} - StatusIcon={statusConfig.icon ?? (() => null)} + statusIcon={statusConfig.icon ?? ''} value={metric.result?.value} thresholds={metric.result?.thresholdResult} isMetricDataError={isMetricDataError} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx index 373b1ef40d7..e1701898276 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx @@ -35,6 +35,7 @@ import { useTheme } from '@mui/material/styles'; import { useTranslation } from '../../hooks/useTranslation'; import { CardWrapper } from '../Common/CardWrapper'; import CustomLegend from './CustomLegend'; +import { ScorecardIcon } from '../ScorecardIcon/ScorecardIcon'; import { getHeightForCenterLabel, getYOffsetForCenterLabel, @@ -47,7 +48,7 @@ interface ScorecardProps { cardTitle: string; description: string; statusColor: string; - StatusIcon: React.ElementType; + statusIcon: string; value: MetricValue | null; thresholds?: ThresholdResult; isMetricDataError?: boolean; @@ -59,7 +60,7 @@ interface ScorecardProps { const ScorecardCenterLabel = ({ cx, cy, - StatusIcon, + statusIcon, value, isErrorState, errorLabel, @@ -69,7 +70,7 @@ const ScorecardCenterLabel = ({ }: { cx: number; cy: number; - StatusIcon: React.ElementType; + statusIcon: string; value: MetricValue | null; isErrorState: boolean; errorLabel: string; @@ -104,7 +105,9 @@ const ScorecardCenterLabel = ({ return ( - { value, loading, statusColor, - StatusIcon, + statusIcon, thresholds, isThresholdError, thresholdError, @@ -63,7 +63,7 @@ jest.mock('../Scorecard', () => {

{description}

Value: {value} Status: {statusColor} - {StatusIcon && Status Icon} + {statusIcon && Status Icon} {thresholds && Thresholds} {isThresholdError && ( diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx index 2d9d42b1784..891f5e5ab05 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx @@ -16,12 +16,20 @@ import { render, screen } from '@testing-library/react'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import DangerousOutlinedIcon from '@mui/icons-material/DangerousOutlined'; import Scorecard from '../Scorecard'; +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApp: () => ({ + getSystemIcon: (key: string) => { + return function MockSystemIcon() { + return ; + }; + }, + }), +})); + jest.mock('recharts', () => ({ ResponsiveContainer: ({ children }: any) => (
{children}
@@ -78,7 +86,7 @@ describe('Scorecard Component', () => { description: 'Current count of open Pull Requests for a given GitHub repository.', statusColor: 'success.main', - StatusIcon: CheckCircleOutlineIcon, + statusIcon: 'scorecardSuccessStatusIcon', value: 8, thresholds: { status: 'success' as const, @@ -137,10 +145,11 @@ describe('Scorecard Component', () => { , ); - const iconElement = container.querySelector( - '[data-testid="CheckCircleOutlineIcon"]', - ); - expect(iconElement).toBeInTheDocument(); + expect( + container.querySelector( + '[data-testid="system-icon-scorecardSuccessStatusIcon"]', + ), + ).toBeInTheDocument(); }); it('should handle zero value correctly', () => { @@ -170,7 +179,7 @@ describe('Scorecard Component', () => { const warningProps = { ...defaultProps, statusColor: 'warning.main', - StatusIcon: WarningAmberIcon, + statusIcon: 'scorecardWarningStatusIcon', value: 25, }; @@ -191,7 +200,7 @@ describe('Scorecard Component', () => { const errorProps = { ...defaultProps, statusColor: 'error.main', - StatusIcon: DangerousOutlinedIcon, + statusIcon: 'scorecardErrorStatusIcon', value: 75, }; @@ -212,7 +221,7 @@ describe('Scorecard Component', () => { const customProps = { ...defaultProps, statusColor: '#FF5733', - StatusIcon: CheckCircleOutlineIcon, + statusIcon: 'scorecardSuccessStatusIcon', value: 4, thresholds: { ...defaultProps.thresholds, diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx new file mode 100644 index 00000000000..0f0e4964b45 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useApp } from '@backstage/core-plugin-api'; +import Box from '@mui/material/Box'; +import MuiIcon from '@mui/material/Icon'; +import type { SxProps, Theme } from '@mui/material/styles'; + +/** + * @public + */ +export interface ScorecardIconProps { + icon: string; + size?: 'small' | 'medium' | 'large'; + sx?: SxProps; +} + +/** + * @public + */ +export const ScorecardIcon = ({ + icon, + size = 'small', + sx, +}: ScorecardIconProps) => { + const app = useApp(); + if (!icon) { + return null; + } + + const SystemIcon = app.getSystemIcon(icon); + if (SystemIcon) { + return ( + + + + ); + } + + if (icon.startsWith(' + + + ); + } + + if ( + icon.startsWith('https://') || + icon.startsWith('http://') || + icon.startsWith('/') + ) { + return ( + + + + ); + } + + return ( + + {icon} + + ); +}; diff --git a/workspaces/scorecard/plugins/scorecard/src/index.ts b/workspaces/scorecard/plugins/scorecard/src/index.ts index 65891242f3f..6605a3dba91 100644 --- a/workspaces/scorecard/plugins/scorecard/src/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/index.ts @@ -30,3 +30,7 @@ ClassNameGenerator.configure(componentName => { export * from './plugin'; export { scorecardTranslations, scorecardTranslationRef } from './translations'; + +export { default as ScorecardSuccessStatusIcon } from '@mui/icons-material/CheckCircleOutline'; +export { default as ScorecardWarningStatusIcon } from '@mui/icons-material/WarningAmber'; +export { default as ScorecardErrorStatusIcon } from '@mui/icons-material/DangerousOutlined'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/colorUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/colorUtils.test.tsx deleted file mode 100644 index 3f21cb0bc5e..00000000000 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/colorUtils.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 { Theme } from '@mui/material/styles'; -import { - DEFAULT_NUMBER_THRESHOLDS, - ThresholdRule, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { - getThresholdRuleColor, - resolveStatusColor, - SCORECARD_ERROR_STATE_COLOR, -} from '..'; - -describe('colorUtils', () => { - describe('getThresholdRuleColor', () => { - const mockRules: ThresholdRule[] = [ - { key: 'custom', expression: '<5', color: '#FF5733' }, - { key: 'success', expression: '<10', color: '#4caf50' }, - { key: 'warning', expression: '10-50' }, - { key: 'critical', expression: '>50', color: 'error.main' }, - ]; - - it('should return color for matching threshold key', () => { - expect(getThresholdRuleColor(mockRules, 'custom')).toEqual('#FF5733'); - expect(getThresholdRuleColor(mockRules, 'success')).toEqual('#4caf50'); - expect(getThresholdRuleColor(mockRules, 'critical')).toEqual( - 'error.main', - ); - }); - - it('should return undefined for non-matching key', () => { - expect(getThresholdRuleColor(mockRules, 'low')).toBeUndefined(); - }); - - it('should return default color for default rule keys', () => { - expect( - getThresholdRuleColor(DEFAULT_NUMBER_THRESHOLDS.rules, 'success'), - ).toEqual('success.main'); - expect( - getThresholdRuleColor(DEFAULT_NUMBER_THRESHOLDS.rules, 'warning'), - ).toEqual('warning.main'); - expect( - getThresholdRuleColor(DEFAULT_NUMBER_THRESHOLDS.rules, 'error'), - ).toEqual('error.main'); - }); - - it('should handle empty rules array', () => { - expect(getThresholdRuleColor([], 'medium')).toBeUndefined(); - }); - }); - - describe('resolveStatusColor', () => { - const mockTheme = { - palette: { - primary: { main: '#0066cc' }, - success: { main: '#2e7d32' }, - warning: { main: '#ed6c02' }, - error: { main: '#d32f2f' }, - rhdh: { - general: { - cardBorderColor: '#c7c7c7', - }, - }, - }, - } as any as Theme; - - it('should resolve theme palette reference', () => { - const color = resolveStatusColor(mockTheme, 'success.main'); - expect(color).toBe('#2e7d32'); - }); - - it('should resolve theme reference with nested levels', () => { - const color = resolveStatusColor(mockTheme, SCORECARD_ERROR_STATE_COLOR); - expect(color).toBe('#c7c7c7'); - }); - - it('should return custom hex color directly', () => { - const color = resolveStatusColor(mockTheme, '#9933ff'); - expect(color).toBe('#9933ff'); - }); - - it('should return custom rgb color directly', () => { - const color = resolveStatusColor(mockTheme, 'rgb(255, 0, 0)'); - expect(color).toBe('rgb(255, 0, 0)'); - }); - - it('should return custom rgba color directly', () => { - const color = resolveStatusColor(mockTheme, 'rgba(255, 0, 0, 0.7)'); - expect(color).toBe('rgba(255, 0, 0, 0.7)'); - }); - - it('should fallback to cardBorderColor when theme path not found', () => { - const color = resolveStatusColor(mockTheme, 'nonexistent.path'); - expect(color).toBe('#c7c7c7'); - }); - - it('should fallback to error.main when theme path not found and cardBorderColor is undefined', () => { - const themeWithoutCardBorder = { - palette: { - error: { main: '#d32f2f' }, - }, - } as any as Theme; - const color = resolveStatusColor( - themeWithoutCardBorder, - 'nonexistent.path', - ); - expect(color).toBe('#d32f2f'); - }); - }); -}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx index 92f26cdeb6c..49c083d5e5f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx @@ -14,12 +14,13 @@ * limitations under the License. */ -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import DangerousOutlinedIcon from '@mui/icons-material/DangerousOutlined'; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; - +import type { Theme } from '@mui/material/styles'; import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { getStatusConfig, SCORECARD_ERROR_STATE_COLOR } from '..'; +import { + getStatusConfig, + resolveStatusColor, + SCORECARD_ERROR_STATE_COLOR, +} from '..'; describe('statusUtils', () => { describe('getStatusConfig', () => { @@ -96,7 +97,7 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'error.main', - icon: DangerousOutlinedIcon, + icon: 'scorecardErrorStatusIcon', }); }); @@ -110,7 +111,7 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'warning.main', - icon: WarningAmberIcon, + icon: 'scorecardWarningStatusIcon', }); }); @@ -124,16 +125,26 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'success.main', - icon: CheckCircleOutlineIcon, + icon: 'scorecardSuccessStatusIcon', }); }); - it('should return custom color from threshold configuration', () => { + it('should return custom color and icon from threshold configuration', () => { const mockThresholds = { rules: [ - { key: 'critical', expression: '>80', color: '#ff0000' }, + { + key: 'critical', + expression: '>80', + color: '#ff0000', + icon: 'scorecardErrorStatusIcon', + }, { key: 'warning', expression: '40-79', color: '#ffa500' }, - { key: 'success', expression: '<40', color: '#00ff00' }, + { + key: 'success', + expression: '<40', + color: '#00ff00', + icon: 'customIcon', + }, ], }; @@ -144,11 +155,11 @@ describe('statusUtils', () => { expect(result).toEqual({ color: '#00ff00', - icon: CheckCircleOutlineIcon, + icon: 'customIcon', }); }); - it('should return default color for default rule keys', () => { + it('should return default color and icon for default rule keys', () => { const result = getStatusConfig({ evaluation: 'success', thresholdRules: DEFAULT_NUMBER_THRESHOLDS.rules, @@ -156,7 +167,7 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'success.main', - icon: CheckCircleOutlineIcon, + icon: 'scorecardSuccessStatusIcon', }); }); }); @@ -171,7 +182,7 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'error.main', - icon: DangerousOutlinedIcon, + icon: 'scorecardErrorStatusIcon', }); }); @@ -184,7 +195,7 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'warning.main', - icon: WarningAmberIcon, + icon: 'scorecardWarningStatusIcon', }); }); @@ -196,9 +207,68 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'success.main', - icon: CheckCircleOutlineIcon, + icon: 'scorecardSuccessStatusIcon', }); }); }); }); + + describe('resolveStatusColor', () => { + const mockTheme = { + palette: { + primary: { main: '#0066cc' }, + success: { main: '#2e7d32' }, + warning: { main: '#ed6c02' }, + error: { main: '#d32f2f' }, + rhdh: { + general: { + cardBorderColor: '#c7c7c7', + }, + }, + }, + } as any as Theme; + + it('should resolve theme palette reference', () => { + const color = resolveStatusColor(mockTheme, 'success.main'); + expect(color).toBe('#2e7d32'); + }); + + it('should resolve theme reference with nested levels', () => { + const color = resolveStatusColor(mockTheme, SCORECARD_ERROR_STATE_COLOR); + expect(color).toBe('#c7c7c7'); + }); + + it('should return custom hex color directly', () => { + const color = resolveStatusColor(mockTheme, '#9933ff'); + expect(color).toBe('#9933ff'); + }); + + it('should return custom rgb color directly', () => { + const color = resolveStatusColor(mockTheme, 'rgb(255, 0, 0)'); + expect(color).toBe('rgb(255, 0, 0)'); + }); + + it('should return custom rgba color directly', () => { + const color = resolveStatusColor(mockTheme, 'rgba(255, 0, 0, 0.7)'); + expect(color).toBe('rgba(255, 0, 0, 0.7)'); + }); + + it('should fallback to cardBorderColor when theme path not found', () => { + const color = resolveStatusColor(mockTheme, 'nonexistent.path'); + expect(color).toBe('#c7c7c7'); + }); + + it('should fallback to error.main when theme path not found and cardBorderColor is undefined', () => { + const themeWithoutCardBorder = { + palette: { + error: { main: '#d32f2f' }, + }, + } as any as Theme; + const color = resolveStatusColor( + themeWithoutCardBorder, + 'nonexistent.path', + ); + expect(color).toBe('#d32f2f'); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/thresholdUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/thresholdUtils.test.tsx new file mode 100644 index 00000000000..23b5bdd840f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/thresholdUtils.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { + DEFAULT_NUMBER_THRESHOLDS, + ThresholdRule, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { getThresholdRuleColor, getThresholdRuleIcon } from '..'; + +describe('thresholdUtils', () => { + const mockRules: ThresholdRule[] = [ + { key: 'custom', expression: '<5', color: '#FF5733', icon: 'customIcon' }, + { + key: 'success', + expression: '<10', + color: '#4caf50', + icon: 'https://example.com/icon.png', + }, + { key: 'warning', expression: '10-50' }, + { + key: 'critical', + expression: '>50', + color: 'error.main', + icon: 'scorecardErrorStatusIcon', + }, + ]; + + describe('getThresholdRuleColor', () => { + it('should return color for matching threshold key', () => { + expect(getThresholdRuleColor(mockRules, 'custom')).toEqual('#FF5733'); + expect(getThresholdRuleColor(mockRules, 'success')).toEqual('#4caf50'); + expect(getThresholdRuleColor(mockRules, 'critical')).toEqual( + 'error.main', + ); + }); + + it('should return undefined for non-matching key', () => { + expect(getThresholdRuleColor(mockRules, 'low')).toBeUndefined(); + }); + + it('should return default color for default rule keys', () => { + expect( + getThresholdRuleColor(DEFAULT_NUMBER_THRESHOLDS.rules, 'success'), + ).toEqual('success.main'); + expect( + getThresholdRuleColor(DEFAULT_NUMBER_THRESHOLDS.rules, 'warning'), + ).toEqual('warning.main'); + expect( + getThresholdRuleColor(DEFAULT_NUMBER_THRESHOLDS.rules, 'error'), + ).toEqual('error.main'); + }); + + it('should handle empty rules array', () => { + expect(getThresholdRuleColor([], 'medium')).toBeUndefined(); + }); + }); + + describe('getThresholdRuleIcon', () => { + it('should return icon for matching threshold key', () => { + expect(getThresholdRuleIcon(mockRules, 'custom')).toEqual('customIcon'); + expect(getThresholdRuleIcon(mockRules, 'success')).toEqual( + 'https://example.com/icon.png', + ); + expect(getThresholdRuleIcon(mockRules, 'critical')).toEqual( + 'scorecardErrorStatusIcon', + ); + }); + + it('should return undefined for non-matching key', () => { + expect(getThresholdRuleIcon(mockRules, 'low')).toBeUndefined(); + }); + + it('should return default color for default rule keys', () => { + expect( + getThresholdRuleIcon(DEFAULT_NUMBER_THRESHOLDS.rules, 'success'), + ).toEqual('scorecardSuccessStatusIcon'); + expect( + getThresholdRuleIcon(DEFAULT_NUMBER_THRESHOLDS.rules, 'warning'), + ).toEqual('scorecardWarningStatusIcon'); + expect( + getThresholdRuleIcon(DEFAULT_NUMBER_THRESHOLDS.rules, 'error'), + ).toEqual('scorecardErrorStatusIcon'); + }); + + it('should handle empty rules array', () => { + expect(getThresholdRuleIcon([], 'medium')).toBeUndefined(); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/colorUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/colorUtils.ts deleted file mode 100644 index 65924d5bd81..00000000000 --- a/workspaces/scorecard/plugins/scorecard/src/utils/colorUtils.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 { - ScorecardThresholdRuleColors, - ThresholdRule, -} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import type { Theme } from '@mui/material/styles'; -import type { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; - -export const getThresholdRuleColor = ( - rules: ThresholdRule[], - ruleKey: string, -): string | undefined => { - const rule = rules.find(r => r.key === ruleKey); - if (rule?.color) { - return rule.color; - } - - switch (ruleKey) { - case 'error': - return ScorecardThresholdRuleColors.ERROR; - case 'warning': - return ScorecardThresholdRuleColors.WARNING; - case 'success': - return ScorecardThresholdRuleColors.SUCCESS; - default: - return undefined; - } -}; - -/** - * Resolves a color value from the theme palette or returns a custom color. - * Supports theme palette paths (e.g., 'error.main', 'rhdh.general.cardBorderColor') - * and direct color values (e.g., '#FF5733', 'blue', 'rgb(255,0,0)'). - * - * @param theme - The theme configuration object - * @param statusColor - Either a theme palette path or a direct color value - * @returns The resolved color string - */ -export const resolveStatusColor = ( - theme: Theme, - statusColor: string, -): string => { - // Theme palette paths are dot-separated; rgba(...) may include '.' in its alpha value. - if (!statusColor.includes('.') || statusColor.includes('rgba')) { - return statusColor; - } - - // Resolve theme palette reference - const parts = statusColor.split('.'); - let value: any = theme.palette; - - for (const part of parts) { - value = value?.[part]; - if (value === undefined) { - break; - } - } - - if (typeof value === 'string') { - return value; - } - - // Fallback to error state color, then error.main - return ( - (theme as ThemeConfig).palette?.rhdh?.general?.cardBorderColor ?? - theme.palette.error.main - ); -}; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts index be95d51a8e8..50f6c6c6e70 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -18,10 +18,10 @@ export { getHeightForCenterLabel, getYOffsetForCenterLabel, } from './chartLabelUtils'; -export { getThresholdRuleColor, resolveStatusColor } from './colorUtils'; export { - SCORECARD_ERROR_STATE_COLOR, SCORECARD_ENTITIES_TABLE_HEADERS, + SCORECARD_ERROR_STATE_COLOR, } from './constants'; -export { getStatusConfig } from './statusUtils'; export { getLastUpdatedLabel } from './entityTableUtils'; +export { getStatusConfig, resolveStatusColor } from './statusUtils'; +export { getThresholdRuleColor, getThresholdRuleIcon } from './thresholdUtils'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts index d4b79905847..cbda77b769c 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts @@ -14,18 +14,16 @@ * limitations under the License. */ -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import DangerousOutlinedIcon from '@mui/icons-material/DangerousOutlined'; +import type { Theme } from '@mui/material/styles'; import type { ThresholdRule } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import type { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme'; import { SCORECARD_ERROR_STATE_COLOR } from './constants'; -import { ElementType } from 'react'; -import { getThresholdRuleColor } from './colorUtils'; +import { getThresholdRuleColor, getThresholdRuleIcon } from './thresholdUtils'; export type StatusConfig = { color: string; - icon?: ElementType; + icon?: string; }; /** @@ -54,13 +52,49 @@ export const getStatusConfig = ({ evaluationColor = getThresholdRuleColor(thresholdRules, evaluation); } const color = evaluationColor ?? SCORECARD_ERROR_STATE_COLOR; + const icon = + thresholdRules && evaluation + ? getThresholdRuleIcon(thresholdRules, evaluation) + : undefined; + return { color, icon }; +}; + +/** + * Resolves a color value from the theme palette or returns a custom color. + * Supports theme palette paths (e.g., 'error.main', 'rhdh.general.cardBorderColor') + * and direct color values (e.g., '#FF5733', 'blue', 'rgb(255,0,0)'). + * + * @param theme - The theme configuration object + * @param statusColor - Either a theme palette path or a direct color value + * @returns The resolved color string + */ +export const resolveStatusColor = ( + theme: Theme, + statusColor: string, +): string => { + // Theme palette paths are dot-separated; rgba(...) may include '.' in its alpha value. + if (!statusColor.includes('.') || statusColor.includes('rgba')) { + return statusColor; + } + + // Resolve theme palette reference + const parts = statusColor.split('.'); + let value: any = theme.palette; - switch (evaluation) { - case 'error': - return { color, icon: DangerousOutlinedIcon }; - case 'warning': - return { color, icon: WarningAmberIcon }; - default: - return { color, icon: CheckCircleOutlineIcon }; + for (const part of parts) { + value = value?.[part]; + if (value === undefined) { + break; + } } + + if (typeof value === 'string') { + return value; + } + + // Fallback to error state color, then error.main + return ( + (theme as ThemeConfig).palette?.rhdh?.general?.cardBorderColor ?? + theme.palette.error.main + ); }; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/thresholdUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/thresholdUtils.ts new file mode 100644 index 00000000000..77424d651e6 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/thresholdUtils.ts @@ -0,0 +1,62 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ScorecardThresholdRuleColors, + ThresholdRule, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export const getThresholdRuleColor = ( + rules: ThresholdRule[], + ruleKey: string, +): string | undefined => { + const rule = rules.find(r => r.key === ruleKey); + if (rule?.color) { + return rule.color; + } + + switch (ruleKey) { + case 'error': + return ScorecardThresholdRuleColors.ERROR; + case 'warning': + return ScorecardThresholdRuleColors.WARNING; + case 'success': + return ScorecardThresholdRuleColors.SUCCESS; + default: + return undefined; + } +}; + +export const getThresholdRuleIcon = ( + rules: ThresholdRule[], + ruleKey: string, +): string | undefined => { + const rule = rules?.find(r => r.key === ruleKey); + if (rule?.icon) { + return rule.icon; + } + + switch (ruleKey) { + case 'error': + return 'scorecardErrorStatusIcon'; + case 'warning': + return 'scorecardWarningStatusIcon'; + case 'success': + return 'scorecardSuccessStatusIcon'; + default: + return undefined; + } +}; From 3dc6c90482fd21855046a97b40220e227bf035f2 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 12:03:23 +0200 Subject: [PATCH 2/8] Fix data URI Signed-off-by: Dominika Zemanovicova --- .../scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx index 0f0e4964b45..106ab438045 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx @@ -59,6 +59,7 @@ export const ScorecardIcon = ({ } if ( + icon.startsWith('data:') || icon.startsWith('https://') || icon.startsWith('http://') || icon.startsWith('/') From 1ad89a11a12561e1f7c70e5f6967263b39f17c48 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 17:33:35 +0200 Subject: [PATCH 3/8] Introduce IconImage Signed-off-by: Dominika Zemanovicova --- .../ScorecardIcon/ScorecardIcon.tsx | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx index 106ab438045..038426481b0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx @@ -27,6 +27,47 @@ export interface ScorecardIconProps { sx?: SxProps; } +const IconImage = ({ + src, + size, + sx, +}: { + src: string; + size?: 'small' | 'medium' | 'large'; + sx?: SxProps; +}) => ( + + + {/* Fallback for browsers without masking support */} + + +); + /** * @public */ @@ -51,11 +92,7 @@ export const ScorecardIcon = ({ if (icon.startsWith(' - - - ); + return ; } if ( @@ -64,11 +101,7 @@ export const ScorecardIcon = ({ icon.startsWith('http://') || icon.startsWith('/') ) { - return ( - - - - ); + return ; } return ( From 3954388f8663b0967d05a572c753269b7e263d05 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 18:24:59 +0200 Subject: [PATCH 4/8] Fix safari Signed-off-by: Dominika Zemanovicova --- .../scorecard/src/components/Scorecard/Scorecard.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx index e1701898276..3b6db83cdba 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx @@ -103,8 +103,8 @@ const ScorecardCenterLabel = ({ }, [isErrorState, errorLabel]); return ( - - + + {!isErrorState && ( From 57126f238cd73e0cd1659294226e9a844d30c10e Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 18:25:31 +0200 Subject: [PATCH 5/8] Revert "Fix safari" This reverts commit 3954388f8663b0967d05a572c753269b7e263d05. --- .../scorecard/src/components/Scorecard/Scorecard.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx index 3b6db83cdba..e1701898276 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx @@ -103,8 +103,8 @@ const ScorecardCenterLabel = ({ }, [isErrorState, errorLabel]); return ( - - + + {!isErrorState && ( From bce4201cf765a3589cebf128c68241831b44a558 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 18:25:48 +0200 Subject: [PATCH 6/8] Revert "Introduce IconImage" This reverts commit 1ad89a11a12561e1f7c70e5f6967263b39f17c48. --- .../ScorecardIcon/ScorecardIcon.tsx | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx index 038426481b0..106ab438045 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardIcon/ScorecardIcon.tsx @@ -27,47 +27,6 @@ export interface ScorecardIconProps { sx?: SxProps; } -const IconImage = ({ - src, - size, - sx, -}: { - src: string; - size?: 'small' | 'medium' | 'large'; - sx?: SxProps; -}) => ( - - - {/* Fallback for browsers without masking support */} - - -); - /** * @public */ @@ -92,7 +51,11 @@ export const ScorecardIcon = ({ if (icon.startsWith('; + return ( + + + + ); } if ( @@ -101,7 +64,11 @@ export const ScorecardIcon = ({ icon.startsWith('http://') || icon.startsWith('/') ) { - return ; + return ( + + + + ); } return ( From 6f5a92dd2f2a6bb31dfd4ee9b439a8d2aa059871 Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 18:41:54 +0200 Subject: [PATCH 7/8] Add information about icon color Signed-off-by: Dominika Zemanovicova --- .../scorecard/plugins/scorecard-backend/docs/thresholds.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md index 6265cd76b88..cb74cfc99a3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md +++ b/workspaces/scorecard/plugins/scorecard-backend/docs/thresholds.md @@ -324,6 +324,9 @@ The `icon` value is a string and can be one of: - **Image URL**: `http(s)://...`, `/assets/icon.svg` - **Data URI**: `data:image/svg+xml;base64,...` +> [!NOTE] +> SVG String, Image URL, and Data URI icons are treated as images and do not inherit the threshold status color. You must define the color within the icon itself. + For information on registering custom icons with Backstage, see [Adding Icons](https://backstage.io/docs/conf/user-interface/icons/#adding-icons). To use your custom Backstage System icons in RHDH for Scorecard, you will need to add `appIcons` key to the plugin that is exporting them: From 64cd1a7e0937cbea8a17b367d782c26d1408b6df Mon Sep 17 00:00:00 2001 From: Dominika Zemanovicova Date: Tue, 31 Mar 2026 19:02:45 +0200 Subject: [PATCH 8/8] Fix tests after merge Signed-off-by: Dominika Zemanovicova --- .../EntitiesTable/__tests__/EntitiesTableHeader.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx index 897c1b1c278..0be8595a662 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardPage/EntitiesTable/__tests__/EntitiesTableHeader.test.tsx @@ -122,11 +122,11 @@ describe('EntitiesTableHeader', () => { , ); - const metricHeader = screen.getByText( - 'entitiesPage.entitiesTable.header.metric', + const statusHeader = screen.getByText( + 'entitiesPage.entitiesTable.header.status', ); - await userEvent.click(metricHeader); - await userEvent.click(metricHeader); + await userEvent.click(statusHeader); + await userEvent.click(statusHeader); expect(onSortRequest).toHaveBeenCalledTimes(2); expect(onSortRequest).toHaveBeenCalledWith('status');