From a7f65a5522cf5999ba49779c4c986f96d8738eb0 Mon Sep 17 00:00:00 2001 From: Lokananda Prabhu Date: Mon, 29 Sep 2025 20:50:16 +0530 Subject: [PATCH] Extension: Fix Support Type filter count inconsistency when other filters are applied --- .../fix-support-type-filter-count.md | 5 + .../MarketplaceCatalogContent.test.tsx | 12 + .../components/MarketplacePluginFilter.tsx | 115 +--------- .../src/hooks/useFilteredPluginFacet.ts | 185 +++++++++++++++ .../src/hooks/useFilteredPlugins.ts | 23 +- .../src/hooks/useFilteredSupportTypes.ts | 214 ++++++++++++++++++ 6 files changed, 438 insertions(+), 116 deletions(-) create mode 100644 workspaces/marketplace/.changeset/fix-support-type-filter-count.md create mode 100644 workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPluginFacet.ts create mode 100644 workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredSupportTypes.ts diff --git a/workspaces/marketplace/.changeset/fix-support-type-filter-count.md b/workspaces/marketplace/.changeset/fix-support-type-filter-count.md new file mode 100644 index 0000000000..2496377022 --- /dev/null +++ b/workspaces/marketplace/.changeset/fix-support-type-filter-count.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-marketplace': patch +--- + +Fix Support Type filter count inconsistency when other filters are applied. diff --git a/workspaces/marketplace/plugins/marketplace/src/components/MarketplaceCatalogContent.test.tsx b/workspaces/marketplace/plugins/marketplace/src/components/MarketplaceCatalogContent.test.tsx index c81536df28..57304fd3f7 100644 --- a/workspaces/marketplace/plugins/marketplace/src/components/MarketplaceCatalogContent.test.tsx +++ b/workspaces/marketplace/plugins/marketplace/src/components/MarketplaceCatalogContent.test.tsx @@ -52,6 +52,18 @@ jest.mock('../hooks/usePluginFacets', () => ({ }), })); +jest.mock('../hooks/useFilteredPluginFacet', () => ({ + useFilteredPluginFacet: jest.fn().mockReturnValue({ + data: [], + }), +})); + +jest.mock('../hooks/useFilteredSupportTypes', () => ({ + useFilteredSupportTypes: jest.fn().mockReturnValue({ + data: [], + }), +})); + afterAll(() => { jest.clearAllMocks(); }); diff --git a/workspaces/marketplace/plugins/marketplace/src/components/MarketplacePluginFilter.tsx b/workspaces/marketplace/plugins/marketplace/src/components/MarketplacePluginFilter.tsx index e507de9f6c..2c16c7ab28 100644 --- a/workspaces/marketplace/plugins/marketplace/src/components/MarketplacePluginFilter.tsx +++ b/workspaces/marketplace/plugins/marketplace/src/components/MarketplacePluginFilter.tsx @@ -20,24 +20,18 @@ import { useSearchParams } from 'react-router-dom'; import Box from '@mui/material/Box'; -import { - MarketplaceAnnotation, - MarketplaceSupportLevel, -} from '@red-hat-developer-hub/backstage-plugin-marketplace-common'; - -import { usePluginFacet } from '../hooks/usePluginFacet'; -import { usePluginFacets } from '../hooks/usePluginFacets'; +import { useFilteredPluginFacet } from '../hooks/useFilteredPluginFacet'; +import { useFilteredSupportTypes } from '../hooks/useFilteredSupportTypes'; import { CustomSelectFilter, CustomSelectItem, } from '../shared-components/CustomSelectFilter'; import { useQueryArrayFilter } from '../hooks/useQueryArrayFilter'; -import { colors } from '../consts'; import { useTranslation } from '../hooks/useTranslation'; const CategoryFilter = () => { const { t } = useTranslation(); - const categoriesFacet = usePluginFacet('spec.categories'); + const categoriesFacet = useFilteredPluginFacet('spec.categories', 'category'); const filter = useQueryArrayFilter('category'); const categories = categoriesFacet.data; @@ -70,7 +64,7 @@ const CategoryFilter = () => { const AuthorFilter = () => { const { t } = useTranslation(); - const authorsFacet = usePluginFacet('spec.authors.name'); + const authorsFacet = useFilteredPluginFacet('spec.authors.name', 'author'); const authors = authorsFacet.data; const filter = useQueryArrayFilter('author'); @@ -101,12 +95,6 @@ const AuthorFilter = () => { ); }; -const facetsKeys = [ - `metadata.annotations.${MarketplaceAnnotation.CERTIFIED_BY}`, - `metadata.annotations.${MarketplaceAnnotation.PRE_INSTALLED}`, - 'spec.support.level', -]; - const evaluateParams = ( newSelection: (string | number)[], newParams: URLSearchParams, @@ -121,100 +109,9 @@ const evaluateParams = ( const SupportTypeFilter = () => { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); - const pluginFacets = usePluginFacets({ facets: facetsKeys }); - - const facets = pluginFacets.data; - - const items = useMemo(() => { - if (!facets) return []; - const allSupportTypeItems: CustomSelectItem[] = []; - - // Certified plugins - const certified = facets[facetsKeys[0]]; - const certifiedCount = - certified?.reduce((acc, curr) => acc + curr.count, 0) || 0; - // const certifiedFilter = certified?.map(c => c.value).join(', ') || ''; - const certifiedProviders = certified?.map(c => c.value).join(', ') || ''; - - allSupportTypeItems.push({ - label: t('badges.certified'), - value: 'certified', - count: certifiedCount, - isBadge: true, - badgeColor: colors.certified, - helperText: t('badges.stableAndSecured' as any, { - provider: certifiedProviders, - }), - displayOrder: 2, - }); - - // Custom plugins - const preinstalled = facets[facetsKeys[1]]; - const customCount = - preinstalled?.find(p => p.value === 'false')?.count ?? 0; - if (customCount > 0) { - allSupportTypeItems.push({ - label: t('badges.customPlugin'), - value: 'custom', - count: customCount, - isBadge: true, - badgeColor: colors.custom, - helperText: t('badges.addedByAdmin'), - displayOrder: 3, - }); - } - - const supportLevelFilters = facets[facetsKeys[2]]; - supportLevelFilters?.forEach(supportLevelFilter => { - if ( - supportLevelFilter.value === MarketplaceSupportLevel.GENERALLY_AVAILABLE - ) { - allSupportTypeItems.push({ - label: t('badges.generallyAvailable'), - value: `support-level=${supportLevelFilter.value}`, - count: supportLevelFilter.count, - isBadge: true, - badgeColor: colors.generallyAvailable, - helperText: t('badges.productionReady'), - displayOrder: 1, - }); - } else if ( - supportLevelFilter.value === MarketplaceSupportLevel.TECH_PREVIEW - ) { - allSupportTypeItems.push({ - label: t('badges.techPreview'), - value: `support-level=${supportLevelFilter.value}`, - count: supportLevelFilter.count, - helperText: t('badges.pluginInDevelopment'), - displayOrder: 4, - }); - } else if ( - supportLevelFilter.value === MarketplaceSupportLevel.DEV_PREVIEW - ) { - allSupportTypeItems.push({ - label: t('badges.devPreview'), - value: `support-level=${supportLevelFilter.value}`, - count: supportLevelFilter.count, - helperText: t('badges.earlyStageExperimental'), - displayOrder: 5, - }); - } else if ( - supportLevelFilter.value === MarketplaceSupportLevel.COMMUNITY - ) { - allSupportTypeItems.push({ - label: t('badges.communityPlugin'), - value: `support-level=${supportLevelFilter.value}`, - count: supportLevelFilter.count, - helperText: t('badges.openSourceNoSupport'), - displayOrder: 6, - }); - } - }); + const filteredSupportTypes = useFilteredSupportTypes(); - return allSupportTypeItems.sort( - (a, b) => (a.displayOrder || 0) - (b.displayOrder || 0), - ); - }, [facets, t]); + const items = filteredSupportTypes.data; const selected = useMemo(() => { const selectedFilters = searchParams.getAll('filter'); diff --git a/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPluginFacet.ts b/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPluginFacet.ts new file mode 100644 index 0000000000..09fd3b51d5 --- /dev/null +++ b/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPluginFacet.ts @@ -0,0 +1,185 @@ +/* + * Copyright The Backstage Authors + * + * 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 { useQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; + +import { MarketplaceAnnotation } from '@red-hat-developer-hub/backstage-plugin-marketplace-common'; + +import { useMarketplaceApi } from './useMarketplaceApi'; + +/** + * Hook to get plugin facets filtered by current active filters + * @param facet - The facet field to get values for + * @param excludeFilterType - The filter type to exclude from the filter (allows getting options for the current filter) + */ +export const useFilteredPluginFacet = ( + facet: string, + excludeFilterType?: string, +) => { + const [searchParams] = useSearchParams(); + const marketplaceApi = useMarketplaceApi(); + + const filters = searchParams.getAll('filter'); + + // Get all plugins and apply client-side filtering for accurate facet calculation + const pluginsQuery = useQuery({ + queryKey: ['marketplaceApi', 'getPlugins'], + queryFn: () => + marketplaceApi.getPlugins({ + orderFields: [{ field: 'metadata.title', order: 'asc' }], + }), + }); + + return useQuery({ + queryKey: [ + 'marketplaceApi', + 'getFilteredPluginFacet', + facet, + filters, + excludeFilterType, + ], + queryFn: async () => { + if (!pluginsQuery.data?.items) return undefined; + + // Apply filtering excluding the specified filter type + const activeFilters = filters.filter(filter => { + if (!excludeFilterType) return true; + + // Exclude filters of the specified type + if ( + excludeFilterType === 'category' && + filter.startsWith('category=') + ) { + return false; + } + if (excludeFilterType === 'author' && filter.startsWith('author=')) { + return false; + } + if ( + excludeFilterType === 'support' && + (filter === 'certified' || + filter === 'custom' || + filter.startsWith('support-level=')) + ) { + return false; + } + return true; + }); + + let filteredPlugins = pluginsQuery.data.items; + + // Apply category filters + const categories = activeFilters + .filter(filter => filter.startsWith('category=')) + .map(filter => filter.substring('category='.length)); + if (categories.length > 0) { + filteredPlugins = filteredPlugins.filter(plugin => + plugin.spec?.categories?.some(category => + categories.includes(category), + ), + ); + } + + // Apply author filters + const authors = activeFilters + .filter(filter => filter.startsWith('author=')) + .map(filter => filter.substring('author='.length)); + if (authors.length > 0) { + filteredPlugins = filteredPlugins.filter(plugin => { + // Check spec.authors array + if ( + plugin.spec?.authors?.some(author => + typeof author === 'string' + ? authors.includes(author) + : authors.includes(author.name), + ) + ) { + return true; + } + // Check certification annotation as fallback + const certifiedBy = + plugin.metadata?.annotations?.[MarketplaceAnnotation.CERTIFIED_BY]; + return certifiedBy && authors.includes(certifiedBy); + }); + } + + // Apply support type filters + const showCertified = activeFilters.includes('certified'); + const showCustom = activeFilters.includes('custom'); + const supportLevels = activeFilters + .filter(filter => filter.startsWith('support-level=')) + .map(filter => filter.substring('support-level='.length)); + + if (showCertified || showCustom || supportLevels.length > 0) { + filteredPlugins = filteredPlugins.filter(plugin => { + if ( + showCertified && + plugin.metadata?.annotations?.[MarketplaceAnnotation.CERTIFIED_BY] + ) { + return true; + } + if ( + showCustom && + plugin.metadata?.annotations?.[ + MarketplaceAnnotation.PRE_INSTALLED + ] !== 'true' + ) { + return true; + } + if (supportLevels.length > 0 && plugin.spec?.support?.level) { + return supportLevels.includes(plugin.spec.support.level); + } + return false; + }); + } + + // Calculate facet values from filtered plugins + const facetValues: Record = {}; + + filteredPlugins.forEach(plugin => { + let values: any[] = []; + + // Extract values based on facet path + if (facet === 'spec.categories') { + values = plugin.spec?.categories || []; + } else if (facet === 'spec.authors.name') { + if (plugin.spec?.authors && plugin.spec.authors.length > 0) { + values = plugin.spec.authors + .map(author => + typeof author === 'string' ? author : author.name, + ) + .filter(Boolean); + } else if (plugin.spec?.author) { + values = [plugin.spec.author]; + } + } + + values.forEach(value => { + facetValues[value] = (facetValues[value] || 0) + 1; + }); + }); + + // Convert to expected format + const result = Object.entries(facetValues).map(([value, count]) => ({ + value, + count, + })); + return result; + }, + enabled: !!pluginsQuery.data, + }); +}; diff --git a/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPlugins.ts b/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPlugins.ts index 60fb8ca002..9d216549ee 100644 --- a/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPlugins.ts +++ b/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredPlugins.ts @@ -67,13 +67,22 @@ export const useFilteredPlugins = () => { .filter(filter => filter.startsWith('author=')) .map(filter => filter.substring('author='.length)); if (authors.length > 0) { - plugins = plugins.filter(plugin => - plugin.spec?.authors?.some(author => - typeof author === 'string' - ? authors.includes(author) - : authors.includes(author.name), - ), - ); + plugins = plugins.filter(plugin => { + if ( + plugin.spec?.authors?.some(author => + typeof author === 'string' + ? authors.includes(author) + : authors.includes(author.name), + ) + ) { + return true; + } + + if (plugin.spec?.author && authors.includes(plugin.spec.author)) { + return true; + } + return false; + }); } const showCertified = filters.includes('certified'); diff --git a/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredSupportTypes.ts b/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredSupportTypes.ts new file mode 100644 index 0000000000..97dd3e077b --- /dev/null +++ b/workspaces/marketplace/plugins/marketplace/src/hooks/useFilteredSupportTypes.ts @@ -0,0 +1,214 @@ +/* + * Copyright The Backstage Authors + * + * 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 { useSearchParams } from 'react-router-dom'; + +import { useQuery } from '@tanstack/react-query'; + +import { + MarketplaceAnnotation, + MarketplaceSupportLevel, +} from '@red-hat-developer-hub/backstage-plugin-marketplace-common'; + +import { useMarketplaceApi } from './useMarketplaceApi'; +import { CustomSelectItem } from '../shared-components/CustomSelectFilter'; +import { colors } from '../consts'; +import { useTranslation } from './useTranslation'; + +/** + * Hook to get support type filter options based on currently filtered plugins + * This ensures support type options only show what's available after applying other filters + */ +export const useFilteredSupportTypes = () => { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const marketplaceApi = useMarketplaceApi(); + + const filters = searchParams.getAll('filter'); + + // Get all plugins (not pre-filtered) + const allPluginsQuery = useQuery({ + queryKey: ['marketplaceApi', 'getPlugins'], + queryFn: () => + marketplaceApi.getPlugins({ + orderFields: [{ field: 'metadata.title', order: 'asc' }], + }), + refetchOnWindowFocus: false, + }); + + // Get current filters excluding support type filters + // This should exclude all support type filters so we can show all available support types + const nonSupportFilters = filters.filter( + filter => + !( + filter === 'certified' || + filter === 'custom' || + filter.startsWith('support-level=') + ), + ); + + // Calculate available support types from the filtered plugin data + const items = useMemo(() => { + if (!allPluginsQuery.data?.items) return []; + + let availablePlugins = allPluginsQuery.data.items; + + // Apply category filter if present + const categories = nonSupportFilters + .filter(filter => filter.startsWith('category=')) + .map(filter => filter.substring('category='.length)); + if (categories.length > 0) { + availablePlugins = availablePlugins.filter(plugin => + plugin.spec?.categories?.some(category => + categories.includes(category), + ), + ); + } + + // Apply author filter if present + const authors = nonSupportFilters + .filter(filter => filter.startsWith('author=')) + .map(filter => filter.substring('author='.length)); + if (authors.length > 0) { + availablePlugins = availablePlugins.filter(plugin => { + // Check spec.authors array + if ( + plugin.spec?.authors?.some(author => + typeof author === 'string' + ? authors.includes(author) + : authors.includes(author.name), + ) + ) { + return true; + } + if (plugin.spec?.author && authors.includes(plugin.spec.author)) { + return true; + } + return false; + }); + } + + const allSupportTypeItems: CustomSelectItem[] = []; + + // Count certified plugins + const certifiedPlugins = availablePlugins.filter( + plugin => + plugin.metadata?.annotations?.[MarketplaceAnnotation.CERTIFIED_BY], + ); + if (certifiedPlugins.length > 0) { + const certifiedProviders = Array.from( + new Set( + certifiedPlugins + .map( + p => + p.metadata?.annotations?.[MarketplaceAnnotation.CERTIFIED_BY], + ) + .filter(Boolean), + ), + ).join(', '); + + allSupportTypeItems.push({ + label: t('badges.certified'), + value: 'certified', + count: certifiedPlugins.length, + isBadge: true, + badgeColor: colors.certified, + helperText: t('badges.stableAndSecured' as any, { + provider: certifiedProviders, + }), + displayOrder: 2, + }); + } + + // Count custom plugins + const customPlugins = availablePlugins.filter( + plugin => + plugin.metadata?.annotations?.[MarketplaceAnnotation.PRE_INSTALLED] !== + 'true', + ); + if (customPlugins.length > 0) { + allSupportTypeItems.push({ + label: t('badges.customPlugin'), + value: 'custom', + count: customPlugins.length, + isBadge: true, + badgeColor: colors.custom, + helperText: t('badges.addedByAdmin'), + displayOrder: 3, + }); + } + + // Count plugins by support level + const supportLevelCounts: Record = {}; + availablePlugins.forEach(plugin => { + const supportLevel = plugin.spec?.support?.level; + if (supportLevel) { + supportLevelCounts[supportLevel] = + (supportLevelCounts[supportLevel] || 0) + 1; + } + }); + + Object.entries(supportLevelCounts).forEach(([level, count]) => { + if (level === MarketplaceSupportLevel.GENERALLY_AVAILABLE) { + allSupportTypeItems.push({ + label: t('badges.generallyAvailable'), + value: `support-level=${level}`, + count, + isBadge: true, + badgeColor: colors.generallyAvailable, + helperText: t('badges.productionReady'), + displayOrder: 1, + }); + } else if (level === MarketplaceSupportLevel.TECH_PREVIEW) { + allSupportTypeItems.push({ + label: t('badges.techPreview'), + value: `support-level=${level}`, + count, + helperText: t('badges.pluginInDevelopment'), + displayOrder: 4, + }); + } else if (level === MarketplaceSupportLevel.DEV_PREVIEW) { + allSupportTypeItems.push({ + label: t('badges.devPreview'), + value: `support-level=${level}`, + count, + helperText: t('badges.earlyStageExperimental'), + displayOrder: 5, + }); + } else if (level === MarketplaceSupportLevel.COMMUNITY) { + allSupportTypeItems.push({ + label: t('badges.communityPlugin'), + value: `support-level=${level}`, + count, + helperText: t('badges.openSourceNoSupport'), + displayOrder: 6, + }); + } + }); + + const result = allSupportTypeItems.sort( + (a, b) => (a.displayOrder || 0) - (b.displayOrder || 0), + ); + return result; + }, [allPluginsQuery.data?.items, nonSupportFilters, t]); + + return { + data: items, + isLoading: allPluginsQuery.isLoading, + error: allPluginsQuery.error, + }; +};