diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx index c355eb8607f45f..e4bd1da842867b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.stories.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import type { ListProps } from './package_list_grid'; +import type { Props } from './package_list_grid'; import { PackageListGrid } from './package_list_grid'; export default { @@ -17,7 +17,7 @@ export default { title: 'Sections/EPM/Package List Grid', }; -type Args = Pick; +type Args = Pick; const args: Args = { title: 'Installed integrations', diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx index 109f7500f160b2..1cffd5292b6a29 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx @@ -14,7 +14,6 @@ import { EuiLink, EuiSpacer, EuiTitle, - // @ts-ignore EuiSearchBar, EuiText, } from '@elastic/eui'; @@ -29,7 +28,7 @@ import type { IntegrationCardItem } from '../../../../../../common/types/models' import { PackageCard } from './package_card'; -export interface ListProps { +export interface Props { isLoading?: boolean; controls?: ReactNode; title: string; @@ -51,7 +50,7 @@ export function PackageListGrid({ setSelectedCategory, showMissingIntegrationMessage = false, callout, -}: ListProps) { +}: Props) { const [searchTerm, setSearchTerm] = useState(initialSearch || ''); const localSearchRef = useLocalSearch(list); @@ -107,7 +106,12 @@ export function PackageListGrid({ }} onChange={onQueryChange} /> - {callout ? callout : null} + {callout ? ( + <> + + {callout} + + ) : null} {gridContent} {showMissingIntegrationMessage && ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx new file mode 100644 index 00000000000000..8aef9121bf67d4 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { useLocation, useHistory, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + +import { pagePathGetters } from '../../../../constants'; +import { + useGetCategories, + useGetPackages, + useBreadcrumbs, + useGetAppendCustomIntegrations, + useGetReplacementCustomIntegrations, + useLink, +} from '../../../../hooks'; +import { doesPackageHaveIntegrations } from '../../../../services'; +import type { PackageList } from '../../../../types'; +import { PackageListGrid } from '../../components/package_list_grid'; + +import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import type { PackageListItem } from '../../../../types'; + +import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common'; + +import { useMergeEprPackagesWithReplacements } from '../../../../../../hooks/use_merge_epr_with_replacements'; + +import { mergeAndReplaceCategoryCounts } from './util'; +import { CategoryFacets } from './category_facets'; +import type { CategoryFacet } from './category_facets'; + +import type { CategoryParams } from '.'; +import { getParams, categoryExists, mapToCard } from '.'; + +// Packages can export multiple integrations, aka `policy_templates` +// In the case where packages ship >1 `policy_templates`, we flatten out the +// list of packages by bringing all integrations to top-level so that +// each integration is displayed as its own tile +const packageListToIntegrationsList = (packages: PackageList): PackageList => { + return packages.reduce((acc: PackageList, pkg) => { + const { policy_templates: policyTemplates = [], ...restOfPackage } = pkg; + return [ + ...acc, + restOfPackage, + ...(doesPackageHaveIntegrations(pkg) + ? policyTemplates.map((integration) => { + const { name, title, description, icons } = integration; + return { + ...restOfPackage, + id: `${restOfPackage}-${name}`, + integration: name, + title, + description, + icons: icons || restOfPackage.icons, + }; + }) + : []), + ]; + }, []); +}; + +const title = i18n.translate('xpack.fleet.epmList.allTitle', { + defaultMessage: 'Browse by category', +}); + +// TODO: clintandrewhall - this component is hard to test due to the hooks, particularly those that use `http` +// or `location` to load data. Ideally, we'll split this into "connected" and "pure" components. +export const AvailablePackages: React.FC = memo(() => { + useBreadcrumbs('integrations_all'); + + const { selectedCategory, searchParam } = getParams( + useParams(), + useLocation().search + ); + + const history = useHistory(); + + const { getHref, getAbsolutePath } = useLink(); + + function setSelectedCategory(categoryId: string) { + const url = pagePathGetters.integrations_all({ + category: categoryId, + searchTerm: searchParam, + })[1]; + history.push(url); + } + + function setSearchTerm(search: string) { + // Use .replace so the browser's back button is not tied to single keystroke + history.replace( + pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1] + ); + } + + const { data: allCategoryPackagesRes, isLoading: isLoadingAllPackages } = useGetPackages({ + category: '', + }); + + const { data: categoryPackagesRes, isLoading: isLoadingCategoryPackages } = useGetPackages({ + category: selectedCategory, + }); + + const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ + include_policy_templates: true, + }); + + const eprPackages = useMemo( + () => packageListToIntegrationsList(categoryPackagesRes?.response || []), + [categoryPackagesRes] + ); + + const allEprPackages = useMemo( + () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), + [allCategoryPackagesRes] + ); + + const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations(); + + const mergedEprPackages: Array = + useMergeEprPackagesWithReplacements( + eprPackages || [], + replacementCustomIntegrations || [], + selectedCategory as IntegrationCategory + ); + + const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } = + useGetAppendCustomIntegrations(); + + const filteredAddableIntegrations = appendCustomIntegrations + ? appendCustomIntegrations.filter((integration: CustomIntegration) => { + if (!selectedCategory) { + return true; + } + return integration.categories.indexOf(selectedCategory as IntegrationCategory) >= 0; + }) + : []; + + const eprAndCustomPackages: Array = [ + ...mergedEprPackages, + ...filteredAddableIntegrations, + ]; + + eprAndCustomPackages.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + + const categories = useMemo(() => { + const eprAndCustomCategories: CategoryFacet[] = + isLoadingCategories || + isLoadingAppendCustomIntegrations || + !appendCustomIntegrations || + !categoriesRes + ? [] + : mergeAndReplaceCategoryCounts( + categoriesRes.response as CategoryFacet[], + appendCustomIntegrations + ); + + return [ + { + id: '', + count: (allEprPackages?.length || 0) + (appendCustomIntegrations?.length || 0), + }, + ...(eprAndCustomCategories ? eprAndCustomCategories : []), + ] as CategoryFacet[]; + }, [ + allEprPackages?.length, + appendCustomIntegrations, + categoriesRes, + isLoadingAppendCustomIntegrations, + isLoadingCategories, + ]); + + if (!isLoadingCategories && !categoryExists(selectedCategory, categories)) { + history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); + return null; + } + + const controls = categories ? ( + { + setSelectedCategory(id); + }} + /> + ) : null; + + const cards = eprAndCustomPackages.map((item) => { + return mapToCard(getAbsolutePath, getHref, item); + }); + + return ( + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx index d60ccd93b8db19..3eba17d6627a11 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/category_facets.tsx @@ -21,19 +21,21 @@ interface ALL_CATEGORY { export type CategoryFacet = IntegrationCategoryCount | ALL_CATEGORY; +export interface Props { + showCounts: boolean; + isLoading?: boolean; + categories: CategoryFacet[]; + selectedCategory: string; + onCategoryChange: (category: CategoryFacet) => unknown; +} + export function CategoryFacets({ showCounts, isLoading, categories, selectedCategory, onCategoryChange, -}: { - showCounts: boolean; - isLoading?: boolean; - categories: CategoryFacet[]; - selectedCategory: string; - onCategoryChange: (category: CategoryFacet) => unknown; -}) { +}: Props) { const controls = ( {isLoading ? ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index 06cf85699bf673..bbebf9e90b16c0 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -5,34 +5,12 @@ * 2.0. */ -import React, { memo, useMemo, Fragment } from 'react'; -import { Switch, Route, useLocation, useHistory, useParams } from 'react-router-dom'; -import semverLt from 'semver/functions/lt'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React, { memo } from 'react'; +import { Switch, Route } from 'react-router-dom'; -import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; - -import { installationStatuses } from '../../../../../../../common/constants'; import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../../constants'; -import { - INTEGRATIONS_ROUTING_PATHS, - INTEGRATIONS_SEARCH_QUERYPARAM, - pagePathGetters, -} from '../../../../constants'; -import { - useGetCategories, - useGetPackages, - useBreadcrumbs, - useGetAppendCustomIntegrations, - useGetReplacementCustomIntegrations, - useLink, - useStartServices, -} from '../../../../hooks'; -import { doesPackageHaveIntegrations } from '../../../../services'; +import { INTEGRATIONS_ROUTING_PATHS, INTEGRATIONS_SEARCH_QUERYPARAM } from '../../../../constants'; import { DefaultLayout } from '../../../../layouts'; -import type { PackageList } from '../../../../types'; -import { PackageListGrid } from '../../components/package_list_grid'; import type { CustomIntegration } from '../../../../../../../../../../src/plugins/custom_integrations/common'; @@ -40,47 +18,47 @@ import type { PackageListItem } from '../../../../types'; import type { IntegrationCardItem } from '../../../../../../../common/types/models'; -import type { IntegrationCategory } from '../../../../../../../../../../src/plugins/custom_integrations/common'; - -import { useMergeEprPackagesWithReplacements } from '../../../../../../hooks/use_merge_epr_with_replacements'; - -import { mergeAndReplaceCategoryCounts } from './util'; -import { CategoryFacets } from './category_facets'; import type { CategoryFacet } from './category_facets'; +import { InstalledPackages } from './installed_packages'; +import { AvailablePackages } from './available_packages'; export interface CategoryParams { category?: string; } -function getParams(params: CategoryParams, search: string) { +export const getParams = (params: CategoryParams, search: string) => { const { category } = params; const selectedCategory = category || ''; const queryParams = new URLSearchParams(search); const searchParam = queryParams.get(INTEGRATIONS_SEARCH_QUERYPARAM) || ''; return { selectedCategory, searchParam }; -} +}; -function categoryExists(category: string, categories: CategoryFacet[]) { +export const categoryExists = (category: string, categories: CategoryFacet[]) => { return categories.some((c) => c.id === category); -} +}; -function mapToCard( +export const mapToCard = ( getAbsolutePath: (p: string) => string, getHref: (page: StaticPage | DynamicPage, values?: DynamicPagePathValues) => string, item: CustomIntegration | PackageListItem -): IntegrationCardItem { +): IntegrationCardItem => { let uiInternalPathUrl; + if (item.type === 'ui_link') { uiInternalPathUrl = getAbsolutePath(item.uiInternalPath); } else { let urlVersion = item.version; + if ('savedObject' in item) { urlVersion = item.savedObject.attributes.version || item.version; } + const url = getHref('integration_details_overview', { pkgkey: `${item.name}-${urlVersion}`, ...(item.integration ? { integration: item.integration } : {}), }); + uiInternalPathUrl = url; } @@ -88,14 +66,14 @@ function mapToCard( id: `${item.type === 'ui_link' ? 'ui_link' : 'epr'}-${item.id}`, description: item.description, icons: !item.icons || !item.icons.length ? [] : item.icons, + title: item.title, + url: uiInternalPathUrl, integration: 'integration' in item ? item.integration || '' : '', name: 'name' in item ? item.name || '' : '', - title: item.title, version: 'version' in item ? item.version || '' : '', release: 'release' in item ? item.release : undefined, - url: uiInternalPathUrl, }; -} +}; export const EPMHomePage: React.FC = memo(() => { return ( @@ -113,305 +91,3 @@ export const EPMHomePage: React.FC = memo(() => { ); }); - -// Packages can export multiple integrations, aka `policy_templates` -// In the case where packages ship >1 `policy_templates`, we flatten out the -// list of packages by bringing all integrations to top-level so that -// each integration is displayed as its own tile -const packageListToIntegrationsList = (packages: PackageList): PackageList => { - return packages.reduce((acc: PackageList, pkg) => { - const { policy_templates: policyTemplates = [], ...restOfPackage } = pkg; - return [ - ...acc, - restOfPackage, - ...(doesPackageHaveIntegrations(pkg) - ? policyTemplates.map((integration) => { - const { name, title, description, icons } = integration; - return { - ...restOfPackage, - id: `${restOfPackage}-${name}`, - integration: name, - title, - description, - icons: icons || restOfPackage.icons, - }; - }) - : []), - ]; - }, []); -}; - -const InstalledPackages: React.FC = memo(() => { - useBreadcrumbs('integrations_installed'); - const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages({ - experimental: true, - }); - const { getHref, getAbsolutePath } = useLink(); - const { docLinks } = useStartServices(); - - const { selectedCategory, searchParam } = getParams( - useParams(), - useLocation().search - ); - const history = useHistory(); - function setSelectedCategory(categoryId: string) { - const url = pagePathGetters.integrations_installed({ - category: categoryId, - searchTerm: searchParam, - })[1]; - history.push(url); - } - function setSearchTerm(search: string) { - // Use .replace so the browser's back button is not tied to single keystroke - history.replace( - pagePathGetters.integrations_installed({ - category: selectedCategory, - searchTerm: search, - })[1] - ); - } - - const allInstalledPackages = useMemo( - () => - (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), - [allPackages?.response] - ); - - const updatablePackages = useMemo( - () => - allInstalledPackages.filter( - (item) => - 'savedObject' in item && semverLt(item.savedObject.attributes.version, item.version) - ), - [allInstalledPackages] - ); - - const title = useMemo( - () => - i18n.translate('xpack.fleet.epmList.installedTitle', { - defaultMessage: 'Installed integrations', - }), - [] - ); - - const categories: CategoryFacet[] = useMemo( - () => [ - { - id: '', - count: allInstalledPackages.length, - }, - { - id: 'updates_available', - count: updatablePackages.length, - }, - ], - [allInstalledPackages.length, updatablePackages.length] - ); - - if (!categoryExists(selectedCategory, categories)) { - history.replace( - pagePathGetters.integrations_installed({ category: '', searchTerm: searchParam })[1] - ); - return null; - } - - const controls = ( - setSelectedCategory(id)} - /> - ); - - const cards = ( - selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages - ).map((item) => { - return mapToCard(getAbsolutePath, getHref, item); - }); - - const link = ( - - {i18n.translate('xpack.fleet.epmList.availableCalloutBlogText', { - defaultMessage: 'announcement blog post', - })} - - ); - const calloutMessage = ( - - ); - - const callout = - selectedCategory === 'updates_available' ? null : ( - - - -

{calloutMessage}

-
-
- ); - - return ( - - ); -}); - -const AvailablePackages: React.FC = memo(() => { - useBreadcrumbs('integrations_all'); - const { selectedCategory, searchParam } = getParams( - useParams(), - useLocation().search - ); - const history = useHistory(); - const { getHref, getAbsolutePath } = useLink(); - - function setSelectedCategory(categoryId: string) { - const url = pagePathGetters.integrations_all({ - category: categoryId, - searchTerm: searchParam, - })[1]; - history.push(url); - } - function setSearchTerm(search: string) { - // Use .replace so the browser's back button is not tied to single keystroke - history.replace( - pagePathGetters.integrations_all({ category: selectedCategory, searchTerm: search })[1] - ); - } - - const { data: allCategoryPackagesRes, isLoading: isLoadingAllPackages } = useGetPackages({ - category: '', - }); - const { data: categoryPackagesRes, isLoading: isLoadingCategoryPackages } = useGetPackages({ - category: selectedCategory, - }); - const { data: categoriesRes, isLoading: isLoadingCategories } = useGetCategories({ - include_policy_templates: true, - }); - - const eprPackages = useMemo( - () => packageListToIntegrationsList(categoryPackagesRes?.response || []), - [categoryPackagesRes] - ); - - const allEprPackages = useMemo( - () => packageListToIntegrationsList(allCategoryPackagesRes?.response || []), - [allCategoryPackagesRes] - ); - - const { value: replacementCustomIntegrations } = useGetReplacementCustomIntegrations(); - - const mergedEprPackages: Array = - useMergeEprPackagesWithReplacements( - eprPackages || [], - replacementCustomIntegrations || [], - selectedCategory as IntegrationCategory - ); - - const { loading: isLoadingAppendCustomIntegrations, value: appendCustomIntegrations } = - useGetAppendCustomIntegrations(); - const filteredAddableIntegrations = appendCustomIntegrations - ? appendCustomIntegrations.filter((integration: CustomIntegration) => { - if (!selectedCategory) { - return true; - } - return integration.categories.indexOf(selectedCategory as IntegrationCategory) >= 0; - }) - : []; - - const title = useMemo( - () => - i18n.translate('xpack.fleet.epmList.allTitle', { - defaultMessage: 'Browse by category', - }), - [] - ); - - const eprAndCustomPackages: Array = [ - ...mergedEprPackages, - ...filteredAddableIntegrations, - ]; - eprAndCustomPackages.sort((a, b) => { - return a.title.localeCompare(b.title); - }); - - const categories = useMemo(() => { - const eprAndCustomCategories: CategoryFacet[] = - isLoadingCategories || - isLoadingAppendCustomIntegrations || - !appendCustomIntegrations || - !categoriesRes - ? [] - : mergeAndReplaceCategoryCounts( - categoriesRes.response as CategoryFacet[], - appendCustomIntegrations - ); - return [ - { - id: '', - count: (allEprPackages?.length || 0) + (appendCustomIntegrations?.length || 0), - }, - ...(eprAndCustomCategories ? eprAndCustomCategories : []), - ] as CategoryFacet[]; - }, [ - allEprPackages?.length, - appendCustomIntegrations, - categoriesRes, - isLoadingAppendCustomIntegrations, - isLoadingCategories, - ]); - - if (!isLoadingCategories && !categoryExists(selectedCategory, categories)) { - history.replace(pagePathGetters.integrations_all({ category: '', searchTerm: searchParam })[1]); - return null; - } - - const controls = categories ? ( - { - setSelectedCategory(id); - }} - /> - ) : null; - - const cards = eprAndCustomPackages.map((item) => { - return mapToCard(getAbsolutePath, getHref, item); - }); - - return ( - - ); -}); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx new file mode 100644 index 00000000000000..404e8820f90b77 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/installed_packages.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { useLocation, useHistory, useParams } from 'react-router-dom'; +import semverLt from 'semver/functions/lt'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { installationStatuses } from '../../../../../../../common/constants'; +import { pagePathGetters } from '../../../../constants'; +import { useGetPackages, useBreadcrumbs, useLink, useStartServices } from '../../../../hooks'; +import { PackageListGrid } from '../../components/package_list_grid'; + +import type { CategoryFacet } from './category_facets'; +import { CategoryFacets } from './category_facets'; + +import type { CategoryParams } from '.'; +import { getParams, categoryExists, mapToCard } from '.'; + +const AnnouncementLink = () => { + const { docLinks } = useStartServices(); + + return ( + + {i18n.translate('xpack.fleet.epmList.availableCalloutBlogText', { + defaultMessage: 'announcement blog post', + })} + + ); +}; + +const Callout = () => ( + +

+ , + }} + /> +

+
+); + +const title = i18n.translate('xpack.fleet.epmList.installedTitle', { + defaultMessage: 'Installed integrations', +}); + +// TODO: clintandrewhall - this component is hard to test due to the hooks, particularly those that use `http` +// or `location` to load data. Ideally, we'll split this into "connected" and "pure" components. +export const InstalledPackages: React.FC = memo(() => { + useBreadcrumbs('integrations_installed'); + + const { data: allPackages, isLoading } = useGetPackages({ + experimental: true, + }); + + const { getHref, getAbsolutePath } = useLink(); + + const { selectedCategory, searchParam } = getParams( + useParams(), + useLocation().search + ); + + const history = useHistory(); + + function setSelectedCategory(categoryId: string) { + const url = pagePathGetters.integrations_installed({ + category: categoryId, + searchTerm: searchParam, + })[1]; + + history.push(url); + } + + function setSearchTerm(search: string) { + // Use .replace so the browser's back button is not tied to single keystroke + history.replace( + pagePathGetters.integrations_installed({ + category: selectedCategory, + searchTerm: search, + })[1] + ); + } + + const allInstalledPackages = useMemo( + () => + (allPackages?.response || []).filter((pkg) => pkg.status === installationStatuses.Installed), + [allPackages?.response] + ); + + const updatablePackages = useMemo( + () => + allInstalledPackages.filter( + (item) => + 'savedObject' in item && semverLt(item.savedObject.attributes.version, item.version) + ), + [allInstalledPackages] + ); + + const categories: CategoryFacet[] = useMemo( + () => [ + { + id: '', + count: allInstalledPackages.length, + }, + { + id: 'updates_available', + count: updatablePackages.length, + }, + ], + [allInstalledPackages.length, updatablePackages.length] + ); + + if (!categoryExists(selectedCategory, categories)) { + history.replace( + pagePathGetters.integrations_installed({ category: '', searchTerm: searchParam })[1] + ); + + return null; + } + + const controls = ( + setSelectedCategory(id)} + /> + ); + + const cards = ( + selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages + ).map((item) => mapToCard(getAbsolutePath, getHref, item)); + + const callout = selectedCategory === 'updates_available' ? null : ; + + return ( + + ); +});