diff --git a/workspaces/extensions/.changeset/catalog-source-filter-ui.md b/workspaces/extensions/.changeset/catalog-source-filter-ui.md
new file mode 100644
index 00000000000..380c8bedfb1
--- /dev/null
+++ b/workspaces/extensions/.changeset/catalog-source-filter-ui.md
@@ -0,0 +1,5 @@
+---
+'@red-hat-developer-hub/backstage-plugin-extensions': minor
+---
+
+Add catalog source filter and display to Extensions UI
diff --git a/workspaces/extensions/app-config.yaml b/workspaces/extensions/app-config.yaml
index a8b460c2ec1..35519a4eba3 100644
--- a/workspaces/extensions/app-config.yaml
+++ b/workspaces/extensions/app-config.yaml
@@ -138,6 +138,15 @@ permission:
- name: user:development/guest
extensions:
+ catalogSources:
+ primary:
+ label: Red Hat
+ description: Plugins provided and supported by Red Hat
+ badge: Red Hat
+ community:
+ label: Community
+ description: Community-maintained plugins
+ badge: Community
# YAML files are in the examples directory
directory: ../../examples
diff --git a/workspaces/extensions/plugins/extensions/config.d.ts b/workspaces/extensions/plugins/extensions/config.d.ts
new file mode 100644
index 00000000000..8d0e98e7c39
--- /dev/null
+++ b/workspaces/extensions/plugins/extensions/config.d.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 interface Config {
+ extensions?: {
+ /**
+ * Metadata for catalog sources used to label and filter plugins by origin.
+ * Keys correspond to the `extensions.backstage.io/catalog-source` annotation value.
+ * @visibility frontend
+ */
+ catalogSources?: {
+ [sourceKey: string]: {
+ /**
+ * Display label for this catalog source.
+ * @visibility frontend
+ */
+ label: string;
+
+ /**
+ * Optional description shown as a tooltip.
+ * @visibility frontend
+ */
+ description?: string;
+
+ /**
+ * Optional badge text shown on the plugin card in list view.
+ * @visibility frontend
+ */
+ badge?: string;
+ };
+ };
+ };
+}
diff --git a/workspaces/extensions/plugins/extensions/dev/__data__/mockExtensions.ts b/workspaces/extensions/plugins/extensions/dev/__data__/mockExtensions.ts
index df8551a41d2..c45d0362911 100644
--- a/workspaces/extensions/plugins/extensions/dev/__data__/mockExtensions.ts
+++ b/workspaces/extensions/plugins/extensions/dev/__data__/mockExtensions.ts
@@ -48,6 +48,9 @@ export const mockPlugins: ExtensionsPlugin[] = [
name: 'techdocs',
title: 'TechDocs',
description: 'Documentation as code for your Backstage instance.',
+ annotations: {
+ 'extensions.backstage.io/catalog-source': 'community',
+ },
},
spec: {
categories: ['documentation'],
@@ -63,6 +66,9 @@ export const mockPlugins: ExtensionsPlugin[] = [
name: 'catalog',
title: 'Software Catalog',
description: 'Manage your software ecosystem with a unified catalog.',
+ annotations: {
+ 'extensions.backstage.io/catalog-source': 'primary',
+ },
},
spec: {
categories: ['infrastructure'],
@@ -78,6 +84,9 @@ export const mockPlugins: ExtensionsPlugin[] = [
name: 'scaffolder',
title: 'Software Templates',
description: 'Create new components and projects from templates.',
+ annotations: {
+ 'extensions.backstage.io/catalog-source': 'primary',
+ },
},
spec: {
categories: ['developer-tools'],
diff --git a/workspaces/extensions/plugins/extensions/package.json b/workspaces/extensions/plugins/extensions/package.json
index 74c5864c2db..5443f9aec84 100644
--- a/workspaces/extensions/plugins/extensions/package.json
+++ b/workspaces/extensions/plugins/extensions/package.json
@@ -21,6 +21,7 @@
"@red-hat-developer-hub/backstage-plugin-extensions-common"
]
},
+ "configSchema": "config.d.ts",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
@@ -85,6 +86,7 @@
},
"files": [
"app-config.dynamic.yaml",
+ "config.d.ts",
"dist"
]
}
diff --git a/workspaces/extensions/plugins/extensions/report-alpha.api.md b/workspaces/extensions/plugins/extensions/report-alpha.api.md
index e7f6437e351..736c1281d0b 100644
--- a/workspaces/extensions/plugins/extensions/report-alpha.api.md
+++ b/workspaces/extensions/plugins/extensions/report-alpha.api.md
@@ -246,6 +246,7 @@ export const extensionsTranslationRef: TranslationRef<
readonly 'metadata.about': string;
readonly 'metadata.highlights': string;
readonly 'metadata.category': string;
+ readonly 'metadata.catalogSource': string;
readonly 'metadata.by': string;
readonly 'metadata.comma': string;
readonly 'metadata.versions': string;
@@ -365,6 +366,7 @@ export const extensionsTranslationRef: TranslationRef<
readonly 'search.clearFilter': string;
readonly 'search.category': string;
readonly 'search.supportType': string;
+ readonly 'search.catalogSource': string;
readonly 'search.noResults': string;
readonly 'search.filterBy': string;
readonly 'search.clearFilters': string;
diff --git a/workspaces/extensions/plugins/extensions/src/__fixtures__/mockPlugins.ts b/workspaces/extensions/plugins/extensions/src/__fixtures__/mockPlugins.ts
index 0eaee47fd0e..97a7bcd8ca0 100644
--- a/workspaces/extensions/plugins/extensions/src/__fixtures__/mockPlugins.ts
+++ b/workspaces/extensions/plugins/extensions/src/__fixtures__/mockPlugins.ts
@@ -27,6 +27,9 @@ export const mockPlugins: ExtensionsPlugin[] = [
metadata: {
namespace: 'default',
name: 'plugin-1',
+ annotations: {
+ 'extensions.backstage.io/catalog-source': 'primary',
+ },
},
},
{
@@ -36,6 +39,9 @@ export const mockPlugins: ExtensionsPlugin[] = [
namespace: 'default',
name: 'plugin-2',
title: 'Plugin 2',
+ annotations: {
+ 'extensions.backstage.io/catalog-source': 'community',
+ },
},
},
];
diff --git a/workspaces/extensions/plugins/extensions/src/alpha/translations/de.ts b/workspaces/extensions/plugins/extensions/src/alpha/translations/de.ts
index 590ae6afea0..6deaeb40fc5 100644
--- a/workspaces/extensions/plugins/extensions/src/alpha/translations/de.ts
+++ b/workspaces/extensions/plugins/extensions/src/alpha/translations/de.ts
@@ -89,6 +89,7 @@ const extensionsTranslationDe = createTranslationMessages({
'search.category': 'Kategorie',
'search.author': 'Autor',
'search.supportType': 'Typ der Unterstützung',
+ 'search.catalogSource': 'Katalogquelle',
'search.noResults': 'Keine Plugins entsprechen Ihren Suchkriterien',
'search.filterBy': 'Filtern nach',
'search.clearFilters': 'Filter löschen',
@@ -213,6 +214,7 @@ const extensionsTranslationDe = createTranslationMessages({
'metadata.category': 'Kategorie',
'metadata.versions': 'Versionen',
'metadata.backstageCompatibility': 'Backstage-Kompatibilitätsversion',
+ 'metadata.catalogSource': 'Katalogquelle',
'supportTypes.verifiedBy': 'Verifiziert von {{value}} ({{count}})',
'supportTypes.customPlugins': 'Benutzerdefinierte Plugins ({{count}})',
'collection.kubernetes': 'Kubernetes',
diff --git a/workspaces/extensions/plugins/extensions/src/alpha/translations/es.ts b/workspaces/extensions/plugins/extensions/src/alpha/translations/es.ts
index c09a1e67d25..d32868a7cef 100644
--- a/workspaces/extensions/plugins/extensions/src/alpha/translations/es.ts
+++ b/workspaces/extensions/plugins/extensions/src/alpha/translations/es.ts
@@ -90,6 +90,7 @@ const extensionsTranslationEs = createTranslationMessages({
'search.category': 'Categoría',
'search.author': 'Autor',
'search.supportType': 'Tipo de soporte',
+ 'search.catalogSource': 'Fuente del catálogo',
'search.noResults':
'No hay ningún complemento que coincida con los criterios de búsqueda',
'search.filterBy': 'Filtrar por',
@@ -215,6 +216,7 @@ const extensionsTranslationEs = createTranslationMessages({
'metadata.category': 'Categoría',
'metadata.versions': 'Versiones',
'metadata.backstageCompatibility': 'Versión de compatibilidad de Backstage',
+ 'metadata.catalogSource': 'Fuente del catálogo',
'supportTypes.verifiedBy': 'Verificado por {{value}} ({{count}})',
'supportTypes.customPlugins': 'Complementos personalizados ({{count}})',
'collection.kubernetes': 'Kubernetes',
diff --git a/workspaces/extensions/plugins/extensions/src/alpha/translations/fr.ts b/workspaces/extensions/plugins/extensions/src/alpha/translations/fr.ts
index f11a7bffec9..a10b33832c8 100644
--- a/workspaces/extensions/plugins/extensions/src/alpha/translations/fr.ts
+++ b/workspaces/extensions/plugins/extensions/src/alpha/translations/fr.ts
@@ -89,6 +89,7 @@ const extensionsTranslationFr = createTranslationMessages({
'search.category': 'Catégorie',
'search.author': 'Auteur',
'search.supportType': 'Type de support',
+ 'search.catalogSource': 'Source du catalogue',
'search.noResults':
'Aucun plugin ne correspond à vos critères de recherche',
'search.filterBy': 'Filtrer par',
@@ -213,6 +214,7 @@ const extensionsTranslationFr = createTranslationMessages({
'metadata.category': 'Catégorie',
'metadata.versions': 'Versions',
'metadata.backstageCompatibility': 'Version de compatibilité backstage',
+ 'metadata.catalogSource': 'Source du catalogue',
'supportTypes.verifiedBy': 'Vérifié par {{value}} ({{count}})',
'supportTypes.customPlugins': 'Plugins personnalisés ({{count}})',
'collection.kubernetes': 'Kubernetes',
diff --git a/workspaces/extensions/plugins/extensions/src/alpha/translations/it.ts b/workspaces/extensions/plugins/extensions/src/alpha/translations/it.ts
index e3e9344a93d..d6588b4a7c1 100644
--- a/workspaces/extensions/plugins/extensions/src/alpha/translations/it.ts
+++ b/workspaces/extensions/plugins/extensions/src/alpha/translations/it.ts
@@ -92,6 +92,7 @@ const extensionsTranslationIt = createTranslationMessages({
'search.category': 'Categoria',
'search.author': 'Autore',
'search.supportType': 'Tipo di supporto',
+ 'search.catalogSource': 'Fonte del catalogo',
'search.noResults': 'Nessun plugin corrisponde ai criteri di ricerca',
'search.filterBy': 'Filtra per',
'search.clearFilters': 'Cancella filtri',
@@ -216,6 +217,7 @@ const extensionsTranslationIt = createTranslationMessages({
'metadata.category': 'Categoria',
'metadata.versions': 'Versioni',
'metadata.backstageCompatibility': 'Versione compatibile con Backstage',
+ 'metadata.catalogSource': 'Fonte del catalogo',
'supportTypes.verifiedBy': 'Verificato da {{value}} ({{count}})',
'supportTypes.customPlugins': 'Plugin personalizzati ({{count}})',
'collection.kubernetes': 'Kubernetes',
diff --git a/workspaces/extensions/plugins/extensions/src/alpha/translations/ja.ts b/workspaces/extensions/plugins/extensions/src/alpha/translations/ja.ts
index 21c3e89fe01..043ef92c622 100644
--- a/workspaces/extensions/plugins/extensions/src/alpha/translations/ja.ts
+++ b/workspaces/extensions/plugins/extensions/src/alpha/translations/ja.ts
@@ -92,6 +92,7 @@ const extensionsTranslationJa = createTranslationMessages({
'search.category': 'カテゴリー',
'search.author': '作成者',
'search.supportType': 'サポートタイプ',
+ 'search.catalogSource': 'カタログソース',
'search.noResults': '検索条件に一致するプラグインがありません',
'search.filterBy': 'フィルター',
'search.clearFilters': 'フィルターをクリア',
@@ -214,6 +215,7 @@ const extensionsTranslationJa = createTranslationMessages({
'metadata.category': 'カテゴリー',
'metadata.versions': 'バージョン',
'metadata.backstageCompatibility': 'Backstage 互換バージョン',
+ 'metadata.catalogSource': 'カタログソース',
'supportTypes.verifiedBy': '{{value}} により検証済み ({{count}})',
'supportTypes.customPlugins': 'カスタムプラグイン ({{count}})',
'collection.kubernetes': 'Kubernetes',
diff --git a/workspaces/extensions/plugins/extensions/src/alpha/translations/ref.ts b/workspaces/extensions/plugins/extensions/src/alpha/translations/ref.ts
index 08543b0acd7..b370f1a2b2c 100644
--- a/workspaces/extensions/plugins/extensions/src/alpha/translations/ref.ts
+++ b/workspaces/extensions/plugins/extensions/src/alpha/translations/ref.ts
@@ -111,6 +111,7 @@ export const extensionsMessages = {
category: 'Category',
author: 'Author',
supportType: 'Support type',
+ catalogSource: 'Catalog source',
noResults: 'No plugins match your search criteria',
filterBy: 'Filter by',
clearFilters: 'Clear filters',
@@ -267,6 +268,7 @@ export const extensionsMessages = {
category: 'Category',
versions: 'Versions',
backstageCompatibility: 'Backstage compatibility version',
+ catalogSource: 'Catalog source',
},
// Support type filters
diff --git a/workspaces/extensions/plugins/extensions/src/components/CatalogSourceBadge.tsx b/workspaces/extensions/plugins/extensions/src/components/CatalogSourceBadge.tsx
new file mode 100644
index 00000000000..716028860ac
--- /dev/null
+++ b/workspaces/extensions/plugins/extensions/src/components/CatalogSourceBadge.tsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Chip from '@mui/material/Chip';
+import Tooltip from '@mui/material/Tooltip';
+
+import type { ExtensionsPlugin } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
+import { ExtensionsAnnotation } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
+
+import {
+ useCatalogSourceConfig,
+ getCatalogSourceLabel,
+} from '../hooks/useCatalogSourceConfig';
+
+export const CatalogSourceBadge = ({
+ plugin,
+}: {
+ plugin: ExtensionsPlugin;
+}) => {
+ const sourcesConfig = useCatalogSourceConfig();
+ const sourceKey =
+ plugin.metadata?.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
+
+ if (!sourceKey) return null;
+
+ const meta = sourcesConfig[sourceKey];
+ const badgeLabel =
+ meta?.badge ?? getCatalogSourceLabel(plugin, sourcesConfig);
+
+ const chip = (
+
+ );
+
+ if (meta?.description) {
+ return (
+
+ {chip}
+
+ );
+ }
+
+ return chip;
+};
diff --git a/workspaces/extensions/plugins/extensions/src/components/CatalogSourceFilter.test.tsx b/workspaces/extensions/plugins/extensions/src/components/CatalogSourceFilter.test.tsx
new file mode 100644
index 00000000000..8715d15f469
--- /dev/null
+++ b/workspaces/extensions/plugins/extensions/src/components/CatalogSourceFilter.test.tsx
@@ -0,0 +1,147 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
+import { TestApiProvider } from '@backstage/test-utils';
+import { MockConfigApi } from '@backstage/test-utils';
+
+import { ExtensionsPluginFilter } from './ExtensionsPluginFilter';
+import { useFilteredPluginFacet } from '../hooks/useFilteredPluginFacet';
+import { useFilteredSupportTypes } from '../hooks/useFilteredSupportTypes';
+
+const useFilteredPluginFacetMock = useFilteredPluginFacet as jest.Mock;
+const useFilteredSupportTypesMock = useFilteredSupportTypes as jest.Mock;
+
+jest.mock('../hooks/useFilteredPluginFacet', () => ({
+ useFilteredPluginFacet: jest.fn(),
+}));
+
+jest.mock('../hooks/useFilteredSupportTypes', () => ({
+ useFilteredSupportTypes: jest.fn().mockReturnValue({
+ data: [],
+ }),
+}));
+
+const mockConfigWithSources = new MockConfigApi({
+ extensions: {
+ catalogSources: {
+ primary: {
+ label: 'Red Hat',
+ description: 'Plugins provided and supported by Red Hat',
+ badge: 'Red Hat',
+ },
+ community: {
+ label: 'Community',
+ description: 'Community-maintained plugins',
+ badge: 'Community',
+ },
+ },
+ },
+});
+
+const mockConfigEmpty = new MockConfigApi({});
+
+describe('CatalogSourceFilter', () => {
+ beforeEach(() => {
+ useFilteredSupportTypesMock.mockReturnValue({ data: [] });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render catalog source filter with two sources', async () => {
+ useFilteredPluginFacetMock.mockImplementation(
+ (facet: string, _exclude?: string) => {
+ if (facet === 'metadata.annotations.catalog-source') {
+ return {
+ data: [
+ { value: 'primary', count: 5 },
+ { value: 'community', count: 3 },
+ ],
+ };
+ }
+ return { data: [] };
+ },
+ );
+
+ const { getByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(getByText('Catalog source')).toBeInTheDocument();
+ });
+
+ it('should not render catalog source filter when no sources exist', async () => {
+ useFilteredPluginFacetMock.mockReturnValue({ data: [] });
+
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(queryByText('Catalog source')).not.toBeInTheDocument();
+ });
+
+ it('should display config labels instead of raw annotation values', async () => {
+ useFilteredPluginFacetMock.mockImplementation(
+ (facet: string, _exclude?: string) => {
+ if (facet === 'metadata.annotations.catalog-source') {
+ return {
+ data: [
+ { value: 'primary', count: 5 },
+ { value: 'community', count: 3 },
+ ],
+ };
+ }
+ return { data: [] };
+ },
+ );
+
+ const { getByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(getByText('Catalog source')).toBeInTheDocument();
+ });
+
+ it('should fall back to raw annotation value when source is not configured', async () => {
+ useFilteredPluginFacetMock.mockImplementation(
+ (facet: string, _exclude?: string) => {
+ if (facet === 'metadata.annotations.catalog-source') {
+ return {
+ data: [{ value: 'unconfigured-source', count: 2 }],
+ };
+ }
+ return { data: [] };
+ },
+ );
+
+ const { getByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(getByText('Catalog source')).toBeInTheDocument();
+ });
+});
diff --git a/workspaces/extensions/plugins/extensions/src/components/CatalogSourceLabel.tsx b/workspaces/extensions/plugins/extensions/src/components/CatalogSourceLabel.tsx
new file mode 100644
index 00000000000..c65bdcaf40e
--- /dev/null
+++ b/workspaces/extensions/plugins/extensions/src/components/CatalogSourceLabel.tsx
@@ -0,0 +1,98 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import Tooltip from '@mui/material/Tooltip';
+import Typography from '@mui/material/Typography';
+
+import type { ExtensionsPlugin } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
+import { ExtensionsAnnotation } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
+
+import {
+ useCatalogSourceConfig,
+ getCatalogSourceLabel,
+} from '../hooks/useCatalogSourceConfig';
+import { useTranslation } from '../hooks/useTranslation';
+
+export const CatalogSourceChip = ({ plugin }: { plugin: ExtensionsPlugin }) => {
+ const sourcesConfig = useCatalogSourceConfig();
+ const sourceKey =
+ plugin.metadata?.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
+
+ if (!sourceKey) return null;
+
+ const label = getCatalogSourceLabel(plugin, sourcesConfig);
+ const meta = sourcesConfig[sourceKey];
+
+ const chip = (
+
+ );
+
+ if (meta?.description) {
+ return (
+
+ {chip}
+
+ );
+ }
+
+ return chip;
+};
+
+export const CatalogSourceLabel = ({
+ plugin,
+}: {
+ plugin: ExtensionsPlugin;
+}) => {
+ const { t } = useTranslation();
+ const sourcesConfig = useCatalogSourceConfig();
+ const sourceKey =
+ plugin.metadata?.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
+
+ if (!sourceKey) return null;
+
+ const label = getCatalogSourceLabel(plugin, sourcesConfig);
+ const meta = sourcesConfig[sourceKey];
+
+ const content = (
+
+
+ {t('metadata.catalogSource')}
+
+ {label}
+
+ );
+
+ if (meta?.description) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return content;
+};
diff --git a/workspaces/extensions/plugins/extensions/src/components/CategoryLinkButton.tsx b/workspaces/extensions/plugins/extensions/src/components/CategoryLinkButton.tsx
index d0a5dea7930..13dbb164a91 100644
--- a/workspaces/extensions/plugins/extensions/src/components/CategoryLinkButton.tsx
+++ b/workspaces/extensions/plugins/extensions/src/components/CategoryLinkButton.tsx
@@ -25,8 +25,14 @@ import { styled } from '@mui/material/styles';
import { getCategoryTagDisplayInfo } from '../utils';
const StyledLinkButton = styled(LinkButton)({
- fontWeight: 'normal',
- padding: '2px 6px',
+ '&&': {
+ fontWeight: 'normal',
+ padding: '3px 10px',
+ fontSize: '0.75rem',
+ borderRadius: '999px',
+ minWidth: 'unset',
+ lineHeight: 1.5,
+ },
transition: 'border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': {
borderColor: '#a3a3a3',
diff --git a/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.test.tsx b/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.test.tsx
index 7c18632dfba..45f0e966654 100644
--- a/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.test.tsx
+++ b/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.test.tsx
@@ -17,7 +17,12 @@
import type { ReactNode } from 'react';
import { BrowserRouter } from 'react-router-dom';
-import { mockApis, MockErrorApi, TestApiProvider } from '@backstage/test-utils';
+import {
+ mockApis,
+ MockConfigApi,
+ MockErrorApi,
+ TestApiProvider,
+} from '@backstage/test-utils';
import { fireEvent, render } from '@testing-library/react';
import { ExtensionsPluginContent } from './ExtensionsPluginContent';
@@ -28,7 +33,7 @@ import { usePluginPackages } from '../hooks/usePluginPackages';
import { ExtensionsPluginInstallStatus } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
import { useExtensionsConfiguration } from '../hooks/useExtensionsConfiguration';
import { useNodeEnvironment } from '../hooks/useNodeEnvironment';
-import { errorApiRef } from '@backstage/core-plugin-api';
+import { configApiRef, errorApiRef } from '@backstage/core-plugin-api';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../queryclient';
@@ -113,6 +118,7 @@ const renderWithProviders = (ui: ReactNode) =>
apis={[
[extensionsApiRef, mockExtensionsApi],
[errorApiRef, new MockErrorApi()],
+ [configApiRef, new MockConfigApi({})],
[translationApiRef, mockApis.translation()],
]}
>
diff --git a/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.tsx b/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.tsx
index 1f7da9b2789..7592a7291e9 100644
--- a/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.tsx
+++ b/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginContent.tsx
@@ -48,6 +48,7 @@ import AlertTitle from '@mui/material/AlertTitle';
import {
isExtensionsPackage,
+ isExtensionsPlugin,
ExtensionsPackage,
ExtensionsPlugin,
ExtensionsPluginInstallStatus,
@@ -77,6 +78,7 @@ import { BadgeChip } from './Badges';
import { PluginIcon } from './PluginIcon';
import { Markdown } from './Markdown';
+import { CatalogSourceChip, CatalogSourceLabel } from './CatalogSourceLabel';
import { Links } from './Links';
import { ActionsMenu } from './ActionsMenu';
import { useEnablePlugin } from '../hooks/useEnablePlugin';
@@ -613,6 +615,9 @@ export const ExtensionsPluginContent = ({
) : null}
{!isPackage && }
+ {isExtensionsPlugin(plugin) && (
+
+ )}
@@ -660,6 +665,10 @@ export const ExtensionsPluginContent = ({
value={plugin.spec?.support?.provider}
/>
+ {isExtensionsPlugin(plugin) && (
+
+ )}
+
{pluginActionButton()}
@@ -667,7 +676,7 @@ export const ExtensionsPluginContent = ({
- {!isExtensionsPackage(plugin) && (
+ {isExtensionsPlugin(plugin) && (
)}
diff --git a/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginFilter.tsx b/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginFilter.tsx
index 270c1869812..de59948ba70 100644
--- a/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginFilter.tsx
+++ b/workspaces/extensions/plugins/extensions/src/components/ExtensionsPluginFilter.tsx
@@ -28,6 +28,7 @@ import {
} from '../shared-components/CustomSelectFilter';
import { useQueryArrayFilter } from '../hooks/useQueryArrayFilter';
import { useTranslation } from '../hooks/useTranslation';
+import { useCatalogSourceConfig } from '../hooks/useCatalogSourceConfig';
const CategoryFilter = () => {
const { t } = useTranslation();
@@ -175,11 +176,52 @@ const SupportTypeFilter = () => {
);
};
+const CatalogSourceFilter = () => {
+ const { t } = useTranslation();
+ const sourcesConfig = useCatalogSourceConfig();
+ const catalogSourceFacet = useFilteredPluginFacet(
+ 'metadata.annotations.catalog-source',
+ 'catalog-source',
+ );
+ const filter = useQueryArrayFilter('catalog-source');
+ const sources = catalogSourceFacet.data;
+
+ const items = useMemo(() => {
+ if (!sources) return [];
+ return sources.map(source => ({
+ label: sourcesConfig[source.value]?.label ?? source.value,
+ value: source.value,
+ count: source.count,
+ helperText: sourcesConfig[source.value]?.description,
+ }));
+ }, [sources, sourcesConfig]);
+
+ const handleChange = useCallback(
+ (_e: SyntheticEvent, value: CustomSelectItem[]) => {
+ const newSelection = value.map(v => v.value);
+ filter.set(newSelection);
+ },
+ [filter],
+ );
+
+ if (items.length === 0) return null;
+
+ return (
+
+ );
+};
+
export const ExtensionsPluginFilter = () => {
return (
+
);
diff --git a/workspaces/extensions/plugins/extensions/src/components/PluginCard.test.tsx b/workspaces/extensions/plugins/extensions/src/components/PluginCard.test.tsx
index 8dd12aacd15..2e3bcf321ac 100644
--- a/workspaces/extensions/plugins/extensions/src/components/PluginCard.test.tsx
+++ b/workspaces/extensions/plugins/extensions/src/components/PluginCard.test.tsx
@@ -16,7 +16,8 @@
import { BrowserRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
-import { TestApiProvider } from '@backstage/test-utils';
+import { TestApiProvider, MockConfigApi } from '@backstage/test-utils';
+import { configApiRef } from '@backstage/core-plugin-api';
import {
ExtensionsPlugin,
@@ -55,9 +56,11 @@ const mockPlugin: ExtensionsPlugin = {
},
};
+const mockConfig = new MockConfigApi({});
+
const renderPluginCard = (plugin: ExtensionsPlugin) => {
return render(
-
+
diff --git a/workspaces/extensions/plugins/extensions/src/components/PluginCard.tsx b/workspaces/extensions/plugins/extensions/src/components/PluginCard.tsx
index 57b546f9319..48a0bf97bf6 100644
--- a/workspaces/extensions/plugins/extensions/src/components/PluginCard.tsx
+++ b/workspaces/extensions/plugins/extensions/src/components/PluginCard.tsx
@@ -25,6 +25,7 @@ import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
+import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
@@ -35,6 +36,7 @@ import {
import { rootRouteRef, pluginRouteRef } from '../routes';
import { BadgeTriange } from './Badges';
+import { CatalogSourceBadge } from './CatalogSourceBadge';
import { CategoryLinkButton } from './CategoryLinkButton';
import { PluginIcon } from './PluginIcon';
import { useTranslation } from '../hooks/useTranslation';
@@ -149,54 +151,81 @@ export const PluginCard = ({ plugin }: { plugin: ExtensionsPlugin }) => {
>
-
+
-
-
- {plugin.metadata.title ?? plugin.metadata.name}
-
-
- {plugin.spec?.authors ? (
+
+
- {plugin.spec.authors.map((author, index) => (
-
- {index > 0 ? t('common.comma') : t('common.by')}
- e.stopPropagation()}
- >
- {author.name}
-
-
- ))}
+ {plugin.metadata.title ?? plugin.metadata.name}
- ) : null}
+
- {plugin.spec?.categories && plugin.spec.categories.length > 0 ? (
-
- e.stopPropagation()}
- />
-
- ) : null}
+
+ {plugin.spec?.authors ? (
+
+ {plugin.spec.authors.map((author, index) => (
+
+ {index > 0 ? t('common.comma') : t('common.by')}
+ e.stopPropagation()}
+ >
+ {author.name}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {plugin.spec?.categories && plugin.spec.categories.length > 0 ? (
+
+ {plugin.spec.categories.map(category => (
+ e.stopPropagation()}
+ />
+ ))}
+
+ ) : null}
+
) =>
+ ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+describe('useCatalogSourceConfig', () => {
+ it('returns empty object when no catalogSources config exists', () => {
+ const { result } = renderHook(() => useCatalogSourceConfig(), {
+ wrapper: createWrapper({}),
+ });
+
+ expect(result.current).toEqual({});
+ });
+
+ it('returns parsed catalog sources from config', () => {
+ const { result } = renderHook(() => useCatalogSourceConfig(), {
+ wrapper: createWrapper({
+ extensions: {
+ catalogSources: {
+ primary: {
+ label: 'Red Hat',
+ description: 'Plugins provided and supported by Red Hat',
+ badge: 'Red Hat',
+ },
+ community: {
+ label: 'Community',
+ description: 'Community-maintained plugins',
+ },
+ },
+ },
+ }),
+ });
+
+ expect(result.current).toEqual({
+ primary: {
+ label: 'Red Hat',
+ description: 'Plugins provided and supported by Red Hat',
+ badge: 'Red Hat',
+ },
+ community: {
+ label: 'Community',
+ description: 'Community-maintained plugins',
+ badge: undefined,
+ },
+ });
+ });
+
+ it('handles sources with only required label field', () => {
+ const { result } = renderHook(() => useCatalogSourceConfig(), {
+ wrapper: createWrapper({
+ extensions: {
+ catalogSources: {
+ internal: {
+ label: 'Internal',
+ },
+ },
+ },
+ }),
+ });
+
+ expect(result.current).toEqual({
+ internal: {
+ label: 'Internal',
+ description: undefined,
+ badge: undefined,
+ },
+ });
+ });
+});
+
+describe('getCatalogSourceLabel', () => {
+ const sources = {
+ primary: { label: 'Red Hat', description: 'Red Hat plugins' },
+ community: { label: 'Community' },
+ };
+
+ const makePlugin = (source?: string) =>
+ ({
+ metadata: {
+ annotations: source
+ ? { 'extensions.backstage.io/catalog-source': source }
+ : {},
+ },
+ }) as any;
+
+ it('returns configured label for known source', () => {
+ expect(getCatalogSourceLabel(makePlugin('primary'), sources)).toBe(
+ 'Red Hat',
+ );
+ });
+
+ it('returns raw key for unconfigured source', () => {
+ expect(getCatalogSourceLabel(makePlugin('unknown-source'), sources)).toBe(
+ 'unknown-source',
+ );
+ });
+});
diff --git a/workspaces/extensions/plugins/extensions/src/hooks/useCatalogSourceConfig.ts b/workspaces/extensions/plugins/extensions/src/hooks/useCatalogSourceConfig.ts
new file mode 100644
index 00000000000..c7e52017a24
--- /dev/null
+++ b/workspaces/extensions/plugins/extensions/src/hooks/useCatalogSourceConfig.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useMemo } from 'react';
+
+import { configApiRef, useApi } from '@backstage/core-plugin-api';
+
+import type { ExtensionsPlugin } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
+import { ExtensionsAnnotation } from '@red-hat-developer-hub/backstage-plugin-extensions-common';
+
+export interface CatalogSourceMeta {
+ label: string;
+ description?: string;
+ badge?: string;
+}
+
+export const useCatalogSourceConfig = (): Record => {
+ const configApi = useApi(configApiRef);
+
+ return useMemo(() => {
+ const result: Record = {};
+ const sourcesConfig = configApi.getOptionalConfig(
+ 'extensions.catalogSources',
+ );
+ if (!sourcesConfig) return result;
+
+ for (const key of sourcesConfig.keys()) {
+ const sourceConfig = sourcesConfig.getConfig(key);
+ result[key] = {
+ label: sourceConfig.getString('label'),
+ description: sourceConfig.getOptionalString('description'),
+ badge: sourceConfig.getOptionalString('badge'),
+ };
+ }
+ return result;
+ }, [configApi]);
+};
+
+export const getCatalogSourceLabel = (
+ plugin: ExtensionsPlugin,
+ sourcesConfig: Record,
+): string => {
+ const sourceKey =
+ plugin.metadata?.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
+ if (!sourceKey) return '';
+ return sourcesConfig[sourceKey]?.label ?? sourceKey;
+};
diff --git a/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPluginFacet.ts b/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPluginFacet.ts
index f21d6256a50..b1154e54ff5 100644
--- a/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPluginFacet.ts
+++ b/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPluginFacet.ts
@@ -77,6 +77,12 @@ export const useFilteredPluginFacet = (
) {
return false;
}
+ if (
+ excludeFilterType === 'catalog-source' &&
+ filter.startsWith('catalog-source=')
+ ) {
+ return false;
+ }
return true;
});
@@ -124,6 +130,18 @@ export const useFilteredPluginFacet = (
});
}
+ // Apply catalog source filters
+ const catalogSources = activeFilters
+ .filter(filter => filter.startsWith('catalog-source='))
+ .map(filter => filter.substring('catalog-source='.length));
+ if (catalogSources.length > 0) {
+ filteredPlugins = filteredPlugins.filter(plugin => {
+ const source =
+ plugin.metadata?.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
+ return source !== undefined && catalogSources.includes(source);
+ });
+ }
+
// Apply support type filters
const showCustom = activeFilters.includes('custom');
const supportLevels = activeFilters
@@ -156,6 +174,12 @@ export const useFilteredPluginFacet = (
// Extract values based on facet path
if (facet === 'spec.categories') {
values = plugin.spec?.categories || [];
+ } else if (facet === 'metadata.annotations.catalog-source') {
+ const source =
+ plugin.metadata?.annotations?.[ExtensionsAnnotation.CATALOG_SOURCE];
+ if (source) {
+ values = [source];
+ }
} else if (facet === 'spec.authors.name') {
if (plugin.spec?.authors && plugin.spec.authors.length > 0) {
values = plugin.spec.authors
diff --git a/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPlugins.ts b/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPlugins.ts
index a1f8a29c8a5..cd766b76c9b 100644
--- a/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPlugins.ts
+++ b/workspaces/extensions/plugins/extensions/src/hooks/useFilteredPlugins.ts
@@ -92,6 +92,19 @@ export const useFilteredPlugins = () => {
});
}
+ const catalogSources = filters
+ .filter(filter => filter.startsWith('catalog-source='))
+ .map(filter => filter.substring('catalog-source='.length));
+ if (catalogSources.length > 0) {
+ plugins = plugins.filter(plugin => {
+ const source =
+ plugin.metadata?.annotations?.[
+ ExtensionsAnnotation.CATALOG_SOURCE
+ ];
+ return source !== undefined && catalogSources.includes(source);
+ });
+ }
+
const showCustom = filters.includes('custom');
const supportLevels = filters
.filter(filter => filter.startsWith('support-level='))