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='))